diff --git a/catalogs/mcp/README.md b/catalogs/mcp/README.md index c12449cc8c..c5d0688d55 100644 --- a/catalogs/mcp/README.md +++ b/catalogs/mcp/README.md @@ -37,6 +37,12 @@ For `updateDataModel`, keys starting with `/` are absolute paths, while relative The v1 catalog, [v1/catalog.json](v1/catalog.json), targets A2UI protocol v1.0. Its catalog ID is `https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json`. It declares `callMcpTool` and `updateDataModel`, with argument and return types taken from the v1.0 `common_types.json`, and the `McpApp` component. +### Tool data payload + +In the v1 catalog, `callMcpTool` returns a payload with `toolName`, `toolInput`, `toolResult` (the raw `CallToolResult`), `structuredContent` (when the server sends one), `text` (the `text` blocks joined with `\n`) and `json` (the parsed value of `text` when it is a JSON object or array; when instead several text blocks are each a JSON object or array, the array of their parsed values; absent otherwise). When the result has an MCP App resource, the payload also carries its `resourceUri`, `html`, `csp` and `permissions`. + +The optional `targetDataPath` argument writes the payload of each successful call to that path, replacing the object there. A call whose response arrives after a newer call to the same path has started does not write, so the latest call wins. A result flagged `isError` fails the call and writes nothing. + ### Data functions | Function | Arguments | Returns | diff --git a/catalogs/mcp/v1/catalog.json b/catalogs/mcp/v1/catalog.json index 59ceafe07a..529ab61922 100644 --- a/catalogs/mcp/v1/catalog.json +++ b/catalogs/mcp/v1/catalog.json @@ -116,7 +116,7 @@ "functions": { "callMcpTool": { "type": "object", - "description": "Invokes a tool on a connected Model Context Protocol (MCP) server and returns the result.", + "description": "Invokes a tool on a connected Model Context Protocol (MCP) server and returns a payload with `toolName`, `toolInput`, `toolResult` (the raw result), `structuredContent` (when the server sends one), `text` (the text blocks joined with newlines) and `json` (the parsed value of `text` when it is a JSON object or array, else the array of parsed values when several text blocks are each one, absent otherwise), plus `resourceUri`, `html`, `csp` and `permissions` when the result has an MCP App resource. A result flagged `isError` fails the call and writes nothing.", "returnType": "any", "properties": { "@call": { @@ -138,7 +138,7 @@ }, "targetDataPath": { "$ref": "common_types.json#/$defs/DynamicString", - "description": "Optional JSON Pointer path on the surface data model where a resolved MCP App resource payload (`resourceUri`, `html`, `csp`, `permissions`, `toolInput`, `toolResult`) is written." + "description": "Optional JSON Pointer path on the surface data model where the payload of each successful call is written, replacing the object at that path. A call whose response arrives after a newer call to the same path has started does not write, so the latest call wins." } }, "required": ["name"], diff --git a/conformance/core/catalog.yaml b/conformance/core/catalog.yaml index bf881c63c9..6f0d0c2bd7 100644 --- a/conformance/core/catalog.yaml +++ b/conformance/core/catalog.yaml @@ -891,6 +891,12 @@ protocolVersion: "v0.9" catalogPath: "catalogs/mcp/catalog.json" +- name: test_v10_mcp_catalog_round_trip + description: Tests that the published v1 MCP catalog round-trips with its McpApp component, its functions, their common_types.json references and its $defs unions intact. + action: round_trip + protocolVersion: "1.0" + catalogPath: "catalogs/mcp/v1/catalog.json" + - name: test_v10_custom_catalog_round_trip_function_metadata description: Tests that function descriptions, return types, allowedCallers and requiresUserActivation round-trip in the v1.0 function shape. The basic catalogs declare no allowedCallers. action: round_trip diff --git a/typescript/catalogs/mcp/CHANGELOG.md b/typescript/catalogs/mcp/CHANGELOG.md index a2f6745701..4ba68183dd 100644 --- a/typescript/catalogs/mcp/CHANGELOG.md +++ b/typescript/catalogs/mcp/CHANGELOG.md @@ -3,6 +3,7 @@ - Emit `'1.0'` as `version` on synthesized `createSurface` and `updateComponents` messages in `callMcpTool` when creating a standalone MCP App surface, matching the unified v1.0 protocol version wire format. [#3097](https://github.com/a2ui-project/a2ui/issues/3097) - The published package contains only the runtime build output; test files and TypeScript build info are no longer included. [#3348](https://github.com/a2ui-project/a2ui/pull/3348) - **BREAKING CHANGE**: Hosts set up the catalog once with `configureMcpCatalog`; `createCallMcpToolImplementation`, `CallMcpToolApi` and `A2UI_MIME_TYPE` are removed and `MCP_CATALOG_ID` now identifies the v1.0 catalog (`catalogs/mcp/v1/catalog.json`, bundled as `@a2ui/catalog-mcp/catalog.json`). `callMcpTool` now hands `ui://` MCP App resources to the host. [#3186](https://github.com/a2ui-project/a2ui/pull/3186) +- `callMcpTool` returns a payload with `toolName`, `toolInput`, `toolResult`, `structuredContent`, `text` and `json`, and writes it to `targetDataPath` when set, where the latest call to a path wins. [#3728](https://github.com/a2ui-project/a2ui/pull/3728) - The `updateDataModel` data function writes key-value pairs to the surface data model; its `UpdateDataModelApi` and `UpdateDataModelImplementation` are exported for hosts that register functions one by one. [#3573](https://github.com/a2ui-project/a2ui/pull/3573) - Add `mcpCatalog`, the v1.0 MCP catalog with the `McpApp` universal component (`A2uiMcpApp`) and `callMcpTool`, ready to hand to a `MessageProcessor`. `McpApp` renders an MCP App in the shared sandbox, binds host data through `data.paths` and dispatches the tools it is allowed to call as A2UI actions. [#2799](https://github.com/a2ui-project/a2ui/pull/2799) diff --git a/typescript/catalogs/mcp/README.md b/typescript/catalogs/mcp/README.md index 3c98b996e2..c078d65f0d 100644 --- a/typescript/catalogs/mcp/README.md +++ b/typescript/catalogs/mcp/README.md @@ -11,7 +11,7 @@ A2UI separates UI layout from backend logic through catalogs. This catalog provi - The host supplies one hook, `getMcpClientForTool(toolName)`. The catalog uses the returned client for tool execution, UI resource reads, and tool discovery. - Tool results are processed automatically. The catalog fetches and caches UI resources referenced by results, then applies any A2UI messages embedded in the result content. - Tool arguments support data bindings, allowing form state to pass directly into tool calls. -- `updateDataModel` writes path-value pairs to the surface data model, so a payload can update several paths at once from a literal object, a binding or a function call. +- `callMcpTool` returns the text, parsed JSON and structured content of each result, and can write them to `targetDataPath`. `updateDataModel` writes path-value pairs to the surface data model, so a payload can update several paths at once from a literal object, a binding or a function call. - The `McpApp` component embeds MCP Apps in a sandboxed double-iframe and connects them to the host through `AppBridge`. - The catalog works with any A2UI web renderer built on `MessageProcessor`. @@ -25,11 +25,22 @@ The canonical schema lives at [`catalogs/mcp/v1/catalog.json`](../../../catalogs | :--------------- | :-------------- | :---------------- | :--------------------------------------------------------------------------------------------------------- | | `name` | `DynamicString` | Yes | The MCP tool to execute. | | `arguments` | `object` | No (default `{}`) | Arguments passed to the tool: an object of dynamic values, or a function call that returns one. | -| `targetDataPath` | `DynamicString` | No | Optional JSON Pointer path on the surface data model where a resolved MCP App resource payload is written. | +| `targetDataPath` | `DynamicString` | No | Optional JSON Pointer path on the surface data model where the payload of each successful call is written. | Tools are addressed by name only. A2UI payloads never name a server, because multi-server routing is resolved by the host inside `getMcpClientForTool`. -The function returns the raw MCP `CallToolResult`. It throws an `A2uiExpressionError` if the client cannot be resolved, the call returns no result, or the result has `isError: true`. +The function returns a payload with these fields: + +| Field | Value | +| :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `toolName` | The name of the tool. | +| `toolInput` | The resolved arguments. | +| `toolResult` | The raw MCP `CallToolResult`. | +| `structuredContent` | `toolResult.structuredContent`, unchanged, when the server sends one. | +| `text` | The `text` content blocks, joined with `\n`. | +| `json` | The parsed value of `text` when it is a JSON object or array. When instead two or more text blocks are each a JSON object or array, the array of their parsed values. Absent otherwise. | + +When the result has an MCP App resource, the payload also carries its `resourceUri`, `html`, `csp` and `permissions`. With `targetDataPath`, each call replaces the object at that path with its payload. A call whose response arrives after a newer call to the same path has started does not write, so out-of-order responses, such as autocomplete suggestions, never overwrite newer ones; it still returns its payload. The function throws an `A2uiExpressionError`, and writes nothing, if the client cannot be resolved, the call returns no result, or the result has `isError: true`. `updateDataModel` writes path-value pairs to the surface data model: @@ -81,7 +92,7 @@ Push every catalog before the first message arrives, since `getClientCapabilitie A surface resolves components and function calls through its default catalog, the `catalogId` of its `createSurface` message, unless the component or the `@call` object names another registered catalog with its own `catalogId`. A payload that mixes basic components with `McpApp` and the MCP functions therefore needs no composed catalog: it creates the surface with the basic catalog id and sets `"catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json"` on the `McpApp` component and on each MCP function call, as the [catalog examples](../../../catalogs/mcp/v1/examples) do. -Function calls in expressions (`${...}`, `@call`) are the exception: they always resolve through the surface's default catalog. A payload that calls `callMcpTool` from expressions next to basic components creates its surface with a catalog that carries both; build one under the id the agent uses (`mcpCatalog.id` is the canonical one): +Function calls inside `${...}` string expressions are the exception: they resolve through the surface's default catalog. A payload that calls `callMcpTool` that way next to basic components creates its surface with a catalog that carries both; build one under the id the agent uses (`mcpCatalog.id` is the canonical one): ```typescript import {basicCatalog, Catalog, MessageProcessor} from '@a2ui/web_core/v1_0'; @@ -130,7 +141,7 @@ On each invocation the catalog: 4. Fetches each UI resource through `resources/read`, once per URI, and decodes every content block whose `mimeType` is `application/a2ui+json`. A resource carrying several such blocks contributes all of them, and one carrying none contributes nothing. 5. Processes each UI resource in the order its URI appeared, skipping any that would recreate a live surface. 6. Processes the A2UI messages inlined in `result.content`, in content order. Only an embedded resource block declaring `application/a2ui+json` counts: a text block is prose for the model, even when it holds a message. -7. Returns the `CallToolResult` unchanged to the caller. +7. Builds the payload, writes it to `targetDataPath` unless a newer call to that path has started, and returns it. ### Invoke a tool during bootstrap @@ -160,29 +171,22 @@ await CallMcpToolImplementation.execute( ### Trigger tools from A2UI payloads -A surface created under a catalog that includes `callMcpTool` can invoke server tools: +A v1.0 payload calls `callMcpTool` from any surface whose host registered `mcpCatalog`, naming the catalog on the call. Arguments may be data bindings, which resolve against the calling surface, and `targetDataPath` writes the returned payload into the data model so components can bind to its fields (such as `/weather/json/temperature` or `/weather/text`): ```json { - "createSurface": { - "surfaceId": "weather-widget", - "catalogId": "https://a2ui.org/specification/v0_9/catalogs/mcp/mcp_catalog.json" - } -} -``` - -Arguments may be data bindings, which resolve against the calling surface: - -```json -{ - "call": "callMcpTool", + "@call": "callMcpTool", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", "args": { "name": "get_weather", - "arguments": {"city": {"path": "/form/city"}} + "arguments": {"city": {"@path": "/form/city"}}, + "targetDataPath": "/weather" } } ``` +A surface created with the MCP catalog id as its default needs no `catalogId` on the call. + ## The `McpApp` component `McpApp` renders an [MCP App](https://github.com/modelcontextprotocol/ext-apps) in a surface. The app is an HTML document the agent sends in the component's `htmlContent`; it runs in a sandboxed inner frame behind a proxy page served from the host's origin (see [Sandbox asset](#sandbox-asset)) and talks to the host with the MCP Apps JSON-RPC protocol over `postMessage`, using `AppBridge` from `@modelcontextprotocol/ext-apps` on the host side. An app written with the MCP Apps App SDK works as it is; the [catalog examples](../../../catalogs/mcp/v1/examples) show the raw protocol. @@ -332,7 +336,7 @@ The component owns its bridge: `McpAppBridge` (`src/components/mcp_app_bridge.ts | `src/index.ts` | Package entry: `mcpCatalog`, `A2uiMcpApp`, `configureMcpCatalog` and the `*Api` and `*Implementation` of every function | | `src/catalog.ts` | `MCP_CATALOG_ID` and `mcpCatalog`: the `McpApp` component, `callMcpTool` and `updateDataModel` | | `src/mcp_catalog_config.ts` | `configureMcpCatalog`: the host's MCP client resolver, message processor and sandbox location | -| `src/functions/callMcpTool.ts` | MCP tool execution, UI resource discovery, caching, and message decoding | +| `src/functions/callMcpTool.ts` | MCP tool execution, the tool data payload, UI resource discovery, caching, and message decoding | | `src/functions/update_data_model.ts` | `updateDataModel` | | `src/dynamic-values.ts` | Resolution of dynamic values nested in literal containers | | `src/components/mcp_app.ts` | The `McpApp` universal component and its API | diff --git a/typescript/catalogs/mcp/src/components/mcp_app.ts b/typescript/catalogs/mcp/src/components/mcp_app.ts index 4675babd32..ac45f4333a 100644 --- a/typescript/catalogs/mcp/src/components/mcp_app.ts +++ b/typescript/catalogs/mcp/src/components/mcp_app.ts @@ -22,6 +22,7 @@ import { } from '@a2ui/web_core/v1_0'; import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js'; import {z} from 'zod'; +import type {McpToolDataPayload} from '../functions/callMcpTool.js'; import {getMcpCatalogConfig} from '../mcp_catalog_config.js'; import {ComponentContextFrameHost} from '../shared/sandbox/component_context_frame_host.js'; import type { @@ -201,7 +202,9 @@ class McpAppElement extends SandboxedFrameElement { // Executed directly rather than through the catalog invoker: the invoker validates the // arguments as A2UI payload values, which excludes the plain objects an app may send, and // the bridge has already checked them for prototype pollution, depth and size. - const res = await callMcpTool.execute({name, arguments: args}, this.context.dataContext); + const payload = await callMcpTool.execute({name, arguments: args}, this.context.dataContext); + // `callMcpTool` returns its tool data payload; the app receives the raw result inside it. + const res = (payload as Partial | undefined)?.toolResult ?? payload; if (res && typeof res === 'object' && Array.isArray((res as CallToolResult).content)) { return res as CallToolResult; } diff --git a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts index fc72e58a1f..a7febc1148 100644 --- a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts +++ b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts @@ -38,11 +38,13 @@ import { extractA2uiMessages, extractInlineMcpAppResources, extractMcpAppResourceLinkUris, + extractToolResultContent, parseA2uiMessages, parseMcpAppResources, readUiResourceUris, type McpAppResourcePayload, type McpToolClient, + type McpToolDataPayload, } from './callMcpTool.js'; import mcpCatalogJson from '../catalog.json' with {type: 'json'}; import {UpdateDataModelImplementation} from './update_data_model.js'; @@ -265,7 +267,7 @@ describe('callMcpTool', () => { }); describe('tool invocation', () => { - it('issues tools/call on the resolved client and returns the raw result', async () => { + it('issues tools/call on the resolved client and returns the tool data payload', async () => { const client = createFakeClient(); const catalog = catalogFor(client); assert.strictEqual(catalog.id, MCP_CATALOG_ID); @@ -279,7 +281,10 @@ describe('callMcpTool', () => { assert.deepStrictEqual(client.calls, [{toolName: 'counter', args: {count: 5}}]); assert.deepStrictEqual(result, { - content: [{type: 'text', text: 'Result of counter: {"count":5}'}], + toolName: 'counter', + toolInput: {count: 5}, + toolResult: {content: [{type: 'text', text: 'Result of counter: {"count":5}'}]}, + text: 'Result of counter: {"count":5}', }); }); @@ -693,6 +698,246 @@ describe('callMcpTool', () => { }); }); + describe('tool data payload', () => { + /** Calls `toolName` once, writing to `targetDataPath` when given, and returns the payload. */ + const invokeWithPath = async ( + client: FakeClient, + dataModel: DataModel, + targetDataPath?: string, + toolName = 'get_data', + ) => { + const catalog = catalogFor(client); + return (await catalog.invoker( + 'callMcpTool', + {name: toolName, ...(targetDataPath ? {targetDataPath} : {})}, + createTestDataContext(dataModel, catalog), + )) as McpToolDataPayload; + }; + + /** A promise with its resolve function, to control the order responses arrive in. */ + const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + return {promise, resolve}; + }; + + it('passes structuredContent through unchanged', async () => { + const structuredContent = {temperature: 21, unit: 'celsius'}; + const client = createFakeClient({ + result: {content: [{type: 'text', text: 'It is 21 °C.'}], structuredContent}, + }); + const dataModel = new DataModel({}); + + const payload = await invokeWithPath(client, dataModel, '/weather'); + + assert.deepStrictEqual(payload.structuredContent, structuredContent); + assert.strictEqual(payload.text, 'It is 21 °C.'); + assert.strictEqual(payload.json, undefined); + assert.deepStrictEqual(dataModel.get('/weather/structuredContent'), structuredContent); + }); + + it('parses JSON sent as text', async () => { + const rows = [{id: 1}, {id: 2}]; + const client = createFakeClient({ + result: {content: [{type: 'text', text: ` ${JSON.stringify(rows)}\n`}]}, + }); + + const payload = await invokeWithPath(client, new DataModel({})); + + assert.deepStrictEqual(payload.json, rows); + assert.strictEqual(payload.structuredContent, undefined); + }); + + it('collects the JSON of several blocks, skipping a prose block', async () => { + const client = createFakeClient({ + result: { + content: [ + {type: 'text', text: 'Summarizer key: abc'}, + {type: 'text', text: '{"id":1}'}, + {type: 'text', text: '{"id":2}'}, + ], + }, + }); + + const payload = await invokeWithPath(client, new DataModel({})); + + assert.deepStrictEqual(payload.json, [{id: 1}, {id: 2}]); + assert.strictEqual(payload.text, 'Summarizer key: abc\n{"id":1}\n{"id":2}'); + }); + + it('keeps a JSON scalar as text only', async () => { + const client = createFakeClient({result: {content: [{type: 'text', text: '42'}]}}); + + const payload = await invokeWithPath(client, new DataModel({})); + + assert.strictEqual(payload.text, '42'); + assert.strictEqual(Object.hasOwn(payload, 'json'), false); + }); + + it('keeps plain text as text, with no json or structuredContent', async () => { + const client = createFakeClient({ + result: {content: [{type: 'text', text: 'Prep time is 15 minutes.'}]}, + }); + const dataModel = new DataModel({}); + + const payload = await invokeWithPath(client, dataModel, '/recipe'); + + assert.deepStrictEqual(Object.keys(payload).sort(), [ + 'text', + 'toolInput', + 'toolName', + 'toolResult', + ]); + assert.strictEqual(dataModel.get('/recipe/text'), 'Prep time is 15 minutes.'); + }); + + it('merges the MCP App fields with structuredContent in one payload', async () => { + const appUri = 'ui://server/chart'; + const client = createFakeClient({ + result: { + content: [ + {type: 'text', text: 'Rendered the chart.'}, + {type: 'resource', resource: {uri: appUri, mimeType: MCP_APP_MIME_TYPE, text: '

'}}, + ], + structuredContent: {points: [1, 2, 3]}, + } as CallToolResult, + }); + const dataModel = new DataModel({}); + + const payload = await invokeWithPath(client, dataModel, '/chart', 'render_chart'); + + assert.strictEqual(payload.resourceUri, appUri); + assert.strictEqual(payload.html, '

'); + assert.deepStrictEqual(payload.structuredContent, {points: [1, 2, 3]}); + assert.strictEqual(payload.toolName, 'render_chart'); + assert.deepStrictEqual(dataModel.get('/chart'), payload); + }); + + it('writes nothing when the result is flagged isError', async () => { + const client = createFakeClient({ + result: {isError: true, content: [{type: 'text', text: 'boom'}]}, + }); + const dataModel = new DataModel({result: 'previous'}); + + await assert.rejects(() => invokeWithPath(client, dataModel, '/result')); + + assert.strictEqual(dataModel.get('/result'), 'previous'); + }); + + it('returns exactly the payload it writes, toolName included', async () => { + const client = createFakeClient({result: {content: [{type: 'text', text: '{"a":1}'}]}}); + const dataModel = new DataModel({}); + + const payload = await invokeWithPath(client, dataModel, '/out', 'get_a'); + + assert.deepStrictEqual(dataModel.get('/out'), payload); + assert.strictEqual(payload.toolName, 'get_a'); + assert.deepStrictEqual(payload.toolInput, {}); + }); + + it('replaces the whole object at targetDataPath on each call', async () => { + const client = createFakeClient({result: {content: [{type: 'text', text: 'plain'}]}}); + const dataModel = new DataModel({out: {json: {stale: true}, extra: 'old'}}); + + await invokeWithPath(client, dataModel, '/out'); + + assert.strictEqual(dataModel.get('/out/json'), undefined); + assert.strictEqual(dataModel.get('/out/extra'), undefined); + assert.strictEqual(dataModel.get('/out/text'), 'plain'); + }); + + it('writes nothing without targetDataPath, and still returns the payload', async () => { + const client = createFakeClient({result: {content: [{type: 'text', text: 'hello'}]}}); + const dataModel = new DataModel({}); + + const payload = await invokeWithPath(client, dataModel); + + assert.deepStrictEqual(dataModel.get('/'), {}); + assert.strictEqual(payload.text, 'hello'); + }); + + it('writes only the latest of two overlapping calls to the same targetDataPath', async () => { + const first = deferred(); + const second = deferred(); + const pending = [first.promise, second.promise]; + const received: McpAppResourcePayload[] = []; + const client = createFakeClient({result: () => pending.shift()}); + const catalog = new Catalog( + MCP_CATALOG_ID, + '0.9', + [], + [ + createCallMcpToolImplementation(() => ({ + defaultVersion: 'v0.9', + getMcpClientForTool: () => asClient(client), + processor, + onMcpAppResource: payload => received.push(payload), + })), + ], + ); + const dataModel = new DataModel({}); + const context = createTestDataContext(dataModel, catalog); + const appBlock = (html: string) => ({ + type: 'resource' as const, + resource: {uri: 'ui://server/suggest', mimeType: MCP_APP_MIME_TYPE, text: html}, + }); + + const firstCall = catalog.invoker( + 'callMcpTool', + {name: 'suggest', arguments: {q: 'a'}, targetDataPath: '/suggestions'}, + context, + ); + const secondCall = catalog.invoker( + 'callMcpTool', + {name: 'suggest', arguments: {q: 'ab'}, targetDataPath: '/suggestions'}, + context, + ); + second.resolve({content: [{type: 'text', text: '["ab"]'}, appBlock('

ab

')]}); + const secondPayload = (await secondCall) as McpToolDataPayload; + first.resolve({content: [{type: 'text', text: '["a"]'}, appBlock('

a

')]}); + const firstPayload = (await firstCall) as McpToolDataPayload; + + assert.deepStrictEqual(dataModel.get('/suggestions/json'), ['ab']); + assert.deepStrictEqual(dataModel.get('/suggestions'), secondPayload); + // The superseded call still returns its own payload, but does not reach the host. + assert.deepStrictEqual(firstPayload.json, ['a']); + assert.deepStrictEqual( + received.map(payload => payload.html), + ['

ab

'], + ); + }); + + it('writes overlapping calls to different targetDataPaths', async () => { + const first = deferred(); + const second = deferred(); + const pending = [first.promise, second.promise]; + const client = createFakeClient({result: () => pending.shift()}); + const catalog = catalogFor(client); + const dataModel = new DataModel({}); + const context = createTestDataContext(dataModel, catalog); + + const firstCall = catalog.invoker( + 'callMcpTool', + {name: 'get_a', targetDataPath: '/a'}, + context, + ); + const secondCall = catalog.invoker( + 'callMcpTool', + {name: 'get_b', targetDataPath: '/b'}, + context, + ); + second.resolve({content: [{type: 'text', text: 'B'}]}); + await secondCall; + first.resolve({content: [{type: 'text', text: 'A'}]}); + await firstCall; + + assert.strictEqual(dataModel.get('/a/text'), 'A'); + assert.strictEqual(dataModel.get('/b/text'), 'B'); + }); + }); + describe('dynamic values', () => { it('resolves dynamic data bindings for name and arguments via DataContext', async () => { const client = createFakeClient(); @@ -858,7 +1103,10 @@ describe('callMcpTool', () => { ); assert.deepStrictEqual(result, { - content: [{type: 'text', text: 'Result of get_user: {"id":"123"}'}], + toolName: 'get_user', + toolInput: {id: '123'}, + toolResult: {content: [{type: 'text', text: 'Result of get_user: {"id":"123"}'}]}, + text: 'Result of get_user: {"id":"123"}', }); }); }); @@ -949,6 +1197,44 @@ describe('message decoding', () => { }); }); + describe('extractToolResultContent', () => { + it('returns nothing for a result without text or structured content', () => { + assert.deepStrictEqual(extractToolResultContent({content: []}), {}); + }); + + it('ignores structuredContent that is not an object', () => { + assert.deepStrictEqual( + extractToolResultContent({content: [], structuredContent: 'text' as any}), + {}, + ); + }); + + it('joins the text blocks and parses them as one document when they form one', () => { + assert.deepStrictEqual( + extractToolResultContent({ + content: [ + {type: 'text', text: '{"rows":'}, + {type: 'image', data: 'AAAA', mimeType: 'image/png'}, + {type: 'text', text: '[1, 2]}'}, + ], + }), + {text: '{"rows":\n[1, 2]}', json: {rows: [1, 2]}}, + ); + }); + + it('does not collect a single JSON block next to prose', () => { + assert.deepStrictEqual( + extractToolResultContent({ + content: [ + {type: 'text', text: 'Found one row:'}, + {type: 'text', text: '{"id":1}'}, + ], + }), + {text: 'Found one row:\n{"id":1}'}, + ); + }); + }); + describe('readUiResourceUris', () => { it('reads _meta.ui.resourceUri from results and tool descriptors alike', () => { assert.deepStrictEqual(readUiResourceUris({_meta: {ui: {resourceUri: 'a2ui://t'}}}), [ diff --git a/typescript/catalogs/mcp/src/functions/callMcpTool.ts b/typescript/catalogs/mcp/src/functions/callMcpTool.ts index b48ed428ec..6fb948fa20 100644 --- a/typescript/catalogs/mcp/src/functions/callMcpTool.ts +++ b/typescript/catalogs/mcp/src/functions/callMcpTool.ts @@ -102,30 +102,43 @@ * {"content": [{"type": "text", "text": "Prep time is 15 minutes."}]} * ``` * - * No resource URI and no A2UI block, so nothing renders. The raw - * `CallToolResult` is still returned, which is what the data functions of this + * No resource URI and no A2UI block, so nothing renders. The call still + * returns its `McpToolDataPayload`, which is what the data functions of this * catalog read when a payload shapes tool output itself. * + * ## Tool data payload + * + * Every successful call returns an `McpToolDataPayload`: the tool name and + * input, the raw result, its `structuredContent` when the server sends one, + * the joined `text` blocks and their `json` parse. When `targetDataPath` is + * set, the payload replaces the object at that path. A call whose response + * arrives after a newer call to the same path has started does not write, so + * the latest call wins, and it still returns its payload. + * * ## Failures * * Every failure raises an `A2uiExpressionError` naming the tool: no client for * the tool, a transport error, a result flagged `isError`, or a payload that * declares the A2UI MIME type but holds invalid JSON. A resource that holds no * A2UI at all is not a failure: it contributes nothing and the call proceeds. + * A failed call writes nothing to `targetDataPath`. */ +// The argument schemas come from the v1.0 entry point: its `DynamicValue` accepts both the v1.0 +// `@path`/`@call` directives and the v0.9 `path`/`call` ones, so a v1.0 payload binding a tool +// argument validates when the surface is processed. The message types stay the v0.9 ones, which +// describe every payload a tool result may carry. +import type {A2uiMessage, CreateSurfaceMessage} from '@a2ui/web_core/v0_9'; import { A2uiExpressionError, DynamicStringSchema, DynamicValueSchema, createFunctionImplementation, - type A2uiMessage, - type CreateSurfaceMessage, type DataContext, type FunctionCall, type FunctionImplementation, type MessageProcessor, -} from '@a2ui/web_core/v0_9'; +} from '@a2ui/web_core/v1_0'; import type {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js'; import type {CallToolResult, ReadResourceResult} from '@modelcontextprotocol/sdk/types.js'; @@ -141,28 +154,59 @@ export const A2UI_MIME_TYPE = 'application/a2ui+json'; export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; /** - * Resolved MCP App resource (`text/html;profile=mcp-app`) together with its `_meta.ui` security - * metadata and the originating tool's input and result. + * What a successful `callMcpTool` call returns, and writes to `targetDataPath` when it is set. */ -export interface McpAppResourcePayload { - readonly resourceUri: string; - readonly html: string; +export interface McpToolDataPayload { + /** The name of the tool that was called. */ + readonly toolName: string; + /** The resolved arguments the tool was called with. */ + readonly toolInput: Record; + /** The raw `CallToolResult`. */ + readonly toolResult: Record; + /** `toolResult.structuredContent`, unchanged, when the server sends one. */ + readonly structuredContent?: Record; + /** The `text` content blocks of the result, joined with `\n`. */ + readonly text?: string; + /** + * `text` parsed as JSON when it is an object or array. Otherwise, when at least two text blocks + * parse to an object or array, an array of those parses, skipping the other blocks. + */ + readonly json?: unknown; + /** The URI of the MCP App resource the call resolved, if any. */ + readonly resourceUri?: string; + /** The HTML of the MCP App resource the call resolved, if any. */ + readonly html?: string; + /** The `_meta.ui.csp` of the MCP App resource. */ readonly csp?: { readonly connectDomains?: readonly string[]; readonly resourceDomains?: readonly string[]; readonly frameDomains?: readonly string[]; readonly baseUriDomains?: readonly string[]; }; + /** The `_meta.ui.permissions` of the MCP App resource. */ readonly permissions?: { readonly camera?: Record; readonly microphone?: Record; readonly geolocation?: Record; readonly clipboardWrite?: Record; }; - readonly toolInput?: Record; - readonly toolResult?: Record; } +/** + * Resolved MCP App resource (`text/html;profile=mcp-app`) together with its `_meta.ui` security + * metadata and the data payload of the originating tool call. + */ +export interface McpAppResourcePayload extends McpToolDataPayload { + readonly resourceUri: string; + readonly html: string; +} + +/** The fields of an MCP App resource payload that come from the resource itself. */ +type McpAppResource = Pick; + +/** The fields of `McpToolDataPayload` read out of the result content. */ +export type ToolResultContent = Pick; + /** Maximum number of decoded UI resources to cache per implementation. */ const MAX_CACHED_RESOURCES = 100; @@ -198,10 +242,11 @@ export const CallMcpToolApi = { 'The arguments to pass to the MCP tool: an object of dynamic values, or a function call that returns one.', ), targetDataPath: DynamicStringSchema.optional().describe( - 'Optional JSON Pointer path on the surface data model where a resolved MCP App resource payload (`resourceUri`, `html`, `csp`, `permissions`, `toolInput`, `toolResult`) is written.', + 'Optional JSON Pointer path on the surface data model where the payload of each successful call is written, replacing the object at that path. A call whose response arrives after a newer call to the same path has started does not write, so the latest call wins.', ), }), - description: 'Invokes a tool on a connected Model Context Protocol (MCP) server.', + description: + 'Invokes a tool on a connected Model Context Protocol (MCP) server and returns a payload with `toolName`, `toolInput`, `toolResult` (the raw result), `structuredContent` (when the server sends one), `text` (the text blocks joined with newlines) and `json` (the parsed value of `text` when it is a JSON object or array, else the array of parsed values when several text blocks are each one, absent otherwise), plus `resourceUri`, `html`, `csp` and `permissions` when the result has an MCP App resource. A result flagged `isError` fails the call and writes nothing.', }; /** @@ -255,7 +300,7 @@ export interface CallMcpToolOptions { interface DecodedUiResource { readonly a2uiMessages: A2uiMessage[]; - readonly mcpAppResources: Array>; + readonly mcpAppResources: McpAppResource[]; } /** @@ -264,7 +309,7 @@ interface DecodedUiResource { * The getter is read on every invocation, so the host can configure (or reconfigure) the MCP * client resolver and message processor after the catalog is registered. The implementation * invokes the named MCP tool, processes any referenced or inline `application/a2ui+json` - * resources through the configured processor, and returns the raw `CallToolResult`. + * resources through the configured processor, and returns an `McpToolDataPayload`. * * @param getOptions Returns the current options; see `CallMcpToolOptions`. */ @@ -277,6 +322,26 @@ export function createCallMcpToolImplementation( /** Cache of tool-declared UI resource URIs per MCP client. */ const declaredUiResourceUris = new WeakMap>>(); + /** + * Sequence number of the latest call per data model and absolute `targetDataPath`, so a + * response that arrives after a newer call to the same path has started is not written. + */ + const latestCallByPath = new WeakMap>(); + let callSequence = 0; + + /** Records a call to `path` and returns a check that tells whether it is still the latest. */ + function startCall(dataModel: object, path: string): () => boolean { + let latestByPath = latestCallByPath.get(dataModel); + if (!latestByPath) { + latestByPath = new Map(); + latestCallByPath.set(dataModel, latestByPath); + } + const sequence = ++callSequence; + latestByPath.set(path, sequence); + const paths = latestByPath; + return () => paths.get(path) === sequence; + } + async function readUiResource(client: McpToolClient, uri: string): Promise { let decodedByResourceUri = decodedByClient.get(client); if (!decodedByResourceUri) { @@ -346,6 +411,9 @@ export function createCallMcpToolImplementation( ); } const withVersion = (message: A2uiMessage) => ensureMessageVersion(message, defaultVersion); + const isLatestCall = targetDataPath + ? startCall(context.dataModel, context.resolvePath(targetDataPath)) + : () => true; try { const resolvedArguments = await resolveToolArguments(args.arguments ?? {}, context); @@ -373,19 +441,13 @@ export function createCallMcpToolImplementation( throw new Error(`MCP tool '${toolName}' execution failed: ${JSON.stringify(result)}`); } - const emitMcpAppResource = ( - base: Omit, - ) => { - const payload: McpAppResourcePayload = { - ...base, - toolInput: resolvedArguments, - toolResult: result as unknown as Record, - }; - if (targetDataPath) { - context.set(targetDataPath, payload); - } - onMcpAppResource?.(payload, context); + const data: McpToolDataPayload = { + toolName, + toolInput: resolvedArguments, + toolResult: result as unknown as Record, + ...extractToolResultContent(result), }; + const appPayloads: McpAppResourcePayload[] = []; // Prefer resource URIs from the tool result, falling back to tool-declared URIs. const named = readUiResourceUris(result); @@ -400,7 +462,7 @@ export function createCallMcpToolImplementation( processor.processMessages(a2uiMessages.map(withVersion)); } for (const appResource of mcpAppResources) { - emitMcpAppResource(appResource); + appPayloads.push({...data, ...appResource}); } } @@ -409,10 +471,20 @@ export function createCallMcpToolImplementation( processor.processMessages(messages.map(withVersion)); } for (const inlineAppResource of extractInlineMcpAppResources(result.content)) { - emitMcpAppResource(inlineAppResource); + appPayloads.push({...data, ...inlineAppResource}); } - return result; + const payload: McpToolDataPayload = appPayloads.at(-1) ?? data; + // A superseded call neither writes nor hands its MCP App to the host, but still returns. + if (isLatestCall()) { + if (targetDataPath) { + context.set(targetDataPath, payload); + } + for (const appPayload of appPayloads) { + onMcpAppResource?.(appPayload, context); + } + } + return payload; } catch (error: unknown) { if (error instanceof A2uiExpressionError) { throw error; @@ -427,6 +499,49 @@ export function createCallMcpToolImplementation( }); } +/** + * Reads the text and structured data of a tool result: `text` joins the `text` content blocks with + * `\n`, `structuredContent` is passed through when it is an object, and `json` is `text` parsed + * when that is a JSON object or array. Otherwise, when at least two text blocks parse to an object + * or array, such as a server that sends one JSON block per row, `json` is an array of those parses, + * skipping the blocks that are prose. A scalar such as `"42"` stays text only. + */ +export function extractToolResultContent(result: CallToolResult): ToolResultContent { + const blocks = (result.content ?? []) + .filter(block => block?.type === 'text' && typeof block.text === 'string') + .map(block => (block as {text: string}).text); + const structuredContent = isRecord(result.structuredContent) + ? result.structuredContent + : undefined; + const text = blocks.length > 0 ? blocks.join('\n') : undefined; + let json = text === undefined ? undefined : parseJsonContainer(text); + if (json === undefined) { + const parsedBlocks = blocks.map(parseJsonContainer).filter(parsed => parsed !== undefined); + if (parsedBlocks.length >= 2) { + json = parsedBlocks; + } + } + return { + ...(structuredContent !== undefined ? {structuredContent} : {}), + ...(text !== undefined ? {text} : {}), + ...(json !== undefined ? {json} : {}), + }; +} + +/** Parses `text` as JSON, returning the value only when it is an object or array. */ +function parseJsonContainer(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(trimmed); + return typeof parsed === 'object' && parsed !== null ? parsed : undefined; + } catch { + return undefined; + } +} + /** * Extracts unique string URIs from `_meta.ui.resourceUri` on a tool descriptor or result. * Accepts either a single URI string or an array of URI strings. @@ -566,7 +681,7 @@ function toMcpAppResource( resourceUri: string, html: string, uiMeta: Record | undefined, -): Omit { +): McpAppResource { const csp = uiMeta?.['csp']; const permissions = uiMeta?.['permissions']; return { @@ -584,8 +699,8 @@ function toMcpAppResource( */ export function extractInlineMcpAppResources( content: CallToolResult['content'] | undefined, -): Array> { - const resources: Array> = []; +): Array { + const resources: Array = []; for (const item of content ?? []) { const block = item as { type?: string; @@ -633,8 +748,8 @@ export function extractInlineMcpAppResources( export function parseMcpAppResources( resource: ReadResourceResult | undefined, uri: string, -): Array> { - const results: Array> = []; +): Array { + const results: Array = []; const topLevelUiMeta = (resource?._meta as Record | undefined)?.['ui'] as | Record | undefined; diff --git a/typescript/catalogs/mcp/src/index.ts b/typescript/catalogs/mcp/src/index.ts index 2aa2f13b23..38fe922a24 100644 --- a/typescript/catalogs/mcp/src/index.ts +++ b/typescript/catalogs/mcp/src/index.ts @@ -29,6 +29,7 @@ export { type CallMcpToolOptions, type McpAppResourcePayload, type McpClientResolver, + type McpToolDataPayload, type McpToolClient, } from './functions/callMcpTool.js'; export {UpdateDataModelApi, UpdateDataModelImplementation} from './functions/update_data_model.js';