diff --git a/.changeset/calm-routes-return.md b/.changeset/calm-routes-return.md new file mode 100644 index 0000000000..72b281971c --- /dev/null +++ b/.changeset/calm-routes-return.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep cross-app `ask_app_status` polling attached to the original task route with an encrypted task handle, report the configured app identity in standalone MCP discovery, and reject unknown cross-app targets instead of running them locally. diff --git a/packages/core/docs/content/external-agents.mdx b/packages/core/docs/content/external-agents.mdx index b6f5399837..5cb4208f5a 100644 --- a/packages/core/docs/content/external-agents.mdx +++ b/packages/core/docs/content/external-agents.mdx @@ -535,13 +535,13 @@ See [MCP Apps](/docs/mcp-apps) for the full bridge details — transplant vs con On top of the per-action tools the MCP server exposes a stable verb set, so an external agent has a predictable surface without guessing per-app action names: -| Tool | Side effects | Returns | -| -------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | none | workspace apps + their URLs / running state | -| `open_app({ app, view?, path?, params?, embed? })` | none | a deep link or same-origin route; `embed: true` renders the full app inline where supported | -| `ask_app({ app, message, async?, maxWaitMs? })` | agent loop | routes a natural-language task to that app's in-app agent; long work returns a durable `taskId` for `ask_app_status` polling | -| `create_workspace_app({ name, template })` | scaffolds | a new app booted via the workspace path, plus its running URL + deep link | -| `list_templates` | none | the allow-listed templates only | +| Tool | Side effects | Returns | +| -------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | none | workspace apps + their URLs / running state | +| `open_app({ app, view?, path?, params?, embed? })` | none | a deep link or same-origin route; `embed: true` renders the full app inline where supported | +| `ask_app({ app, message, async?, maxWaitMs? })` | agent loop | routes a natural-language task to that app's in-app agent; long work returns a durable `taskHandle` plus a legacy `taskId` for `ask_app_status` polling | +| `create_workspace_app({ name, template })` | scaffolds | a new app booted via the workspace path, plus its running URL + deep link | +| `list_templates` | none | the allow-listed templates only | `create_workspace_app` rejects any non-allow-listed template — the public template allow-list in `packages/shared-app-config/templates.ts` is authoritative and CI-guarded; an external agent cannot widen it. A same-named template action overrides a builtin (template-over-core precedence). Disable the whole set with `MCPConfig.builtinCrossAppTools: false`. @@ -551,12 +551,18 @@ For fast ChatGPT/Claude handoffs, the ideal path is direct: call the action that ### Long-running agent requests +When `ask_app` returns a `taskHandle`, pass that opaque value back unchanged to +`ask_app_status`; it preserves the original task route even if app discovery +changes between calls. The older `app` plus `taskId` arguments remain accepted +during migration. + MCP hosts and gateways do not share one long tool-call timeout; some enforce a five-minute ceiling and others are configurable. Do not design an external agent integration around a ten-minute blocking `tools/call`. `ask-agent` and -`ask_app` acknowledge hosted work quickly, then return a durable `taskId` when -the inline wait expires. Poll with `ask_app_status` using the returned `app` -and `taskId`; the result includes `pollAfterMs` as a host-friendly interval. +`ask_app` acknowledge hosted work quickly, then return a durable `taskHandle` +plus a legacy `taskId` when the inline wait expires. Poll with `ask_app_status` +by replaying the returned `poll.arguments` unchanged, especially the +`taskHandle`; the result includes `pollAfterMs` as a host-friendly interval. The task must be created before the first response can time out, so a polling URL returned only after a long call is not a reliable recovery contract. @@ -564,7 +570,9 @@ If a bounded transient status read is unavailable, `ask_app_status` can return `status: "unknown"`, `statusRead: "unavailable"`, `retryable: true`, the original `app` and `taskId`, and `pollAfterMs` / `poll`. This means the status read is unavailable—not that the durable task failed. Retry `ask_app_status` -with that exact `app` and `taskId`; never resubmit `ask_app` to recover, because +with the exact returned `poll.arguments`, including `taskHandle` when present; +legacy results without a handle must reuse the same `app` and `taskId`. Never +resubmit `ask_app` to recover, because submission can duplicate work. Permanent authentication, not-found, and protocol failures remain errors. diff --git a/packages/core/docs/content/locales/ar-SA/external-agents.mdx b/packages/core/docs/content/locales/ar-SA/external-agents.mdx index c6a545e4a5..b5c8e3b244 100644 --- a/packages/core/docs/content/locales/ar-SA/external-agents.mdx +++ b/packages/core/docs/content/locales/ar-SA/external-agents.mdx @@ -509,13 +509,13 @@ Claude Code calls: manage-draft(to: "john@example.com", subject: "Q3 Report", bo علاوة على أدوات تنفيذ الإجراء، يعرض خادم MCP مجموعة أفعال ثابتة، بحيث يتمتع الوكيل الخارجي بسطح يمكن التنبؤ به دون تخمين أسماء الإجراءات لكل تطبيق: -| الأداة | الآثار الجانبية | المرتجعات | -| -------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | لا شيء | تطبيقات مساحة العمل + URL/حالة التشغيل | -| `open_app({ app, view?, path?, params?, embed? })` | لا شيء | رابط عميق أو مسار من نفس المصدر؛ يعرض `embed: true` التطبيق الكامل مضمنًا حيث يكون مدعومًا | -| `ask_app({ app, message, async?, maxWaitMs? })` | حلقة الوكيل | توجيه مهمة باللغة الطبيعية إلى الوكيل داخل التطبيق لذلك التطبيق؛ تُرجع المهام الطويلة `taskId` دائمًا لاستطلاع `ask_app_status` | -| `create_workspace_app({ name, template })` | السقالات | تطبيق جديد تم تشغيله عبر مسار مساحة العمل، بالإضافة إلى URL قيد التشغيل + رابط عميق | -| `list_templates` | لا شيء | النماذج المسموح بها فقط | +| الأداة | الآثار الجانبية | المرتجعات | +| -------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | لا شيء | تطبيقات مساحة العمل + URL/حالة التشغيل | +| `open_app({ app, view?, path?, params?, embed? })` | لا شيء | رابط عميق أو مسار من نفس المصدر؛ يعرض `embed: true` التطبيق الكامل مضمنًا حيث يكون مدعومًا | +| `ask_app({ app, message, async?, maxWaitMs? })` | حلقة الوكيل | توجيه مهمة باللغة الطبيعية إلى الوكيل داخل التطبيق لذلك التطبيق؛ تُرجع المهام الطويلة `taskHandle` مع `taskId` قديم لاستطلاع `ask_app_status` | +| `create_workspace_app({ name, template })` | السقالات | تطبيق جديد تم تشغيله عبر مسار مساحة العمل، بالإضافة إلى URL قيد التشغيل + رابط عميق | +| `list_templates` | لا شيء | النماذج المسموح بها فقط | يرفض `create_workspace_app` أي قالب غير مدرج في القائمة المسموح بها - القائمة المسموح بها للقالب العام في `packages/shared-app-config/templates.ts` موثوقة ومحمية بواسطة CI؛ ولا يمكن لعامل خارجي توسيعه. يتجاوز إجراء القالب الذي يحمل نفس الاسم الإجراء المدمج (أسبقية القالب على المركز الأساسي). قم بتعطيل المجموعة بأكملها باستخدام `MCPConfig.builtinCrossAppTools: false`. @@ -525,13 +525,18 @@ Claude Code calls: manage-draft(to: "john@example.com", subject: "Q3 Report", bo ### الطلبات الوكيلية طويلة الأمد -لا تشترك مضيفات وبوابات MCP في مهلة زمنية واحدة طويلة لاستدعاء الأداة؛ يفرض بعضها حدًا أقصى مدته خمس دقائق بينما يكون بعضها الآخر قابلاً للتكوين. لا تصمم تكامل الوكيل الخارجي بالاعتماد على استدعاء `tools/call` محظور لمدة عشر دقائق. يقر كل من `ask-agent` و`ask_app` بالعمل المستضاف بسرعة، ثم يُرجعان `taskId` دائمًا عند انتهاء مهلة الانتظار المضمّن. استطلع باستخدام `ask_app_status` مستخدمًا `app` و`taskId` المُرجعين؛ وتتضمن النتيجة `pollAfterMs` كفاصل زمني ملائم للمضيف. يجب إنشاء المهمة قبل أن تنتهي مهلة الاستجابة الأولى، لذا فإن رابط الاستطلاع الذي يُرجع فقط بعد استدعاء طويل ليس عقد استرداد يمكن الاعتماد عليه. +عندما تُرجع `ask_app` قيمة `taskHandle`، مرّر هذه القيمة المعتمة دون تغيير إلى +`ask_app_status`؛ فهي تحفظ مسار المهمة الأصلي حتى إذا تغيّر اكتشاف التطبيقات بين +الاستدعاءات. تظل وسيطتا `app` و`taskId` الأقدم مقبولتين خلال فترة الانتقال. + +لا تشترك مضيفات وبوابات MCP في مهلة زمنية واحدة طويلة لاستدعاء الأداة؛ يفرض بعضها حدًا أقصى مدته خمس دقائق بينما يكون بعضها الآخر قابلاً للتكوين. لا تصمم تكامل الوكيل الخارجي بالاعتماد على استدعاء `tools/call` محظور لمدة عشر دقائق. يقر كل من `ask-agent` و`ask_app` بالعمل المستضاف بسرعة، ثم يُرجعان `taskHandle` مع `taskId` قديم عند انتهاء مهلة الانتظار المضمّن. استطلع باستخدام `ask_app_status` عبر إعادة `poll.arguments` المُرجعة دون تغيير، ولا سيما `taskHandle`؛ وتتضمن النتيجة `pollAfterMs` كفاصل زمني ملائم للمضيف. يجب إنشاء المهمة قبل أن تنتهي مهلة الاستجابة الأولى، لذا فإن رابط الاستطلاع الذي يُرجع فقط بعد استدعاء طويل ليس عقد استرداد يمكن الاعتماد عليه. عند عدم إتاحة قراءة حالة عابرة ومحدودة المدة، يمكن أن تُرجع `ask_app_status` القيم `status: "unknown"` و`statusRead: "unavailable"` و`retryable: true`، إلى جانب `app` و`taskId` الأصليين و`pollAfterMs` / `poll`. يعني ذلك أن قراءة الحالة غير -متاحة، وليس أن المهمة الدائمة قد فشلت. أعد محاولة `ask_app_status` باستخدام `app` -و`taskId` نفسيهما تمامًا؛ ولا تعِد إرسال `ask_app` للاسترداد مطلقًا، إذ قد يكرر +متاحة، وليس أن المهمة الدائمة قد فشلت. أعد محاولة `ask_app_status` باستخدام +`poll.arguments` المُرجعة نفسها، بما فيها `taskHandle` عند وجودها؛ أما النتائج القديمة +من دون مقبض فيجب أن تعيد استخدام `app` و`taskId` نفسيهما. لا تعِد إرسال `ask_app` للاسترداد مطلقًا، إذ قد يكرر الإرسال العمل. تظل أخطاء المصادقة الدائمة أو عدم العثور أو البروتوكول أخطاءً. ### جولة لكل تطبيق {#tour} diff --git a/packages/core/docs/content/locales/ar-SA/mcp-protocol.mdx b/packages/core/docs/content/locales/ar-SA/mcp-protocol.mdx index d433000a64..ee9f3d536d 100644 --- a/packages/core/docs/content/locales/ar-SA/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/ar-SA/mcp-protocol.mdx @@ -224,7 +224,11 @@ Auto-mounted on every app. Speaks the standard MCP handshake (`initialize` → ` { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/de-DE/external-agents.mdx b/packages/core/docs/content/locales/de-DE/external-agents.mdx index 4488206ba2..8286f99a6e 100644 --- a/packages/core/docs/content/locales/de-DE/external-agents.mdx +++ b/packages/core/docs/content/locales/de-DE/external-agents.mdx @@ -512,13 +512,13 @@ Siehe [MCP Apps](/docs/mcp-apps) für die vollständigen Bridge-Details – Tran Zusätzlich zu den Tools pro Aktion stellt der MCP-Server einen stabilen Verbsatz bereit, sodass ein externer Agent eine vorhersehbare Oberfläche hat, ohne die Aktionsnamen pro App erraten zu müssen: -| Werkzeug | Nebenwirkungen | Retouren | -| -------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `list_apps` | keine | Workspace-Apps + ihre URLs/Ausführungsstatus | -| `open_app({ app, view?, path?, params?, embed? })` | keine | ein Deep Link oder eine Route mit demselben Ursprung; `embed: true` rendert die vollständige App inline, sofern dies unterstützt wird | -| `ask_app({ app, message, async?, maxWaitMs? })` | Agentenschleife | leitet eine Aufgabe in natürlicher Sprache an den In-App-Agenten dieser App weiter; bei länger laufender Arbeit wird eine dauerhafte `taskId` für das Polling mit `ask_app_status` zurückgegeben | -| `create_workspace_app({ name, template })` | Gerüste | Eine neue App, die über den Workspace-Pfad gestartet wurde, plus die Ausführung von URL + Deep Link | -| `list_templates` | keine | Nur die auf der Zulassungsliste aufgeführten Vorlagen | +| Werkzeug | Nebenwirkungen | Retouren | +| -------------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | keine | Workspace-Apps + ihre URLs/Ausführungsstatus | +| `open_app({ app, view?, path?, params?, embed? })` | keine | ein Deep Link oder eine Route mit demselben Ursprung; `embed: true` rendert die vollständige App inline, sofern dies unterstützt wird | +| `ask_app({ app, message, async?, maxWaitMs? })` | Agentenschleife | leitet eine Aufgabe in natürlicher Sprache an den In-App-Agenten dieser App weiter; bei länger laufender Arbeit werden ein dauerhaftes `taskHandle` und eine veraltete `taskId` für das Polling mit `ask_app_status` zurückgegeben | +| `create_workspace_app({ name, template })` | Gerüste | Eine neue App, die über den Workspace-Pfad gestartet wurde, plus die Ausführung von URL + Deep Link | +| `list_templates` | keine | Nur die auf der Zulassungsliste aufgeführten Vorlagen | `create_workspace_app` lehnt alle nicht auf der Zulassungsliste aufgeführten Vorlagen ab – die öffentliche Zulassungsliste für Vorlagen in `packages/shared-app-config/templates.ts` ist maßgeblich und CI-geschützt; ein externer Agent kann es nicht erweitern. Eine gleichnamige Vorlagenaktion überschreibt eine integrierte Aktion (Vorrang der Vorlage vor dem Kern). Deaktivieren Sie das gesamte Set mit `MCPConfig.builtinCrossAppTools: false`. @@ -528,15 +528,21 @@ Für schnelle ChatGPT/Claude-Übergaben ist der ideale Pfad direkt: Rufen Sie di ### Lang laufende Agent-Anfragen -MCP-Hosts und -Gateways teilen sich kein einheitliches Zeitlimit für Tool-Aufrufe; manche erzwingen eine Obergrenze von fünf Minuten, andere sind konfigurierbar. Planen Sie eine externe Agentenintegration nicht um einen zehnminütigen, blockierenden `tools/call` herum. `ask-agent` und `ask_app` bestätigen gehostete Arbeit schnell und geben dann eine dauerhafte `taskId` zurück, sobald die Inline-Wartezeit abläuft. Fragen Sie mit `ask_app_status` unter Verwendung der zurückgegebenen `app` und `taskId` ab; das Ergebnis enthält `pollAfterMs` als host-freundliches Intervall. Die Aufgabe muss erstellt werden, bevor die erste Antwort in ein Timeout laufen kann – eine Polling-URL, die erst nach einem langen Aufruf zurückgegeben wird, ist kein zuverlässiger Recovery-Vertrag. +Wenn `ask_app` ein `taskHandle` zurückgibt, übergeben Sie diesen undurchsichtigen +Wert unverändert an `ask_app_status`. Er bewahrt die ursprüngliche Task-Route, +auch wenn sich die App-Erkennung zwischen Aufrufen ändert. Die älteren Argumente +`app` und `taskId` bleiben während der Migration gültig. + +MCP-Hosts und -Gateways teilen sich kein einheitliches Zeitlimit für Tool-Aufrufe; manche erzwingen eine Obergrenze von fünf Minuten, andere sind konfigurierbar. Planen Sie eine externe Agentenintegration nicht um einen zehnminütigen, blockierenden `tools/call` herum. `ask-agent` und `ask_app` bestätigen gehostete Arbeit schnell und geben dann ein dauerhaftes `taskHandle` sowie eine veraltete `taskId` zurück, sobald die Inline-Wartezeit abläuft. Fragen Sie mit `ask_app_status` ab, indem Sie die zurückgegebenen `poll.arguments`, insbesondere das `taskHandle`, unverändert wiederverwenden; das Ergebnis enthält `pollAfterMs` als host-freundliches Intervall. Die Aufgabe muss erstellt werden, bevor die erste Antwort in ein Timeout laufen kann – eine Polling-URL, die erst nach einem langen Aufruf zurückgegeben wird, ist kein zuverlässiger Recovery-Vertrag. Ist ein begrenzter, vorübergehender Statusabruf nicht verfügbar, kann `ask_app_status` `status: "unknown"`, `statusRead: "unavailable"` und `retryable: true` sowie das ursprüngliche `app`, `taskId` und `pollAfterMs` / `poll` zurückgeben. Das bedeutet, dass der Statusabruf nicht verfügbar ist – nicht, dass die dauerhafte Aufgabe fehlgeschlagen ist. Wiederholen Sie -`ask_app_status` mit genau demselben `app` und `taskId`; reichen Sie zur -Wiederherstellung niemals erneut `ask_app` ein, da dies Arbeit duplizieren kann. +`ask_app_status` mit genau den zurückgegebenen `poll.arguments`, einschließlich +`taskHandle`, wenn vorhanden. Ältere Ergebnisse ohne Handle müssen dasselbe `app` +und dieselbe `taskId` wiederverwenden. Reichen Sie zur Wiederherstellung niemals erneut `ask_app` ein, da dies Arbeit duplizieren kann. Dauerhafte Authentifizierungs-, Nicht-gefunden- und Protokollfehler bleiben Fehler. ### Tour pro App {#tour} diff --git a/packages/core/docs/content/locales/de-DE/mcp-protocol.mdx b/packages/core/docs/content/locales/de-DE/mcp-protocol.mdx index c047716acb..7494a10347 100644 --- a/packages/core/docs/content/locales/de-DE/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/de-DE/mcp-protocol.mdx @@ -224,7 +224,11 @@ Der Agent führt die gleiche Schleife aus wie der interaktive Chat – er kann m { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/es-ES/external-agents.mdx b/packages/core/docs/content/locales/es-ES/external-agents.mdx index 8b4747d224..077dab10ce 100644 --- a/packages/core/docs/content/locales/es-ES/external-agents.mdx +++ b/packages/core/docs/content/locales/es-ES/external-agents.mdx @@ -512,13 +512,13 @@ Consulte [MCP Apps](/docs/mcp-apps) para obtener detalles completos del puente: Además de las herramientas por acción, el servidor MCP expone un conjunto de verbos estables, por lo que un agente externo tiene una superficie predecible sin adivinar los nombres de acción por aplicación: -| Herramienta | Efectos secundarios | Devoluciones | -| -------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | ninguno | aplicaciones de espacio de trabajo + sus URL/estado de ejecución | -| `open_app({ app, view?, path?, params?, embed? })` | ninguno | un enlace profundo o una ruta del mismo origen; `embed: true` renderiza la aplicación completa en línea donde sea compatible | -| `ask_app({ app, message, async?, maxWaitMs? })` | bucle de agente | enruta una tarea en lenguaje natural al agente dentro de la aplicación de esa aplicación; el trabajo largo devuelve un `taskId` duradero para sondear con `ask_app_status` | -| `create_workspace_app({ name, template })` | andamios | una nueva aplicación iniciada a través de la ruta del espacio de trabajo, además de su URL en ejecución + enlace profundo | -| `list_templates` | ninguno | solo las plantillas incluidas en la lista permitida | +| Herramienta | Efectos secundarios | Devoluciones | +| -------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | ninguno | aplicaciones de espacio de trabajo + sus URL/estado de ejecución | +| `open_app({ app, view?, path?, params?, embed? })` | ninguno | un enlace profundo o una ruta del mismo origen; `embed: true` renderiza la aplicación completa en línea donde sea compatible | +| `ask_app({ app, message, async?, maxWaitMs? })` | bucle de agente | enruta una tarea en lenguaje natural al agente dentro de la aplicación de esa aplicación; el trabajo largo devuelve un `taskHandle` duradero y un `taskId` heredado para sondear con `ask_app_status` | +| `create_workspace_app({ name, template })` | andamios | una nueva aplicación iniciada a través de la ruta del espacio de trabajo, además de su URL en ejecución + enlace profundo | +| `list_templates` | ninguno | solo las plantillas incluidas en la lista permitida | `create_workspace_app` rechaza cualquier plantilla no incluida en la lista de permitidos: la lista de plantillas públicas permitidas en `packages/shared-app-config/templates.ts` tiene autoridad y está protegida por CI; un agente externo no puede ampliarlo. Una acción de plantilla con el mismo nombre anula una acción incorporada (precedencia de plantilla sobre núcleo). Desactive todo el conjunto con `MCPConfig.builtinCrossAppTools: false`. @@ -528,13 +528,19 @@ Para transferencias rápidas de ChatGPT/Claude, la ruta ideal es directa: llame ### Solicitudes de agente de larga duración +Cuando `ask_app` devuelva un `taskHandle`, pase ese valor opaco sin modificar a +`ask_app_status`; conserva la ruta original de la tarea aunque cambie el +descubrimiento de aplicaciones entre llamadas. Los argumentos anteriores `app` +y `taskId` siguen aceptándose durante la migración. + Los hosts y gateways MCP no comparten un único tiempo de espera largo para las llamadas a herramientas; algunos aplican un límite de cinco minutos y otros lo hacen configurable. No diseñe una integración de agente externo alrededor de una `tools/call` bloqueante de diez minutos. `ask-agent` y `ask_app` confirman -el trabajo alojado rápidamente y luego devuelven un `taskId` duradero cuando -expira la espera en línea. Sondee con `ask_app_status` usando el `app` y el -`taskId` devueltos; el resultado incluye `pollAfterMs` como un intervalo apto +el trabajo alojado rápidamente y luego devuelven un `taskHandle` duradero y un +`taskId` heredado cuando expira la espera en línea. Sondee con `ask_app_status` +reutilizando sin cambios los `poll.arguments` devueltos, especialmente el +`taskHandle`; el resultado incluye `pollAfterMs` como un intervalo apto para hosts. La tarea debe crearse antes de que la primera respuesta pueda agotar el tiempo de espera, por lo que una URL de sondeo devuelta solo después de una llamada larga no es un contrato de recuperación confiable. @@ -544,7 +550,9 @@ Si no está disponible una lectura de estado transitoria y acotada, `statusRead: "unavailable"`, `retryable: true`, el `app` y `taskId` originales, y `pollAfterMs` / `poll`. Esto significa que la lectura de estado no está disponible, no que la tarea duradera haya fallado. Vuelva a intentar -`ask_app_status` con exactamente el mismo `app` y `taskId`; nunca reenvíe +`ask_app_status` con los mismos `poll.arguments`, incluido el `taskHandle` cuando +esté presente. Los resultados heredados sin identificador deben reutilizar el mismo +`app` y `taskId`; nunca reenvíe `ask_app` para recuperarse, porque el envío puede duplicar el trabajo. Los errores permanentes de autenticación, de no encontrado y de protocolo siguen siendo errores. diff --git a/packages/core/docs/content/locales/es-ES/mcp-protocol.mdx b/packages/core/docs/content/locales/es-ES/mcp-protocol.mdx index 8f135e4d2e..b6d5936301 100644 --- a/packages/core/docs/content/locales/es-ES/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/es-ES/mcp-protocol.mdx @@ -230,7 +230,11 @@ El agente ejecuta el mismo ciclo que el chat interactivo: puede llamar a múltip { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/fr-FR/external-agents.mdx b/packages/core/docs/content/locales/fr-FR/external-agents.mdx index 6c4782fc2b..d479f569f1 100644 --- a/packages/core/docs/content/locales/fr-FR/external-agents.mdx +++ b/packages/core/docs/content/locales/fr-FR/external-agents.mdx @@ -513,13 +513,13 @@ Voir [MCP Apps](/docs/mcp-apps) pour plus de détails sur le pont : transplanta En plus des outils par action, le serveur MCP expose un ensemble de verbes stable, de sorte qu'un agent externe dispose d'une surface prévisible sans deviner les noms d'action par application : -| Outil | Effets secondaires | Retours | -| -------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | aucun | applications d'espace de travail + leurs URL/état d'exécution | -| `open_app({ app, view?, path?, params?, embed? })` | aucun | un lien profond ou une route de même origine ; `embed: true` restitue l'application complète en ligne là où elle est prise en charge | -| `ask_app({ app, message, async?, maxWaitMs? })` | boucle d'agent | achemine une tâche en langage naturel vers l'agent intégré à l'application ; les tâches longues renvoient un `taskId` durable pour l'interrogation via `ask_app_status` | -| `create_workspace_app({ name, template })` | échafaudages | une nouvelle application démarrée via le chemin de l'espace de travail, ainsi que son URL en cours d'exécution + son lien profond | -| `list_templates` | aucun | les modèles autorisés uniquement | +| Outil | Effets secondaires | Retours | +| -------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | aucun | applications d'espace de travail + leurs URL/état d'exécution | +| `open_app({ app, view?, path?, params?, embed? })` | aucun | un lien profond ou une route de même origine ; `embed: true` restitue l'application complète en ligne là où elle est prise en charge | +| `ask_app({ app, message, async?, maxWaitMs? })` | boucle d'agent | achemine une tâche en langage naturel vers l'agent intégré à l'application ; les tâches longues renvoient un `taskHandle` durable et un `taskId` hérité pour l'interrogation via `ask_app_status` | +| `create_workspace_app({ name, template })` | échafaudages | une nouvelle application démarrée via le chemin de l'espace de travail, ainsi que son URL en cours d'exécution + son lien profond | +| `list_templates` | aucun | les modèles autorisés uniquement | `create_workspace_app` rejette tout modèle non autorisé : la liste verte de modèles publics dans `packages/shared-app-config/templates.ts` fait autorité et est protégée par CI ; un agent extérieur ne peut pas l’élargir. Une action de modèle du même nom remplace une action intégrée (précédence du modèle sur le noyau). Désactivez l'ensemble avec `MCPConfig.builtinCrossAppTools: false`. @@ -529,14 +529,20 @@ Pour des transferts rapides ChatGPT/Claude, le chemin idéal est direct : appel ### Requêtes d'agent de longue durée -Les hôtes et passerelles MCP ne partagent pas un seul et même délai d'expiration pour les appels d'outils : certains imposent un plafond de cinq minutes, d'autres sont configurables. Ne concevez pas une intégration d'agent externe autour d'un `tools/call` bloquant de dix minutes. `ask-agent` et `ask_app` accusent rapidement réception du travail hébergé, puis renvoient un `taskId` durable lorsque l'attente en ligne expire. Interrogez avec `ask_app_status` en utilisant les `app` et `taskId` renvoyés ; le résultat inclut `pollAfterMs` comme intervalle adapté à l'hôte. La tâche doit être créée avant que la première réponse ne puisse expirer, de sorte qu'une URL d'interrogation renvoyée seulement après un long appel n'est pas un contrat de récupération fiable. +Lorsque `ask_app` renvoie un `taskHandle`, transmettez cette valeur opaque sans +la modifier à `ask_app_status` ; elle conserve la route d'origine de la tâche +même si la découverte des applications change entre deux appels. Les anciens +arguments `app` et `taskId` restent acceptés pendant la migration. + +Les hôtes et passerelles MCP ne partagent pas un seul et même délai d'expiration pour les appels d'outils : certains imposent un plafond de cinq minutes, d'autres sont configurables. Ne concevez pas une intégration d'agent externe autour d'un `tools/call` bloquant de dix minutes. `ask-agent` et `ask_app` accusent rapidement réception du travail hébergé, puis renvoient un `taskHandle` durable et un `taskId` hérité lorsque l'attente en ligne expire. Interrogez avec `ask_app_status` en réutilisant sans modification les `poll.arguments` renvoyés, en particulier le `taskHandle` ; le résultat inclut `pollAfterMs` comme intervalle adapté à l'hôte. La tâche doit être créée avant que la première réponse ne puisse expirer, de sorte qu'une URL d'interrogation renvoyée seulement après un long appel n'est pas un contrat de récupération fiable. Si une lecture d'état transitoire et limitée est indisponible, `ask_app_status` peut renvoyer `status: "unknown"`, `statusRead: "unavailable"`, `retryable: true`, les `app` et `taskId` d'origine, ainsi que `pollAfterMs` / `poll`. Cela signifie que la lecture d'état est indisponible, et non que la tâche -durable a échoué. Réessayez `ask_app_status` avec exactement les mêmes `app` et -`taskId` ; ne soumettez jamais à nouveau `ask_app` pour récupérer, car la +durable a échoué. Réessayez `ask_app_status` avec exactement les mêmes +`poll.arguments`, y compris le `taskHandle` lorsqu'il est présent. Les anciens +résultats sans handle doivent réutiliser les mêmes `app` et `taskId` ; ne soumettez jamais à nouveau `ask_app` pour récupérer, car la soumission peut dupliquer le travail. Les erreurs permanentes d'authentification, d'introuvable et de protocole restent des erreurs. diff --git a/packages/core/docs/content/locales/fr-FR/mcp-protocol.mdx b/packages/core/docs/content/locales/fr-FR/mcp-protocol.mdx index 3cb10c71b0..f7b7940b2c 100644 --- a/packages/core/docs/content/locales/fr-FR/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/fr-FR/mcp-protocol.mdx @@ -224,7 +224,11 @@ L'agent exécute la même boucle que le chat interactif : il peut appeler plusi { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/hi-IN/external-agents.mdx b/packages/core/docs/content/locales/hi-IN/external-agents.mdx index a23d64c1c4..f6951a643f 100644 --- a/packages/core/docs/content/locales/hi-IN/external-agents.mdx +++ b/packages/core/docs/content/locales/hi-IN/external-agents.mdx @@ -509,13 +509,13 @@ ChatGPT/Claude-शैली OAuth ऐप होस्ट के लिए, ख प्रति-क्रिया टूल के शीर्ष पर MCP सर्वर एक स्थिर क्रिया सेट को उजागर करता है, इसलिए एक बाहरी एजेंट के पास प्रति-ऐप कार्रवाई नामों का अनुमान लगाए बिना एक पूर्वानुमानित सतह होती है: -| उपकरण | दुष्प्रभाव | रिटर्न | -| -------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | कोई नहीं | कार्यस्थान ऐप्स + उनकी URLs/चलने की स्थिति | -| `open_app({ app, view?, path?, params?, embed? })` | कोई नहीं | एक गहरा लिंक या समान मूल मार्ग; `embed: true` जहां समर्थित है वहां पूर्ण ऐप इनलाइन प्रस्तुत करता है | -| `ask_app({ app, message, async?, maxWaitMs? })` | एजेंट लूप | एक प्राकृतिक-भाषा कार्य को उस ऐप के इन-ऐप एजेंट तक पहुंचाता है; लंबे काम के लिए `ask_app_status` पोलिंग हेतु एक स्थायी `taskId` लौटाया जाता है | -| `create_workspace_app({ name, template })` | मचान | कार्यस्थान पथ के माध्यम से बूट किया गया एक नया ऐप, साथ ही इसका चल रहा URL + डीप लिंक | -| `list_templates` | कोई नहीं | केवल अनुमति-सूचीबद्ध टेम्पलेट | +| उपकरण | दुष्प्रभाव | रिटर्न | +| -------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | कोई नहीं | कार्यस्थान ऐप्स + उनकी URLs/चलने की स्थिति | +| `open_app({ app, view?, path?, params?, embed? })` | कोई नहीं | एक गहरा लिंक या समान मूल मार्ग; `embed: true` जहां समर्थित है वहां पूर्ण ऐप इनलाइन प्रस्तुत करता है | +| `ask_app({ app, message, async?, maxWaitMs? })` | एजेंट लूप | एक प्राकृतिक-भाषा कार्य को उस ऐप के इन-ऐप एजेंट तक पहुंचाता है; लंबे काम के लिए `ask_app_status` पोलिंग हेतु एक स्थायी `taskHandle` और पुराना `taskId` लौटाया जाता है | +| `create_workspace_app({ name, template })` | मचान | कार्यस्थान पथ के माध्यम से बूट किया गया एक नया ऐप, साथ ही इसका चल रहा URL + डीप लिंक | +| `list_templates` | कोई नहीं | केवल अनुमति-सूचीबद्ध टेम्पलेट | `create_workspace_app` किसी भी गैर-अनुमति-सूचीबद्ध टेम्पलेट को अस्वीकार करता है - `packages/shared-app-config/templates.ts` में सार्वजनिक टेम्पलेट अनुमति-सूची आधिकारिक और सीआई-संरक्षित है; कोई बाहरी एजेंट इसे चौड़ा नहीं कर सकता. एक समान-नामित टेम्पलेट क्रिया एक बिल्टिन (टेम्पलेट-ओवर-कोर प्राथमिकता) को ओवरराइड करती है। `MCPConfig.builtinCrossAppTools: false` के साथ पूरे सेट को अक्षम करें। @@ -525,12 +525,18 @@ ChatGPT/Claude-शैली OAuth ऐप होस्ट के लिए, ख ### लंबे समय तक चलने वाले एजेंट अनुरोध +जब `ask_app` कोई `taskHandle` लौटाए, तो उस अपारदर्शी मान को बिना बदले +`ask_app_status` को दें; ऐप खोज कॉलों के बीच बदलने पर भी यह मूल टास्क रूट को +सुरक्षित रखता है। माइग्रेशन के दौरान पुराने `app` और `taskId` तर्क भी स्वीकार +किए जाते रहेंगे। + MCP होस्ट और गेटवे एक साझा लंबे टूल-कॉल टाइमआउट का उपयोग नहीं करते; कुछ पांच मिनट की सीमा लागू करते हैं और अन्य कॉन्फ़िगर करने योग्य हैं। दस मिनट के ब्लॉकिंग `tools/call` के इर्द-गिर्द बाहरी एजेंट एकीकरण डिज़ाइन न करें। `ask-agent` और `ask_app` होस्ट किए गए कार्य को जल्दी स्वीकार करते हैं, फिर -इनलाइन प्रतीक्षा समाप्त होने पर एक स्थायी `taskId` लौटाते हैं। लौटाए गए -`app` और `taskId` का उपयोग करके `ask_app_status` के साथ पोल करें; परिणाम में +इनलाइन प्रतीक्षा समाप्त होने पर एक स्थायी `taskHandle` और पुराना `taskId` लौटाते हैं। +लौटाए गए `poll.arguments`, विशेष रूप से `taskHandle`, को बिना बदले फिर से उपयोग करके +`ask_app_status` के साथ पोल करें; परिणाम में एक होस्ट-अनुकूल अंतराल के रूप में `pollAfterMs` शामिल है। पहली प्रतिक्रिया के टाइमआउट होने से पहले टास्क बनाया जाना चाहिए, इसलिए केवल लंबे कॉल के बाद लौटाया गया पोलिंग URL एक विश्वसनीय रिकवरी अनुबंध नहीं है। @@ -538,8 +544,9 @@ MCP होस्ट और गेटवे एक साझा लंबे ट यदि सीमित, अस्थायी स्थिति-पठन उपलब्ध नहीं है, तो `ask_app_status` `status: "unknown"`, `statusRead: "unavailable"`, `retryable: true`, मूल `app` और `taskId`, तथा `pollAfterMs` / `poll` लौटा सकता है। इसका अर्थ है कि स्थिति-पठन -अनुपलब्ध है, न कि स्थायी टास्क विफल हुआ है। ठीक उसी `app` और `taskId` के साथ -`ask_app_status` को फिर आज़माएँ; पुनर्प्राप्ति के लिए `ask_app` को कभी फिर से सबमिट +अनुपलब्ध है, न कि स्थायी टास्क विफल हुआ है। लौटाए गए उन्हीं `poll.arguments` के साथ +`ask_app_status` को फिर आज़माएँ, और उपलब्ध होने पर `taskHandle` शामिल करें। बिना +हैंडल वाले पुराने परिणामों में वही `app` और `taskId` दोबारा उपयोग करें; पुनर्प्राप्ति के लिए `ask_app` को कभी फिर से सबमिट न करें, क्योंकि सबमिशन काम की नकल कर सकता है। स्थायी प्रमाणीकरण, न मिलने और प्रोटोकॉल त्रुटियाँ त्रुटियाँ ही रहती हैं। diff --git a/packages/core/docs/content/locales/hi-IN/mcp-protocol.mdx b/packages/core/docs/content/locales/hi-IN/mcp-protocol.mdx index f5dc81d12b..4f3fdd53c9 100644 --- a/packages/core/docs/content/locales/hi-IN/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/hi-IN/mcp-protocol.mdx @@ -228,7 +228,11 @@ Auto-mounted on every app. Speaks the standard MCP handshake (`initialize` → ` { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/ja-JP/external-agents.mdx b/packages/core/docs/content/locales/ja-JP/external-agents.mdx index 33fcfef2d3..320c3d16c1 100644 --- a/packages/core/docs/content/locales/ja-JP/external-agents.mdx +++ b/packages/core/docs/content/locales/ja-JP/external-agents.mdx @@ -510,13 +510,13 @@ ChatGPT/Claude スタイルの OAuth アプリ ホストの場合、検出サー アクションごとのツールに加えて、MCP サーバーは安定した動詞セットを公開するため、外部エージェントはアプリごとのアクション名を推測することなく予測可能な表面を持ちます。 -| ツール | 副作用 | 返品 | -| -------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | なし | ワークスペース アプリ + その URL / 実行状態 | -| `open_app({ app, view?, path?, params?, embed? })` | なし | ディープリンクまたは同一発信元ルート。 `embed: true` は、サポートされている場合、アプリ全体をインラインでレンダリングします | -| `ask_app({ app, message, async?, maxWaitMs? })` | エージェント ループ | 自然言語タスクをそのアプリのアプリ内エージェントにルーティングします。長時間の処理では、`ask_app_status` でポーリングするための永続的な `taskId` を返します | -| `create_workspace_app({ name, template })` | 足場 | ワークスペース パス経由で起動された新しいアプリと、実行中の URL + ディープ リンク | -| `list_templates` | なし | 許可リストに登録されたテンプレートのみ | +| ツール | 副作用 | 返品 | +| -------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | なし | ワークスペース アプリ + その URL / 実行状態 | +| `open_app({ app, view?, path?, params?, embed? })` | なし | ディープリンクまたは同一発信元ルート。 `embed: true` は、サポートされている場合、アプリ全体をインラインでレンダリングします | +| `ask_app({ app, message, async?, maxWaitMs? })` | エージェント ループ | 自然言語タスクをそのアプリのアプリ内エージェントにルーティングします。長時間の処理では、`ask_app_status` でポーリングするための永続的な `taskHandle` と従来の `taskId` を返します | +| `create_workspace_app({ name, template })` | 足場 | ワークスペース パス経由で起動された新しいアプリと、実行中の URL + ディープ リンク | +| `list_templates` | なし | 許可リストに登録されたテンプレートのみ | `create_workspace_app` は、許可リストに登録されていないテンプレートを拒否します。`packages/shared-app-config/templates.ts` のパブリック テンプレートの許可リストは権限があり、CI で保護されています。外部エージェントがそれを広げることはできません。同じ名前のテンプレート アクションは、組み込みをオーバーライドします (コアよりテンプレートが優先されます)。 `MCPConfig.builtinCrossAppTools: false` でセット全体を無効にします。 @@ -526,13 +526,20 @@ ChatGPT/Claude スタイルの OAuth アプリ ホストの場合、検出サー ### 長時間実行されるエージェント リクエスト -MCP ホストとゲートウェイは、単一の長いツール呼び出しタイムアウトを共有していません。5 分の上限を課すものもあれば、設定可能なものもあります。10 分間ブロックする `tools/call` を前提に外部エージェント連携を設計しないでください。 `ask-agent` と `ask_app` はホストされている処理をすぐに確認応答し、インライン待機が期限切れになると永続的な `taskId` を返します。返された `app` と `taskId` を使用して `ask_app_status` でポーリングしてください。結果にはホストにとって扱いやすい間隔として `pollAfterMs` が含まれます。タスクは最初のレスポンスがタイムアウトする前に作成されている必要があるため、長い呼び出しの後にのみ返されるポーリング URL は信頼できる復旧手段ではありません。 +`ask_app` が `taskHandle` を返した場合は、その不透明な値を変更せずに +`ask_app_status` へ渡してください。呼び出し間でアプリ検出が変わっても、元の +タスクルートが保持されます。移行期間中は従来の `app` と `taskId` 引数も引き続き +使用できます。 + +MCP ホストとゲートウェイは、単一の長いツール呼び出しタイムアウトを共有していません。5 分の上限を課すものもあれば、設定可能なものもあります。10 分間ブロックする `tools/call` を前提に外部エージェント連携を設計しないでください。 `ask-agent` と `ask_app` はホストされている処理をすぐに確認応答し、インライン待機が期限切れになると永続的な `taskHandle` と従来の `taskId` を返します。返された `poll.arguments`、特に `taskHandle` を変更せずに再利用して `ask_app_status` でポーリングしてください。結果にはホストにとって扱いやすい間隔として `pollAfterMs` が含まれます。タスクは最初のレスポンスがタイムアウトする前に作成されている必要があるため、長い呼び出しの後にのみ返されるポーリング URL は信頼できる復旧手段ではありません。 制限された一時的なステータス取得を利用できない場合、`ask_app_status` は `status: "unknown"`、`statusRead: "unavailable"`、`retryable: true`、元の `app` と `taskId`、および `pollAfterMs` / `poll` を返すことがあります。これは ステータス取得が利用できないことを意味し、永続タスクが失敗したことを意味しません。 -まったく同じ `app` と `taskId` で `ask_app_status` を再試行してください。送信により +返されたものとまったく同じ `poll.arguments`(存在する場合は `taskHandle` を含む)で +`ask_app_status` を再試行してください。ハンドルのない従来の結果では、同じ `app` と +`taskId` を再利用します。送信により 作業が重複する可能性があるため、復旧のために `ask_app` を再送信してはいけません。 永続的な認証、未検出、プロトコルのエラーは引き続きエラーです。 diff --git a/packages/core/docs/content/locales/ja-JP/mcp-protocol.mdx b/packages/core/docs/content/locales/ja-JP/mcp-protocol.mdx index e4cbc98d33..163ae6ec9d 100644 --- a/packages/core/docs/content/locales/ja-JP/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/ja-JP/mcp-protocol.mdx @@ -224,7 +224,11 @@ Auto-mounted on every app. Speaks the standard MCP handshake (`initialize` → ` { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/ko-KR/external-agents.mdx b/packages/core/docs/content/locales/ko-KR/external-agents.mdx index a3c7bddfed..cbc7788a3c 100644 --- a/packages/core/docs/content/locales/ko-KR/external-agents.mdx +++ b/packages/core/docs/content/locales/ko-KR/external-agents.mdx @@ -511,13 +511,13 @@ ChatGPT/Claude 스타일 OAuth 앱 호스트의 경우 검색 표면은 기본 액션별 도구 외에도 MCP 서버는 안정적인 동사 세트를 노출하므로 외부 에이전트는 앱별 액션 이름을 추측하지 않고도 예측 가능한 표면을 갖습니다. -| 도구 | 부작용 | 반품 | -| -------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | 없음 | 작업 공간 앱 + 해당 URL / 실행 상태 | -| `open_app({ app, view?, path?, params?, embed? })` | 없음 | 딥 링크 또는 동일 출처 경로; `embed: true`는 지원되는 경우 전체 앱을 인라인으로 렌더링합니다. | -| `ask_app({ app, message, async?, maxWaitMs? })` | 에이전트 루프 | 자연어 작업을 해당 앱의 인앱 에이전트로 라우팅합니다. 오래 걸리는 작업은 `ask_app_status` 폴링을 위한 내구성 있는 `taskId`를 반환합니다 | -| `create_workspace_app({ name, template })` | 비계 | 작업공간 경로를 통해 부팅된 새 앱과 실행 중인 URL + 딥 링크 | -| `list_templates` | 없음 | 허용 목록에 있는 템플릿만 | +| 도구 | 부작용 | 반품 | +| -------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | 없음 | 작업 공간 앱 + 해당 URL / 실행 상태 | +| `open_app({ app, view?, path?, params?, embed? })` | 없음 | 딥 링크 또는 동일 출처 경로; `embed: true`는 지원되는 경우 전체 앱을 인라인으로 렌더링합니다. | +| `ask_app({ app, message, async?, maxWaitMs? })` | 에이전트 루프 | 자연어 작업을 해당 앱의 인앱 에이전트로 라우팅합니다. 오래 걸리는 작업은 `ask_app_status` 폴링을 위한 내구성 있는 `taskHandle`과 기존 `taskId`를 반환합니다 | +| `create_workspace_app({ name, template })` | 비계 | 작업공간 경로를 통해 부팅된 새 앱과 실행 중인 URL + 딥 링크 | +| `list_templates` | 없음 | 허용 목록에 있는 템플릿만 | `create_workspace_app`는 허용 목록에 없는 템플릿을 거부합니다. `packages/shared-app-config/templates.ts`의 공개 템플릿 허용 목록은 신뢰할 수 있고 CI로 보호됩니다. 외부 에이전트는 이를 확장할 수 없습니다. 동일한 이름의 템플릿 작업은 기본 제공(코어보다 템플릿 우선 순위)을 재정의합니다. `MCPConfig.builtinCrossAppTools: false`로 전체 세트를 비활성화하세요. @@ -527,11 +527,16 @@ ChatGPT/Claude 스타일 OAuth 앱 호스트의 경우 검색 표면은 기본 ### 장기 실행 에이전트 요청 +`ask_app`이 `taskHandle`을 반환하면 해당 불투명 값을 변경하지 말고 +`ask_app_status`에 전달하세요. 호출 사이에 앱 검색 결과가 바뀌어도 원래 작업 경로가 +유지됩니다. 마이그레이션 중에는 기존 `app` 및 `taskId` 인수도 계속 허용됩니다. + MCP 호스트와 게이트웨이는 하나의 긴 도구 호출 제한 시간을 공유하지 않습니다. 일부는 5분 상한을 강제하고 다른 일부는 설정 가능합니다. 10분간 차단되는 `tools/call`을 중심으로 외부 에이전트 통합을 설계하지 마세요. `ask-agent`와 `ask_app`은 호스팅된 -작업의 접수를 빠르게 확인한 뒤, 인라인 대기 시간이 만료되면 내구성 있는 `taskId`를 -반환합니다. 반환된 `app`과 `taskId`를 사용해 `ask_app_status`로 폴링하세요. 결과에는 +작업의 접수를 빠르게 확인한 뒤, 인라인 대기 시간이 만료되면 내구성 있는 `taskHandle`과 +기존 `taskId`를 반환합니다. 반환된 `poll.arguments`, 특히 `taskHandle`을 변경하지 않고 +재사용해 `ask_app_status`로 폴링하세요. 결과에는 호스트 친화적인 간격인 `pollAfterMs`가 포함됩니다. 첫 번째 응답이 시간 초과되기 전에 작업이 생성되어야 하므로, 오랜 호출이 끝난 후에만 반환되는 폴링 URL은 신뢰할 수 있는 복구 계약이 아닙니다. @@ -540,7 +545,8 @@ MCP 호스트와 게이트웨이는 하나의 긴 도구 호출 제한 시간을 `status: "unknown"`, `statusRead: "unavailable"`, `retryable: true`, 원래의 `app`과 `taskId`, 그리고 `pollAfterMs` / `poll`을 반환할 수 있습니다. 이는 상태 읽기를 사용할 수 없다는 뜻이지 내구성 있는 작업이 실패했다는 뜻은 아닙니다. 정확히 같은 -`app`과 `taskId`로 `ask_app_status`를 재시도하세요. 제출하면 작업이 중복될 수 있으므로 +반환된 동일한 `poll.arguments`로 `ask_app_status`를 재시도하고, 있는 경우 `taskHandle`을 +포함하세요. 핸들이 없는 기존 결과는 동일한 `app`과 `taskId`를 재사용해야 합니다. 제출하면 작업이 중복될 수 있으므로 복구를 위해 `ask_app`을 다시 제출하지 마세요. 영구적인 인증, 찾을 수 없음, 프로토콜 오류는 계속 오류로 남습니다. diff --git a/packages/core/docs/content/locales/ko-KR/mcp-protocol.mdx b/packages/core/docs/content/locales/ko-KR/mcp-protocol.mdx index 8689fb7ada..67851b0fd0 100644 --- a/packages/core/docs/content/locales/ko-KR/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/ko-KR/mcp-protocol.mdx @@ -229,7 +229,11 @@ Auto-mounted on every app. Speaks the standard MCP handshake (`initialize` → ` { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/pt-BR/external-agents.mdx b/packages/core/docs/content/locales/pt-BR/external-agents.mdx index ada8f30e31..a306c7a8d2 100644 --- a/packages/core/docs/content/locales/pt-BR/external-agents.mdx +++ b/packages/core/docs/content/locales/pt-BR/external-agents.mdx @@ -512,13 +512,13 @@ Consulte [MCP Apps](/docs/mcp-apps) para obter todos os detalhes da ponte: trans Além das ferramentas por ação, o servidor MCP expõe um conjunto de verbos estável, para que um agente externo tenha uma superfície previsível sem adivinhar os nomes das ações por aplicativo: -| Ferramenta | Efeitos colaterais | Devoluções | -| -------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list_apps` | nenhum | aplicativos de espaço de trabalho + seus URLs/estado de execução | -| `open_app({ app, view?, path?, params?, embed? })` | nenhum | um link direto ou rota de mesma origem; `embed: true` renderiza o aplicativo completo in-line onde houver suporte | -| `ask_app({ app, message, async?, maxWaitMs? })` | loop de agente | encaminha uma tarefa em linguagem natural para o agente no aplicativo desse aplicativo; trabalho longo retorna um `taskId` durável para consulta com `ask_app_status` | -| `create_workspace_app({ name, template })` | andaimes | um novo aplicativo inicializado pelo caminho do espaço de trabalho, além de URL em execução + link direto | -| `list_templates` | nenhum | somente os modelos permitidos | +| Ferramenta | Efeitos colaterais | Devoluções | +| -------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | nenhum | aplicativos de espaço de trabalho + seus URLs/estado de execução | +| `open_app({ app, view?, path?, params?, embed? })` | nenhum | um link direto ou rota de mesma origem; `embed: true` renderiza o aplicativo completo in-line onde houver suporte | +| `ask_app({ app, message, async?, maxWaitMs? })` | loop de agente | encaminha uma tarefa em linguagem natural para o agente no aplicativo desse aplicativo; trabalho longo retorna um `taskHandle` durável e um `taskId` legado para consulta com `ask_app_status` | +| `create_workspace_app({ name, template })` | andaimes | um novo aplicativo inicializado pelo caminho do espaço de trabalho, além de URL em execução + link direto | +| `list_templates` | nenhum | somente os modelos permitidos | `create_workspace_app` rejeita qualquer modelo não listado na lista de permissões — a lista de permissões de modelos públicos no `packages/shared-app-config/templates.ts` é oficial e protegida por CI; um agente externo não pode ampliá-lo. Uma ação de modelo com o mesmo nome substitui uma ação interna (precedência de modelo sobre núcleo). Desative todo o conjunto com `MCPConfig.builtinCrossAppTools: false`. @@ -528,13 +528,19 @@ Para transferências rápidas de ChatGPT/Claude, o caminho ideal é direto: cham ### Solicitações de agente de longa duração +Quando `ask_app` retornar um `taskHandle`, passe esse valor opaco sem alterações +para `ask_app_status`; ele preserva a rota original da tarefa mesmo que a +descoberta de aplicativos mude entre as chamadas. Os argumentos antigos `app` e +`taskId` continuam aceitos durante a migração. + Hosts e gateways MCP não compartilham um único timeout longo de chamada de ferramenta; alguns impõem um teto de cinco minutos e outros são configuráveis. Não projete uma integração de agente externo em torno de um `tools/call` bloqueante de dez minutos. `ask-agent` e `ask_app` confirmam o -trabalho hospedado rapidamente e depois retornam um `taskId` durável quando a -espera embutida expira. Consulte com `ask_app_status` usando o `app` e o -`taskId` retornados; o resultado inclui `pollAfterMs` como um intervalo +trabalho hospedado rapidamente e depois retornam um `taskHandle` durável e um +`taskId` legado quando a espera embutida expira. Consulte com `ask_app_status` +reutilizando sem alterações os `poll.arguments` retornados, especialmente o +`taskHandle`; o resultado inclui `pollAfterMs` como um intervalo amigável para hosts. A tarefa precisa ser criada antes que a primeira resposta possa expirar, então um URL de consulta retornado apenas após uma chamada longa não é um contrato de recuperação confiável. @@ -544,7 +550,8 @@ Se uma leitura de status transitória e limitada estiver indisponível, `statusRead: "unavailable"`, `retryable: true`, o `app` e o `taskId` originais, e `pollAfterMs` / `poll`. Isso significa que a leitura de status está indisponível, não que a tarefa durável falhou. Tente novamente `ask_app_status` -com exatamente o mesmo `app` e `taskId`; nunca reenvie `ask_app` para recuperar, +com os mesmos `poll.arguments`, incluindo o `taskHandle` quando presente. Resultados +legados sem identificador devem reutilizar o mesmo `app` e `taskId`; nunca reenvie `ask_app` para recuperar, pois o envio pode duplicar o trabalho. Erros permanentes de autenticação, de não encontrado e de protocolo continuam sendo erros. diff --git a/packages/core/docs/content/locales/pt-BR/mcp-protocol.mdx b/packages/core/docs/content/locales/pt-BR/mcp-protocol.mdx index b102766d57..bf70933faf 100644 --- a/packages/core/docs/content/locales/pt-BR/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/pt-BR/mcp-protocol.mdx @@ -224,7 +224,11 @@ O agente executa o mesmo loop que o bate-papo interativo: ele pode chamar divers { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/zh-CN/external-agents.mdx b/packages/core/docs/content/locales/zh-CN/external-agents.mdx index 1febf8f894..1e957ce035 100644 --- a/packages/core/docs/content/locales/zh-CN/external-agents.mdx +++ b/packages/core/docs/content/locales/zh-CN/external-agents.mdx @@ -507,13 +507,13 @@ CSP 和沙箱权限等安全元数据存在于资源中 在每个操作工具之上,MCP 服务器公开了一个稳定的动词集,因此外部代理具有可预测的表面,而无需猜测每个应用操作名称: -| 工具 | 副作用 | 退货 | -| -------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | -| `list_apps` | 无 | 工作区应用+其 URL/运行状态 | -| `open_app({ app, view?, path?, params?, embed? })` | 无 | 深层链接或同源路由; `embed: true` 在支持的情况下内联渲染完整的应用程序 | -| `ask_app({ app, message, async?, maxWaitMs? })` | 代理循环 | 将自然语言任务路由到该应用的应用内代理;长时间运行的任务会返回一个持久的 `taskId`,供 `ask_app_status` 轮询 | -| `create_workspace_app({ name, template })` | 脚手架 | 通过工作区路径启动的新应用程序,及其正在运行的 URL + 深层链接 | -| `list_templates` | 无 | 仅限允许列出的模板 | +| 工具 | 副作用 | 退货 | +| -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | 无 | 工作区应用+其 URL/运行状态 | +| `open_app({ app, view?, path?, params?, embed? })` | 无 | 深层链接或同源路由; `embed: true` 在支持的情况下内联渲染完整的应用程序 | +| `ask_app({ app, message, async?, maxWaitMs? })` | 代理循环 | 将自然语言任务路由到该应用的应用内代理;长时间运行的任务会返回一个持久的 `taskHandle` 和旧版 `taskId`,供 `ask_app_status` 轮询 | +| `create_workspace_app({ name, template })` | 脚手架 | 通过工作区路径启动的新应用程序,及其正在运行的 URL + 深层链接 | +| `list_templates` | 无 | 仅限允许列出的模板 | `create_workspace_app` 拒绝任何非允许列表模板 - `packages/shared-app-config/templates.ts` 中的公共模板允许列表是权威且受 CI 保护的;外部代理无法扩大它。同名的模板操作会覆盖内置操作(模板优先于核心优先级)。使用 `MCPConfig.builtinCrossAppTools: false` 禁用整个设置。 @@ -523,12 +523,17 @@ CSP 和沙箱权限等安全元数据存在于资源中 ### 长时间运行的代理请求 -MCP 主机和网关并不共享同一个较长的工具调用超时时间;有些强制执行五分钟的上限,有些则可配置。不要围绕一个阻塞十分钟的 `tools/call` 来设计外部代理集成。`ask-agent` 和 `ask_app` 会快速确认托管工作已开始,然后在内联等待到期时返回一个持久的 `taskId`。使用返回的 `app` 和 `taskId` 通过 `ask_app_status` 进行轮询;结果中包含 `pollAfterMs`,作为对主机友好的轮询间隔。任务必须在第一次响应超时之前就已创建,因此仅在长时间调用之后才返回的轮询 URL 并不是可靠的恢复约定。 +当 `ask_app` 返回 `taskHandle` 时,请将这个不透明值原样传给 +`ask_app_status`;即使两次调用之间的应用发现结果发生变化,它也会保留原始任务路由。 +迁移期间仍接受旧的 `app` 和 `taskId` 参数。 + +MCP 主机和网关并不共享同一个较长的工具调用超时时间;有些强制执行五分钟的上限,有些则可配置。不要围绕一个阻塞十分钟的 `tools/call` 来设计外部代理集成。`ask-agent` 和 `ask_app` 会快速确认托管工作已开始,然后在内联等待到期时返回一个持久的 `taskHandle` 和旧版 `taskId`。请原样重用返回的 `poll.arguments`(尤其是 `taskHandle`)通过 `ask_app_status` 进行轮询;结果中包含 `pollAfterMs`,作为对主机友好的轮询间隔。任务必须在第一次响应超时之前就已创建,因此仅在长时间调用之后才返回的轮询 URL 并不是可靠的恢复约定。 当有界的暂态状态读取不可用时,`ask_app_status` 可以返回 `status: "unknown"`、`statusRead: "unavailable"`、`retryable: true`、原始的 `app` 和 `taskId`,以及 `pollAfterMs` / `poll`。这表示状态读取不可用,并不表示 -持久任务失败。请使用完全相同的 `app` 和 `taskId` 重试 `ask_app_status`;切勿为恢复而 +持久任务失败。请使用完全相同的返回 `poll.arguments`(存在时包括 `taskHandle`)重试 +`ask_app_status`;没有句柄的旧版结果必须重用相同的 `app` 和 `taskId`。切勿为恢复而 重新提交 `ask_app`,因为提交可能会重复工作。永久性的身份验证、未找到和协议错误仍然是错误。 ### 每应用游览 {#tour} diff --git a/packages/core/docs/content/locales/zh-CN/mcp-protocol.mdx b/packages/core/docs/content/locales/zh-CN/mcp-protocol.mdx index 3718677515..07ea4fb814 100644 --- a/packages/core/docs/content/locales/zh-CN/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/zh-CN/mcp-protocol.mdx @@ -224,7 +224,11 @@ Auto-mounted on every app. Speaks the standard MCP handshake (`initialize` → ` { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/locales/zh-TW/external-agents.mdx b/packages/core/docs/content/locales/zh-TW/external-agents.mdx index 40d7ea3ffa..b80a9fee23 100644 --- a/packages/core/docs/content/locales/zh-TW/external-agents.mdx +++ b/packages/core/docs/content/locales/zh-TW/external-agents.mdx @@ -507,13 +507,13 @@ CSP 和沙箱權限等安全中繼資料存在於資源中 在每個操作工具之上,MCP 伺服器公開了一個穩定的動詞集,因此外部代理具有可預測的表面,而無需猜測每個應用操作名稱: -| 工具 | 副作用 | 退貨 | -| -------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | -| `list_apps` | 無 | 工作區應用+其 URL/執行狀態 | -| `open_app({ app, view?, path?, params?, embed? })` | 無 | 深層連結或同來源路由; `embed: true` 在支援的情況下行內渲染完整的應用程式 | -| `ask_app({ app, message, async?, maxWaitMs? })` | 代理迴圈 | 將自然語言工作路由到該應用的應用內代理;長時間的工作會傳回持久的 `taskId`,供 `ask_app_status` 輪詢 | -| `create_workspace_app({ name, template })` | 腳手架 | 透過工作區路徑啟動的新應用程式,及其正在執行的 URL + 深層連結 | -| `list_templates` | 無 | 僅限允許列出的範本 | +| 工具 | 副作用 | 退貨 | +| -------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `list_apps` | 無 | 工作區應用+其 URL/執行狀態 | +| `open_app({ app, view?, path?, params?, embed? })` | 無 | 深層連結或同來源路由; `embed: true` 在支援的情況下行內渲染完整的應用程式 | +| `ask_app({ app, message, async?, maxWaitMs? })` | 代理迴圈 | 將自然語言工作路由到該應用的應用內代理;長時間的工作會傳回持久的 `taskHandle` 與舊版 `taskId`,供 `ask_app_status` 輪詢 | +| `create_workspace_app({ name, template })` | 腳手架 | 透過工作區路徑啟動的新應用程式,及其正在執行的 URL + 深層連結 | +| `list_templates` | 無 | 僅限允許列出的範本 | `create_workspace_app` 拒絕任何非允許清單範本 - `packages/shared-app-config/templates.ts` 中的公開範本允許清單是權威且受 CI 保護的;外部代理無法擴大它。同名的範本操作會覆蓋內建操作(範本優先於核心優先級)。使用 `MCPConfig.builtinCrossAppTools: false` 停用整個設定。 @@ -523,12 +523,17 @@ CSP 和沙箱權限等安全中繼資料存在於資源中 ### 長時間執行的代理請求 -MCP 主機與閘道器並未共用單一的長工具呼叫逾時限制;有些強制五分鐘上限,有些則可設定。請勿將外部代理整合設計成依賴十分鐘的封鎖式 `tools/call`。`ask-agent` 與 `ask_app` 會快速確認託管工作,並在行內等待逾期時傳回持久的 `taskId`。請使用傳回的 `app` 與 `taskId` 呼叫 `ask_app_status` 進行輪詢;結果包含 `pollAfterMs` 作為對主機友善的輪詢間隔。任務必須在第一次回應逾時之前就已建立,因此僅在長時間呼叫後才傳回輪詢網址並非可靠的復原機制。 +當 `ask_app` 傳回 `taskHandle` 時,請將這個不透明值原樣傳給 +`ask_app_status`;即使兩次呼叫之間的應用程式探索結果改變,它仍會保留原始任務路由。 +遷移期間仍接受舊的 `app` 與 `taskId` 引數。 + +MCP 主機與閘道器並未共用單一的長工具呼叫逾時限制;有些強制五分鐘上限,有些則可設定。請勿將外部代理整合設計成依賴十分鐘的封鎖式 `tools/call`。`ask-agent` 與 `ask_app` 會快速確認託管工作,並在行內等待逾期時傳回持久的 `taskHandle` 與舊版 `taskId`。請原樣重用傳回的 `poll.arguments`(尤其是 `taskHandle`)呼叫 `ask_app_status` 進行輪詢;結果包含 `pollAfterMs` 作為對主機友善的輪詢間隔。任務必須在第一次回應逾時之前就已建立,因此僅在長時間呼叫後才傳回輪詢網址並非可靠的復原機制。 若有界的暫時狀態讀取無法使用,`ask_app_status` 可傳回 `status: "unknown"`、`statusRead: "unavailable"`、`retryable: true`、原始的 `app` 與 `taskId`,以及 `pollAfterMs` / `poll`。這表示狀態讀取無法使用,並不表示 -持久任務失敗。請以完全相同的 `app` 與 `taskId` 重試 `ask_app_status`;切勿為了復原而 +持久任務失敗。請以完全相同的傳回 `poll.arguments`(若存在則包括 `taskHandle`)重試 +`ask_app_status`;沒有控制代碼的舊版結果必須重用相同的 `app` 與 `taskId`。切勿為了復原而 重新提交 `ask_app`,因為提交可能重複工作。永久性的驗證、找不到與通訊協定錯誤仍然是錯誤。 ### 每應用游覽 {#tour} diff --git a/packages/core/docs/content/locales/zh-TW/mcp-protocol.mdx b/packages/core/docs/content/locales/zh-TW/mcp-protocol.mdx index 2b1161f7b5..1bc8269f71 100644 --- a/packages/core/docs/content/locales/zh-TW/mcp-protocol.mdx +++ b/packages/core/docs/content/locales/zh-TW/mcp-protocol.mdx @@ -224,7 +224,11 @@ Auto-mounted on every app. Speaks the standard MCP handshake (`initialize` → ` { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/docs/content/mcp-protocol.mdx b/packages/core/docs/content/mcp-protocol.mdx index fc207483f5..2e24c6ccde 100644 --- a/packages/core/docs/content/mcp-protocol.mdx +++ b/packages/core/docs/content/mcp-protocol.mdx @@ -235,7 +235,11 @@ The agent runs the same loop as the interactive chat — it can call multiple to { "poll": { "tool": "ask_app_status", - "arguments": { "app": "mail", "taskId": "task_123" } + "arguments": { + "app": "mail", + "taskId": "task_123", + "taskHandle": "opaque_handle" + } } } ``` diff --git a/packages/core/src/mcp/ask-app-task-handle.spec.ts b/packages/core/src/mcp/ask-app-task-handle.spec.ts new file mode 100644 index 0000000000..8fc63c137e --- /dev/null +++ b/packages/core/src/mcp/ask-app-task-handle.spec.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + signAskAppTaskHandle, + verifyAskAppTaskHandle, +} from "./ask-app-task-handle.js"; + +describe("ask_app task handles", () => { + beforeEach(() => { + process.env.BETTER_AUTH_SECRET = "test-better-auth-secret"; + }); + + const route = { + app: "content", + origin: "https://content.example.com/_agent-native/a2a", + routedVia: "a2a" as const, + requestOrigin: "https://content.example.com", + }; + + it("round-trips the signed route and remote task id", async () => { + const handle = await signAskAppTaskHandle({ + issuerApp: "calendar", + subject: "alice@example.com", + route, + taskId: "task-123", + }); + + await expect( + verifyAskAppTaskHandle(handle, { + issuerApp: "calendar", + subject: "alice@example.com", + }), + ).resolves.toEqual({ route, taskId: "task-123" }); + }); + + it.each([ + ["wrong connector", { issuerApp: "mail", subject: "alice@example.com" }], + ["wrong user", { issuerApp: "calendar", subject: "mallory@example.com" }], + ])("rejects the %s without exposing claims", async (_label, expected) => { + const handle = await signAskAppTaskHandle({ + issuerApp: "calendar", + subject: "alice@example.com", + route, + taskId: "task-secret", + }); + + await expect(verifyAskAppTaskHandle(handle, expected)).rejects.toThrow( + /^Invalid or expired ask_app task handle\.$/, + ); + }); + + it("rejects modified and expired handles with the same opaque error", async () => { + const handle = await signAskAppTaskHandle({ + issuerApp: "calendar", + subject: "alice@example.com", + route, + taskId: "task-secret", + expiresInSeconds: -1, + }); + const modified = `${handle.slice(0, -1)}${handle.endsWith("a") ? "b" : "a"}`; + + await expect( + verifyAskAppTaskHandle(modified, { + issuerApp: "calendar", + subject: "alice@example.com", + }), + ).rejects.toThrow(/^Invalid or expired ask_app task handle\.$/); + await expect( + verifyAskAppTaskHandle(handle, { + issuerApp: "calendar", + subject: "alice@example.com", + }), + ).rejects.toThrow(/^Invalid or expired ask_app task handle\.$/); + }); +}); diff --git a/packages/core/src/mcp/ask-app-task-handle.ts b/packages/core/src/mcp/ask-app-task-handle.ts new file mode 100644 index 0000000000..c63f6f1b35 --- /dev/null +++ b/packages/core/src/mcp/ask-app-task-handle.ts @@ -0,0 +1,151 @@ +import { createHmac } from "node:crypto"; + +import { CompactEncrypt, compactDecrypt } from "jose"; + +import { getAuthSecret } from "../server/better-auth-instance.js"; + +const HANDLE_VERSION = 1; +const HANDLE_TTL_SECONDS = 60 * 60; +const INVALID_HANDLE_ERROR = "Invalid or expired ask_app task handle."; + +export interface AskAppTaskHandleRoute { + app: string; + origin: string; + routedVia: "local" | "a2a"; + requestOrigin?: string; +} + +interface AskAppTaskHandleClaims { + version: number; + issuerApp: string; + subject: string; + route: AskAppTaskHandleRoute; + taskId: string; + expiresAt: number; +} + +function normalizedIdentity(value: string): string { + return value.trim().toLowerCase(); +} + +function rootSecrets(): string[] { + const a2a = process.env.A2A_SECRET?.trim(); + const auth = getAuthSecret(); + return [...new Set([a2a, auth].filter((value): value is string => !!value))]; +} + +function handleKey(rootSecret: string): Uint8Array { + return createHmac("sha256", rootSecret) + .update("agent-native:ask-app-task-handle:v1") + .digest(); +} + +function validHttpUrl(value: string): URL | null { + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) return null; + if (url.username || url.password) return null; + return url; + } catch { + return null; + } +} + +function validRoute(value: unknown): value is AskAppTaskHandleRoute { + if (!value || typeof value !== "object") return false; + const route = value as Partial; + if (typeof route.app !== "string" || !route.app.trim()) return false; + if (route.routedVia !== "local" && route.routedVia !== "a2a") return false; + if (typeof route.origin !== "string") return false; + const endpoint = validHttpUrl(route.origin); + if (!endpoint) return false; + if (route.requestOrigin == null) return true; + if (typeof route.requestOrigin !== "string") return false; + const requestOrigin = validHttpUrl(route.requestOrigin); + return requestOrigin?.origin === endpoint.origin; +} + +function invalidHandle(): Error { + return new Error(INVALID_HANDLE_ERROR); +} + +export async function signAskAppTaskHandle(params: { + issuerApp: string; + subject: string; + route: AskAppTaskHandleRoute; + taskId: string; + expiresInSeconds?: number; +}): Promise { + const issuerApp = normalizedIdentity(params.issuerApp); + const subject = normalizedIdentity(params.subject); + const taskId = params.taskId.trim(); + if (!issuerApp || !subject || !taskId || !validRoute(params.route)) { + throw invalidHandle(); + } + + const claims: AskAppTaskHandleClaims = { + version: HANDLE_VERSION, + issuerApp, + subject, + route: { + ...params.route, + app: normalizedIdentity(params.route.app), + }, + taskId, + expiresAt: + Math.floor(Date.now() / 1000) + + (params.expiresInSeconds ?? HANDLE_TTL_SECONDS), + }; + const payload = new TextEncoder().encode(JSON.stringify(claims)); + return new CompactEncrypt(payload) + .setProtectedHeader({ + alg: "dir", + enc: "A256GCM", + typ: "agent-native-ask-app-task-handle", + }) + .encrypt(handleKey(rootSecrets()[0])); +} + +export async function verifyAskAppTaskHandle( + handle: string, + expected: { issuerApp: string; subject: string }, +): Promise<{ route: AskAppTaskHandleRoute; taskId: string }> { + const issuerApp = normalizedIdentity(expected.issuerApp); + const subject = normalizedIdentity(expected.subject); + if (!handle.trim() || !issuerApp || !subject) throw invalidHandle(); + + for (const rootSecret of rootSecrets()) { + try { + const { plaintext, protectedHeader } = await compactDecrypt( + handle, + handleKey(rootSecret), + ); + if ( + protectedHeader.alg !== "dir" || + protectedHeader.enc !== "A256GCM" || + protectedHeader.typ !== "agent-native-ask-app-task-handle" + ) { + continue; + } + const claims = JSON.parse( + new TextDecoder().decode(plaintext), + ) as Partial; + if ( + claims.version !== HANDLE_VERSION || + claims.issuerApp !== issuerApp || + claims.subject !== subject || + typeof claims.taskId !== "string" || + !claims.taskId || + typeof claims.expiresAt !== "number" || + claims.expiresAt <= Math.floor(Date.now() / 1000) || + !validRoute(claims.route) + ) { + continue; + } + return { route: claims.route, taskId: claims.taskId }; + } catch { + // All handle failures are deliberately indistinguishable to callers. + } + } + throw invalidHandle(); +} diff --git a/packages/core/src/mcp/builtin-cross-app.spec.ts b/packages/core/src/mcp/builtin-cross-app.spec.ts index 3a6c3fe52e..d87510067b 100644 --- a/packages/core/src/mcp/builtin-cross-app.spec.ts +++ b/packages/core/src/mcp/builtin-cross-app.spec.ts @@ -350,6 +350,7 @@ describe("list_apps — reports the live request origin for the current app", () const result: any = await tools.list_apps.run({}); expect(result.workspace).toBe(false); expect(result.apps).toHaveLength(1); + expect(result.apps[0].id).toBe("content"); expect(result.apps[0].url).toBe("http://localhost:8080"); expect(result.apps[0].port).toBe(8080); expect(result.apps[0].running).toBe(true); @@ -686,22 +687,17 @@ describe("ask_app — honest routing metadata", () => { expect(sendSpy).not.toHaveBeenCalled(); }); - it("does not falsely claim delegation when the target is unreachable", async () => { + it("rejects an unreachable target before invoking the local agent", async () => { + const askAgent = vi.fn(async () => "local-answer"); const tools = getBuiltinCrossAppTools( baseConfig({ - askAgent: async () => "local-answer", + askAgent, }), ); - // "calendar" is not a resolvable workspace app in this test env, so it - // falls back to local but must say so honestly. - const result: any = await tools.ask_app.run({ - app: "calendar", - message: "hi", - }); - expect(result.routedVia).toBe("local"); - expect(result.app).toBe("mail"); - expect(typeof result.note).toBe("string"); - expect(result.note).toContain("calendar"); + await expect( + tools.ask_app.run({ app: "calendar", message: "hi" }), + ).rejects.toThrow(/No reachable ask_app route for app "calendar"/); + expect(askAgent).not.toHaveBeenCalled(); }); it("throws when no agent handler exists and target is local", async () => { @@ -1098,23 +1094,121 @@ describe("ask_app — org-directory routing", () => { expect(result.note).toBeUndefined(); }); - it("directory error ⇒ silent [] ⇒ falls back to honest local answer", async () => { + it("directory error ⇒ unknown target fails closed before the local agent", async () => { vi.spyOn(orgDirectory, "fetchOrgApps").mockResolvedValue([]); const callAgentSpy = vi.spyOn(a2aClient, "callAgent"); + const askAgent = vi.fn(async () => "local-answer"); + const tools = getBuiltinCrossAppTools(baseConfig({ askAgent })); + await expect( + tools.ask_app.run({ app: "calendar", message: "hi" }), + ).rejects.toThrow(/No reachable ask_app route for app "calendar"/); + + expect(callAgentSpy).not.toHaveBeenCalled(); + expect(askAgent).not.toHaveBeenCalled(); + }); + + it("polls a durable org task from its signed handle after discovery disappears", async () => { + vi.spyOn(orgDirectory, "fetchOrgApps") + .mockResolvedValueOnce([ + { + id: "content", + name: "Content", + url: "https://content.acme.com", + a2aUrl: "https://content.acme.com/_agent-native/a2a", + }, + ]) + .mockResolvedValue([]); + vi.spyOn(callerAuth, "resolveA2ACallerAuth").mockResolvedValue({ + apiKey: "signed-org-jwt", + userEmail: "caller@acme.com", + orgId: "org-1", + orgDomain: "acme.com", + orgSecret: "org-secret", + metadata: {}, + }); + const sendSpy = vi + .spyOn(a2aClient.A2AClient.prototype, "send") + .mockResolvedValue({ + id: "content-task-1", + status: { state: "working" }, + history: [], + artifacts: [], + } as any); + const getTaskSpy = vi + .spyOn(a2aClient.A2AClient.prototype, "getTask") + .mockResolvedValue({ + id: "content-task-1", + status: { + state: "completed", + message: { + role: "agent", + parts: [{ type: "text", text: "content answer" }], + }, + }, + history: [], + artifacts: [], + } as any); const tools = getBuiltinCrossAppTools( baseConfig({ askAgent: async () => "local-answer" }), + { origin: "https://mail.acme.com" }, ); - const result: any = await tools.ask_app.run({ - app: "calendar", - message: "hi", + + const submitted: any = await runWithRequestContext( + { userEmail: "caller@acme.com", orgId: "org-1" }, + () => + tools.ask_app.run({ + app: "content", + message: "recover this document", + async: true, + }), + ); + expect(submitted.taskHandle).toEqual(expect.any(String)); + expect(submitted.poll).toMatchObject({ + tool: "ask_app_status", + arguments: { taskHandle: submitted.taskHandle }, }); - expect(callAgentSpy).not.toHaveBeenCalled(); - expect(result.routedVia).toBe("local"); - expect(result.app).toBe("mail"); - expect(typeof result.note).toBe("string"); - expect(result.note).toContain("calendar"); - expect(result.response).toBe("local-answer"); + await expect( + runWithRequestContext( + { userEmail: "other@acme.com", orgId: "org-1" }, + () => tools.ask_app_status.run({ taskHandle: submitted.taskHandle }), + ), + ).rejects.toThrow(/^Invalid or expired ask_app task handle\.$/); + const otherConnector = getBuiltinCrossAppTools( + baseConfig({ appId: "calendar", askAgent: async () => "unused" }), + { origin: "https://calendar.acme.com" }, + ); + await expect( + runWithRequestContext( + { userEmail: "caller@acme.com", orgId: "org-1" }, + () => + otherConnector.ask_app_status.run({ + taskHandle: submitted.taskHandle, + }), + ), + ).rejects.toThrow(/^Invalid or expired ask_app task handle\.$/); + expect(getTaskSpy).not.toHaveBeenCalled(); + + const completed: any = await runWithRequestContext( + { userEmail: "caller@acme.com", orgId: "org-1" }, + () => tools.ask_app_status.run({ taskHandle: submitted.taskHandle }), + ); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(getTaskSpy).toHaveBeenCalledWith("content-task-1"); + expect(completed).toMatchObject({ + app: "content", + routedVia: "a2a", + taskId: "content-task-1", + status: "completed", + response: "content answer", + }); + await runWithRequestContext( + { userEmail: "caller@acme.com", orgId: "org-1" }, + () => tools.ask_app_status.run({ taskHandle: submitted.taskHandle }), + ); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(getTaskSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/core/src/mcp/builtin-tools.ts b/packages/core/src/mcp/builtin-tools.ts index 087246d429..cb3ec93ddf 100644 --- a/packages/core/src/mcp/builtin-tools.ts +++ b/packages/core/src/mcp/builtin-tools.ts @@ -21,9 +21,9 @@ * `open_app` / `create_workspace_app` return an **absolute** URL on the * *target* app's origin when it differs from this app (so a workspace link * lands in the right app), and a relative path for the same app / standalone. - * `ask_app` routes to a *different* workspace app over A2A when possible and - * reports `routedVia: "a2a"`; otherwise it answers locally - * (`routedVia: "local"`) and never falsely claims cross-app delegation. + * `ask_app` routes a known *different* workspace app over A2A and reports + * `routedVia: "a2a"`; unknown cross-app targets fail closed instead of running + * on the current app. Same-app requests report `routedVia: "local"`. * | `list_templates` | none | `{ templates: [...] }` (allow-list only) | * * Node-only at call time (workspace resolution + scaffolding use `fs`), but @@ -36,7 +36,12 @@ import type { ActionEntry } from "../agent/production-agent.js"; import type { ActionTool } from "../agent/types.js"; import { getConfiguredAppBasePath } from "../server/app-base-path.js"; import { buildDeepLink } from "../server/deep-link.js"; +import { getRequestUserEmail } from "../server/request-context.js"; import { MCP_APP_CHAT_BRIDGE_QUERY_PARAM } from "../shared/embed-auth.js"; +import { + signAskAppTaskHandle, + verifyAskAppTaskHandle, +} from "./ask-app-task-handle.js"; import type { MCPConfig } from "./build-server.js"; import { embedApp } from "./embed-app.js"; import { fetchOrgApps, type OrgApp } from "./org-directory.js"; @@ -112,6 +117,7 @@ interface AskAppTaskResult { app: string; routedVia: "local" | "a2a"; taskId: string; + taskHandle?: string; status: string; response?: string; error?: string; @@ -120,7 +126,7 @@ interface AskAppTaskResult { pollAfterMs?: number; poll?: { tool: "ask_app_status"; - arguments: { app: string; taskId: string }; + arguments: { app: string; taskId: string; taskHandle?: string }; }; message?: string; statusRead?: "unavailable"; @@ -231,13 +237,18 @@ function taskText(task: Task): string { ); } -function askAppTaskResult(route: AskAppRoute, task: Task): AskAppTaskResult { +function askAppTaskResult( + route: AskAppRoute, + task: Task, + taskHandle?: string, +): AskAppTaskResult { const status = taskState(task); const response = taskText(task); const base = { app: route.app, routedVia: route.routedVia, taskId: task.id, + ...(taskHandle ? { taskHandle } : {}), status, ...(route.note ? { note: route.note } : {}), }; @@ -273,11 +284,17 @@ function askAppTaskResult(route: AskAppRoute, task: Task): AskAppTaskResult { pollAfterMs: ASK_APP_POLL_INTERVAL_MS, poll: { tool: "ask_app_status", - arguments: { app: route.app, taskId: task.id }, + arguments: { + app: route.app, + taskId: task.id, + ...(taskHandle ? { taskHandle } : {}), + }, }, message: `ask_app is still ${status}. Call ask_app_status with ` + - `taskId "${task.id}" to retrieve the final response.`, + (taskHandle + ? "the returned taskHandle to retrieve the final response." + : `taskId "${task.id}" to retrieve the final response.`), }; } @@ -412,6 +429,7 @@ async function waitForA2ATask( async function submitAskAppA2ATask( route: AskAppRoute, + issuerApp: string, message: string, maxWaitMs: number, approvedActions?: A2AApprovedAction[], @@ -434,12 +452,21 @@ async function submitAskAppA2ATask( }, ); const finalOrRunning = await waitForA2ATask(client, task, deadline); - return askAppTaskResult(route, finalOrRunning); + const subject = + typeof metadata.userEmail === "string" ? metadata.userEmail : "anonymous"; + const taskHandle = await signAskAppTaskHandle({ + issuerApp, + subject, + route, + taskId: task.id, + }); + return askAppTaskResult(route, finalOrRunning, taskHandle); } async function fetchAskAppA2ATask( route: AskAppRoute, taskId: string, + taskHandle?: string, ): Promise { const { client } = await createA2AClientForAskApp(route.origin); const maxAttempts = ASK_APP_STATUS_RETRY_DELAYS_MS.length + 1; @@ -451,7 +478,7 @@ async function fetchAskAppA2ATask( const startedAt = Date.now(); try { const task = await client.getTask(taskId); - return askAppTaskResult(route, task); + return askAppTaskResult(route, task, taskHandle); } catch (err) { const delayMs = ASK_APP_STATUS_RETRY_DELAYS_MS[attempt]; const errorCategory = askAppStatusErrorCategory(err); @@ -482,6 +509,7 @@ async function fetchAskAppA2ATask( taskId, errorCategory, maxAttempts, + taskHandle, ); } await new Promise((resolve) => setTimeout(resolve, delayMs)); @@ -541,11 +569,13 @@ function askAppStatusReadUnavailableResult( taskId: string, errorCategory: AskAppStatusErrorCategory, attempts: number, + taskHandle?: string, ): AskAppTaskResult { return { app: route.app, routedVia: route.routedVia, taskId, + ...(taskHandle ? { taskHandle } : {}), status: "unknown", statusRead: "unavailable", retryable: true, @@ -554,12 +584,18 @@ function askAppStatusReadUnavailableResult( pollAfterMs: ASK_APP_POLL_INTERVAL_MS, poll: { tool: "ask_app_status", - arguments: { app: route.app, taskId }, + arguments: { + app: route.app, + taskId, + ...(taskHandle ? { taskHandle } : {}), + }, }, message: "The durable ask_app task status could not be read after bounded retries. " + "The task may still be running or completed. Retry ask_app_status " + - "with the same app and taskId; do not resubmit ask_app.", + (taskHandle + ? "with the same taskHandle; do not resubmit ask_app." + : "with the same app and taskId; do not resubmit ask_app."), }; } @@ -631,7 +667,7 @@ function listAppsTool( livePort = 0; } } - const selfId = (config.appId ?? "").toLowerCase(); + const selfId = currentAppId(config); const isSelf = (id: string) => !!liveOrigin && (!ws.isWorkspace || (!!selfId && id.toLowerCase() === selfId)); @@ -644,7 +680,10 @@ function listAppsTool( source: "workspace" | "org-directory"; } - const apps: AppEntry[] = ws.apps.map((a) => + const resolvedApps = ws.isWorkspace + ? ws.apps + : ws.apps.map((app) => ({ ...app, id: selfId, name: config.name })); + const apps: AppEntry[] = resolvedApps.map((a) => isSelf(a.id) ? { id: a.id, @@ -991,6 +1030,7 @@ async function routeAskOverA2A( message: string, options?: { durable?: boolean; + issuerApp?: string; maxWaitMs?: number; requestOrigin?: string; approvedActions?: A2AApprovedAction[]; @@ -999,6 +1039,9 @@ async function routeAskOverA2A( { app: string; routedVia: "a2a"; response: string } | AskAppTaskResult > { if (options?.durable) { + if (!options.issuerApp) { + throw new Error("ask_app durable routing requires an issuer app id."); + } return submitAskAppA2ATask( { app: id, @@ -1006,6 +1049,7 @@ async function routeAskOverA2A( routedVia: "a2a", requestOrigin: options.requestOrigin ?? origin, }, + options.issuerApp, message, options.maxWaitMs ?? ASK_APP_DEFAULT_INLINE_WAIT_MS, options.approvedActions, @@ -1097,8 +1141,8 @@ function askAppTool( "param is optional (defaults to this app). When 'app' names a " + "different workspace app it is routed there over A2A; the result's " + "'routedVia' field reports whether it ran cross-app or locally. " + - "On hosted MCP, long tasks may return a durable taskId instead of a " + - "final response; call ask_app_status with that taskId until completed.", + "On hosted MCP, long tasks may return a durable taskHandle instead of " + + "a final response; pass that handle to ask_app_status until completed.", { app: { type: "string", @@ -1111,12 +1155,12 @@ function askAppTool( async: { type: "boolean", description: - "When true, start a durable task and return immediately with a taskId.", + "When true, start a durable task and return immediately with a taskHandle and legacy taskId.", }, maxWaitMs: { type: "number", description: - "Maximum time to wait inline before returning a taskId. Hosted MCP clamps this to 25000ms.", + "Maximum time to wait inline before returning a taskHandle. Hosted MCP clamps this to 25000ms.", }, approvedActions: { type: "array", @@ -1160,6 +1204,7 @@ function askAppTool( message, { durable: useDurableA2A, + issuerApp: selfId, maxWaitMs, requestOrigin: targetApp.origin, approvedActions, @@ -1195,6 +1240,7 @@ function askAppTool( message, { durable: useDurableA2A, + issuerApp: selfId, maxWaitMs, requestOrigin: dirMatch.url, approvedActions, @@ -1209,7 +1255,14 @@ function askAppTool( } } - // Same app (or no workspace / unknown target): answer locally with this + if (requestedApp && requestedApp.toLowerCase() !== selfId) { + throw new Error( + `No reachable ask_app route for app "${requestedApp}". ` + + "Call list_apps and retry with an available app id.", + ); + } + + // Same app (or no target): answer locally with this // app's own ask-agent handler — the same entry point the HTTP MCP mount // + A2A use, so there is no second agent runner. if (!config.askAgent) { @@ -1218,15 +1271,6 @@ function askAppTool( ); } - // If the caller named an app we couldn't route to (unknown id, or no - // workspace), say so honestly instead of claiming we reached it. - const unresolved = - !!requestedApp && requestedApp.toLowerCase() !== selfId; - const note = unresolved - ? `Requested app "${requestedApp}" is not a reachable workspace ` + - `app; answered with this app ("${selfId}") instead.` - : undefined; - // Hosted MCP cannot safely keep a JSON request/response open for a full // agent loop: serverless gateways can return an inactivity 504 before // the result body exists. When we know the running app origin, submit the @@ -1240,8 +1284,8 @@ function askAppTool( origin: localA2AEndpointUrl, routedVia: "local", requestOrigin: requestMeta?.origin, - ...(note ? { note } : {}), }, + selfId, message, maxWaitMs, approvedActions, @@ -1267,7 +1311,6 @@ function askAppTool( return { app: selfId, routedVia: "local", - ...(note ? { note } : {}), response: inline.response || "(no response)", }; } @@ -1276,7 +1319,7 @@ function askAppTool( // same way the old unbounded `await config.askAgent(message)` did. throw new Error(inline.error || "ask_app task failed."); } - return askAppInlineTaskResult(selfId, inline.taskId, inline, note); + return askAppInlineTaskResult(selfId, inline.taskId, inline); }, }; } @@ -1300,16 +1343,43 @@ function askAppStatusTool( }, taskId: { type: "string", - description: "The durable task id returned by ask_app.", + description: + "Legacy durable task id returned by ask_app. Use taskHandle when present.", + }, + taskHandle: { + type: "string", + description: + "Opaque task handle returned by ask_app. It preserves the original route across cold starts and discovery changes.", }, }, - ["taskId"], ), readOnly: true, parallelSafe: true, run: async (args: Record) => { - const taskId = String(args.taskId ?? "").trim(); - if (!taskId) throw new Error("ask_app_status requires 'taskId'."); + const suppliedTaskId = String(args.taskId ?? "").trim(); + const taskHandle = String(args.taskHandle ?? "").trim(); + if (!taskHandle && !suppliedTaskId) { + throw new Error("ask_app_status requires 'taskHandle' or 'taskId'."); + } + + if (taskHandle) { + const verified = await verifyAskAppTaskHandle(taskHandle, { + issuerApp: currentAppId(config), + subject: getRequestUserEmail() ?? "anonymous", + }); + const suppliedApp = String(args.app ?? "") + .trim() + .toLowerCase(); + if ( + (suppliedTaskId && suppliedTaskId !== verified.taskId) || + (suppliedApp && suppliedApp !== verified.route.app.toLowerCase()) + ) { + throw new Error("Invalid or expired ask_app task handle."); + } + return fetchAskAppA2ATask(verified.route, verified.taskId, taskHandle); + } + + const taskId = suppliedTaskId; // The no-derivable-origin ask_app fallback tracks its tasks in a // process-local map, not the durable A2A store. Check it FIRST —