From 37489e54012592d9a05393fe989c4a2fef298164 Mon Sep 17 00:00:00 2001 From: Jose Montes Date: Thu, 29 Oct 2026 00:10:12 +0000 Subject: [PATCH] feat(catalog-mcp): add transformData --- catalogs/mcp/README.md | 43 ++- catalogs/mcp/v1/catalog.json | 31 +- typescript/catalogs/mcp/CHANGELOG.md | 1 + typescript/catalogs/mcp/README.md | 58 +++- typescript/catalogs/mcp/package.json | 2 + typescript/catalogs/mcp/src/catalog.test.ts | 2 + typescript/catalogs/mcp/src/catalog.ts | 7 +- .../mcp/src/functions/callMcpTool.test.ts | 33 +- .../mcp/src/functions/transform_data.test.ts | 289 ++++++++++++++++++ .../mcp/src/functions/transform_data.ts | 110 +++++++ typescript/catalogs/mcp/src/index.ts | 5 +- yarn.lock | 16 + 12 files changed, 574 insertions(+), 23 deletions(-) create mode 100644 typescript/catalogs/mcp/src/functions/transform_data.test.ts create mode 100644 typescript/catalogs/mcp/src/functions/transform_data.ts diff --git a/catalogs/mcp/README.md b/catalogs/mcp/README.md index 84be7f8e47..cbfd111d3e 100644 --- a/catalogs/mcp/README.md +++ b/catalogs/mcp/README.md @@ -35,7 +35,7 @@ For `updateDataModel`, keys starting with `/` are absolute paths, while relative ## v1 catalog (protocol v1.0) -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. +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 the data functions below, with argument and return types taken from the v1.0 `common_types.json`, and the `McpApp` component. ### Tool data payload @@ -45,11 +45,42 @@ The optional `targetDataPath` argument writes the payload of each successful cal ### Data functions -| Function | Arguments | Returns | -| :---------------- | :-------- | :-------------------------------------------------------------------------- | -| `updateDataModel` | `updates` | Nothing. Writes each key-value pair in `updates` to the surface data model. | - -The `updates` argument is a required `DynamicValue`, so it can be a literal object, a `@path` binding or a nested `@call`; a `null` or missing argument is rejected. Keys starting with `/` are absolute paths, while relative keys resolve against the calling data context (such as the current row scope inside a template list). `updateDataModel` does not evaluate `@path` or `@call` objects found inside the values it writes: a tool result is data, not an expression. +Most MCP tools return plain `content` blocks rather than A2UI resources, so a surface needs to pick the fields it wants out of the payload and write them to the data model. The v1 catalog declares these functions for that: + +| Function | Arguments | Returns | +| :---------------- | :----------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | +| `transformData` | `jmespath`, `data` | The result of evaluating the [JMESPath](https://jmespath.org/specification.html) expression against `data`, or `null` for missing fields. | +| `updateDataModel` | `updates` | Nothing. Writes each key-value pair in `updates` to the surface data model. | + +Every argument is required, and a `null` or missing argument is rejected. `transformData` implements standard JMESPath, with no extensions. `jmespath` is a `DynamicString`; `data` and `updates` are `DynamicValue`, so each can be a literal, a `@path` binding or a nested `@call`. For `updateDataModel`, keys starting with `/` are absolute paths, while relative keys resolve against the calling data context (such as the current row scope inside a template list). + +A surface is usually created on the basic catalog, so each call selects the MCP catalog with its own `catalogId`. A button action chains the functions this way: + +```json +{ + "@call": "updateDataModel", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "updates": { + "@call": "transformData", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "jmespath": "{\"/issues\": json[?state == 'open'].{number: to_string(number), title: title}}", + "data": { + "@call": "callMcpTool", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "name": "list_issues", + "arguments": {"repository": {"@path": "/repository"}} + } + } + } + } + } +} +``` + +`transformData` reads the parsed `json` of the payload and builds the `{path: value}` object, and `updateDataModel` writes it. The functions do not evaluate `@path` or `@call` objects found inside tool output: a tool result is data, not an expression. ### McpApp component diff --git a/catalogs/mcp/v1/catalog.json b/catalogs/mcp/v1/catalog.json index 479fd1a3ca..bb24ee9d0c 100644 --- a/catalogs/mcp/v1/catalog.json +++ b/catalogs/mcp/v1/catalog.json @@ -3,7 +3,7 @@ "$id": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", "protocolVersion": "1.0", "title": "A2UI MCP Catalog", - "description": "A2UI catalog for Model Context Protocol (MCP) integrations. It defines the `callMcpTool` function, the `updateDataModel` function that writes values to the surface data model, and the `McpApp` component that renders a sandboxed MCP App connected to the host through the MCP Apps bridge.", + "description": "A2UI catalog for Model Context Protocol (MCP) integrations. It defines the `callMcpTool` function, the data functions that turn a tool result into data model updates, and the `McpApp` component that renders a sandboxed MCP App connected to the host through the MCP Apps bridge.", "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", "components": { "McpApp": { @@ -147,6 +147,32 @@ }, "required": ["@call", "args"] }, + "transformData": { + "type": "object", + "description": "Evaluates a JMESPath expression (https://jmespath.org/specification.html) against `data`. Standard JMESPath only, no extensions.", + "returnType": "any", + "properties": { + "@call": { + "const": "transformData" + }, + "args": { + "type": "object", + "properties": { + "jmespath": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "A JMESPath expression (https://jmespath.org/specification.html) evaluated against `data`. Standard JMESPath only, no extensions." + }, + "data": { + "$ref": "common_types.json#/$defs/DynamicValue", + "description": "The input data to evaluate the expression against." + } + }, + "required": ["jmespath", "data"], + "additionalProperties": false + } + }, + "required": ["@call", "args"] + }, "updateDataModel": { "type": "object", "description": "Writes key-value pairs to the surface data model.", @@ -186,6 +212,9 @@ { "$ref": "#/functions/callMcpTool" }, + { + "$ref": "#/functions/transformData" + }, { "$ref": "#/functions/updateDataModel" } diff --git a/typescript/catalogs/mcp/CHANGELOG.md b/typescript/catalogs/mcp/CHANGELOG.md index 1d739f2db9..ba19d0267f 100644 --- a/typescript/catalogs/mcp/CHANGELOG.md +++ b/typescript/catalogs/mcp/CHANGELOG.md @@ -1,6 +1,7 @@ ## Unreleased - **BREAKING CHANGE**: `callMcpTool` returns a payload with `toolName`, `toolInput`, `toolResult`, `structuredContent`, `text` and `json` instead of the raw `CallToolResult`, and writes it to `targetDataPath` when set, where the latest call to a path wins. Recommended migration: read the raw result from the `toolResult` field of the returned payload. [#3728](https://github.com/a2ui-project/a2ui/pull/3728) +- The `transformData` (standard JMESPath) data function shapes the `callMcpTool` payload into data model updates; its `TransformDataApi` and `TransformDataImplementation` are exported for hosts that register functions one by one. [#3736](https://github.com/a2ui-project/a2ui/pull/3736) ## 0.0.1-alpha.2 diff --git a/typescript/catalogs/mcp/README.md b/typescript/catalogs/mcp/README.md index 8f0ec774c4..d419332479 100644 --- a/typescript/catalogs/mcp/README.md +++ b/typescript/catalogs/mcp/README.md @@ -2,7 +2,7 @@ > **Experimental.** This package is under active development. Its API and the catalog schema may change between releases without notice. -The A2UI MCP catalog lets A2UI surfaces invoke [Model Context Protocol](https://modelcontextprotocol.io/) tools, write values to the data model, and render [MCP Apps](https://github.com/modelcontextprotocol/ext-apps). It defines the `callMcpTool` function, the `updateDataModel` data function, and the `McpApp` component, allowing agents to emit declarative payloads whose controls and bindings interact with MCP servers directly. +The A2UI MCP catalog lets A2UI surfaces invoke [Model Context Protocol](https://modelcontextprotocol.io/) tools, shape what they return, and render [MCP Apps](https://github.com/modelcontextprotocol/ext-apps). It defines the `callMcpTool` function, the `transformData` and `updateDataModel` data functions, and the `McpApp` component, allowing agents to emit declarative payloads whose controls and bindings interact with MCP servers directly. ## Overview @@ -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. -- `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. +- `callMcpTool` returns the text, parsed JSON and structured content of each result, and can write them to `targetDataPath`. The `transformData` and `updateDataModel` data functions shape them into data model updates, so a payload can render the output of a tool that knows nothing about A2UI. - 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`. @@ -42,13 +42,18 @@ The function returns a payload with these fields: 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: +Two data functions turn that payload into data model updates (see [Shape a headless tool result](#shape-a-headless-tool-result)): -| Function | Arguments | Returns | -| :---------------- | :---------------------- | :-------------------------------------------------------------------------- | -| `updateDataModel` | `updates: DynamicValue` | Nothing. Writes each key-value pair in `updates` to the surface data model. | +| Function | Arguments | Returns | +| :---------------- | :---------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | +| `transformData` | `jmespath: DynamicString`, `data: DynamicValue` | The result of evaluating the [JMESPath](https://jmespath.org/specification.html) expression against `data`, or `null` for missing fields. | +| `updateDataModel` | `updates: DynamicValue` | Nothing. Writes each key-value pair in `updates` to the surface data model. | -The `updates` argument is required, and a `null` or missing argument is rejected by validation. `updateDataModel` throws an `A2uiExpressionError` for `updates` that are not a non-null plain object, or for a key that is empty or contains a `__proto__`, `constructor` or `prototype` segment. It checks every key before it writes anything, so a rejected key leaves the data model unchanged. Keys starting with `/` are absolute paths and relative keys resolve against the calling data context, such as the current row of a template list. It does not evaluate `@path` or `@call` objects found inside the values it writes: a tool result is data, not an expression. +Every argument is required, and a `null` or missing argument is rejected by validation. None of the functions evaluates `@path` or `@call` objects found inside its data: a tool result is data, not an expression. + +`transformData` implements standard JMESPath, with no extensions. It throws an `A2uiExpressionError` for an expression that does not parse (`Invalid JMESPath expression: ...`) or fails to evaluate. + +`updateDataModel` throws an `A2uiExpressionError` for `updates` that are not a non-null plain object, or for a key that is empty or contains a `__proto__`, `constructor` or `prototype` segment. It checks every key before it writes anything, so a rejected key leaves the data model unchanged. Keys starting with `/` are absolute paths and relative keys resolve against the calling data context, such as the current row of a template list. ## Installation @@ -112,7 +117,7 @@ const processor = new MessageProcessor( configureMcpCatalog({getMcpClientForTool: () => client, processor}); ``` -A host that only wants some of the functions registers the `*Implementation` exports it needs (`CallMcpToolImplementation`, `UpdateDataModelImplementation`) in a catalog of its own instead of `mcpCatalog`. +A host that only wants some of the functions registers the `*Implementation` exports it needs (`CallMcpToolImplementation`, `TransformDataImplementation`, `UpdateDataModelImplementation`) in a catalog of its own instead of `mcpCatalog`. For multi-server setups, keep a registry of which server advertises each tool, usually built from `listTools()` at connection time, and resolve it in the same hook: @@ -187,6 +192,40 @@ A v1.0 payload calls `callMcpTool` from any surface whose host registered `mcpCa A surface created with the MCP catalog id as its default needs no `catalogId` on the call. +### Shape a headless tool result + +Most MCP tools return plain `content` blocks rather than A2UI resources. The payload `callMcpTool` returns already holds their `text` and its `json` parse, so `transformData` reads it directly and builds an object of data model paths, and `updateDataModel` writes it. A button action that loads the open issues of a repository into `/issues`: + +```json +{ + "functionCall": { + "@call": "updateDataModel", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "updates": { + "@call": "transformData", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "jmespath": "{\"/issues\": json[?state == 'open'].{number: to_string(number), title: title}}", + "data": { + "@call": "callMcpTool", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "name": "list_issues", + "arguments": {"repository": {"@path": "/repository"}} + } + } + } + } + } + } +} +``` + +`callMcpTool` is asynchronous, so its result reaches the enclosing `transformData` as a pending Promise. The runtime awaits the pending arguments of a call before it validates them and runs the function, so each function in the chain returns a Promise in turn, which is why the action resolves to a Promise a host can await; with settled arguments the functions run synchronously, so a binding that uses them stays reactive. + +When the data needs no reshaping, `targetDataPath` alone is enough: a second action writes the payload to `/issueResponse`, and a list binds to `/issueResponse/structuredContent/issues`. Only writes to `targetDataPath` follow the latest-call rule, so bind autocomplete results there rather than writing them with `updateDataModel`. + ## 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. @@ -334,9 +373,10 @@ The component owns its bridge: `McpAppBridge` (`src/components/mcp_app_bridge.ts | :------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/catalog.json` | Copy of `catalogs/mcp/v1/catalog.json` made by the `copy-catalog` build step | | `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/catalog.ts` | `MCP_CATALOG_ID` and `mcpCatalog`: the `McpApp` component, `callMcpTool` and the data functions | | `src/mcp_catalog_config.ts` | `configureMcpCatalog`: the host's MCP client resolver, message processor and sandbox location | | `src/functions/callMcpTool.ts` | MCP tool execution, the tool data payload, UI resource discovery, caching, and message decoding | +| `src/functions/transform_data.ts` | `transformData` | | `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/package.json b/typescript/catalogs/mcp/package.json index 8c21b9f13e..049ae108df 100644 --- a/typescript/catalogs/mcp/package.json +++ b/typescript/catalogs/mcp/package.json @@ -200,6 +200,7 @@ "@modelcontextprotocol/ext-apps": "^1.7.4", "@modelcontextprotocol/sdk": "^1.29.0", "ajv": "^8.17.1", + "jmespath": "^0.16.0", "zod": "^3.25.76" }, "peerDependencies": { @@ -209,6 +210,7 @@ "devDependencies": { "@a2ui/web_core": "workspace:*", "@types/jasmine": "^6.0.0", + "@types/jmespath": "^0.15.2", "@types/node": "^25.9.3", "esbuild": "^0.28.1", "eslint": "^10.4.1", diff --git a/typescript/catalogs/mcp/src/catalog.test.ts b/typescript/catalogs/mcp/src/catalog.test.ts index 37e9876b77..5e85861d30 100644 --- a/typescript/catalogs/mcp/src/catalog.test.ts +++ b/typescript/catalogs/mcp/src/catalog.test.ts @@ -24,6 +24,7 @@ import catalogJson from './catalog.json' with {type: 'json'}; import {MCP_CATALOG_ID, mcpCatalog} from './catalog.js'; import {A2uiMcpApp} from './components/mcp_app.js'; import {CallMcpToolImplementation} from './functions/callMcpTool.js'; +import {TransformDataImplementation} from './functions/transform_data.js'; import {UpdateDataModelImplementation} from './functions/update_data_model.js'; import {EXAMPLE_CATALOGS, parseExampleMessages} from './testing/frame_test_support.js'; @@ -128,6 +129,7 @@ describe('mcpCatalog', () => { Object.keys(catalogJson['functions']).sort(), ); expect(mcpCatalog.functions.get('callMcpTool')).toBe(CallMcpToolImplementation); + expect(mcpCatalog.functions.get('transformData')).toBe(TransformDataImplementation); expect(mcpCatalog.functions.get('updateDataModel')).toBe(UpdateDataModelImplementation); }); diff --git a/typescript/catalogs/mcp/src/catalog.ts b/typescript/catalogs/mcp/src/catalog.ts index cae852921a..b73a13d142 100644 --- a/typescript/catalogs/mcp/src/catalog.ts +++ b/typescript/catalogs/mcp/src/catalog.ts @@ -17,14 +17,15 @@ import {Catalog, type WebComponentImplementation} from '@a2ui/web_core/v1_0'; import {A2uiMcpApp} from './components/mcp_app.js'; import {CallMcpToolImplementation} from './functions/callMcpTool.js'; +import {TransformDataImplementation} from './functions/transform_data.js'; import {UpdateDataModelImplementation} from './functions/update_data_model.js'; /** Identifier of the A2UI MCP catalog schema (`catalogs/mcp/v1/catalog.json`). */ export const MCP_CATALOG_ID = 'https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json'; /** - * The MCP catalog: the `McpApp` component, `callMcpTool`, and `updateDataModel`, ready to hand - * to a + * The MCP catalog: the `McpApp` component, `callMcpTool`, and the data functions that turn a + * tool result into data model updates (`transformData`, `updateDataModel`), ready to hand to a * `MessageProcessor`. `callMcpTool` runs once the host has called * `configureMcpCatalog({getMcpClientForTool, processor})`. * @@ -35,5 +36,5 @@ export const mcpCatalog = new Catalog( MCP_CATALOG_ID, '1.0', [A2uiMcpApp], - [CallMcpToolImplementation, UpdateDataModelImplementation], + [CallMcpToolImplementation, TransformDataImplementation, UpdateDataModelImplementation], ); diff --git a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts index fb1922950b..591be91e6b 100644 --- a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts +++ b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts @@ -47,6 +47,7 @@ import { type McpToolDataPayload, } from './callMcpTool.js'; import mcpCatalogJson from '../catalog.json' with {type: 'json'}; +import {TransformDataImplementation} from './transform_data.js'; import {UpdateDataModelImplementation} from './update_data_model.js'; const SURFACE_CATALOG_ID = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; @@ -56,7 +57,7 @@ const mcpCatalog = new Catalog( MCP_CATALOG_ID, '1.0', [], - [CallMcpToolImplementation, UpdateDataModelImplementation], + [CallMcpToolImplementation, TransformDataImplementation, UpdateDataModelImplementation], ); /** A function that returns `{path: data}`, the shape a tool argument named `path` needs. */ @@ -246,7 +247,7 @@ describe('callMcpTool', () => { }); it('parses a function call as the whole arguments object', () => { - const call = {call: 'jmespath', args: {expression: '{path: @}', data: {path: '/dir'}}}; + const call = {call: 'transformData', args: {jmespath: '{path: @}', data: {path: '/dir'}}}; const parsed = CallMcpToolApi.schema.parse({name: 'list_directory', arguments: call}); assert.deepStrictEqual(parsed, {name: 'list_directory', arguments: call}); }); @@ -936,6 +937,34 @@ describe('callMcpTool', () => { assert.strictEqual(dataModel.get('/a/text'), 'A'); assert.strictEqual(dataModel.get('/b/text'), 'B'); }); + + it('lets transformData read the text of the returned payload', async () => { + const client = createFakeClient({ + result: {content: [{type: 'text', text: 'Title: Foo\nURL: https://foo'}]}, + }); + const catalog = new Catalog( + MCP_CATALOG_ID, + '0.9', + [], + [ + createCallMcpToolImplementation(() => ({ + defaultVersion: 'v0.9', + getMcpClientForTool: () => asClient(client), + processor, + })), + TransformDataImplementation, + ], + ); + const context = createTestDataContext(new DataModel({}), catalog); + + const text = await context.resolveDynamicValue({ + call: 'transformData', + args: {jmespath: 'text', data: {call: 'callMcpTool', args: {name: 'search'}}}, + returnType: 'any', + }); + + assert.strictEqual(text, 'Title: Foo\nURL: https://foo'); + }); }); describe('dynamic values', () => { diff --git a/typescript/catalogs/mcp/src/functions/transform_data.test.ts b/typescript/catalogs/mcp/src/functions/transform_data.test.ts new file mode 100644 index 0000000000..9defec2a7b --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/transform_data.test.ts @@ -0,0 +1,289 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, it} from 'node:test'; +import * as assert from 'node:assert'; +import {A2uiExpressionError, Catalog, DataContext, DataModel} from '@a2ui/web_core/v1_0'; +import {TransformDataApi, TransformDataImplementation} from './transform_data.js'; + +const catalog = new Catalog( + 'https://a2ui.org/test/transform-data', + '1.0', + [], + [TransformDataImplementation], +); + +const call = (args: Record, model = new DataModel({})) => + catalog.invoker('transformData', args, new DataContext(model, '/')); + +describe('transformData', () => { + it('has the metadata the catalog schema publishes', () => { + assert.strictEqual(TransformDataApi.name, 'transformData'); + assert.strictEqual(TransformDataApi.returnType, 'any'); + assert.ok(TransformDataApi.description.includes('https://jmespath.org/specification.html')); + }); + + it('evaluates an expression against the document', () => { + assert.deepStrictEqual( + call({ + jmespath: 'entries[*].name', + data: {entries: [{name: 'notes.md'}, {name: 'todo.txt'}]}, + }), + ['notes.md', 'todo.txt'], + ); + }); + + it('builds the objects a data model row needs', () => { + assert.deepStrictEqual( + call({ + jmespath: '{"/rows": entries[].{name: name, size: size}}', + data: {entries: [{name: 'notes.md', size: 10, mode: 'rw'}]}, + }), + {'/rows': [{name: 'notes.md', size: 10}]}, + ); + }); + + it('yields null for a missing field', () => { + assert.strictEqual(call({jmespath: 'entry.size', data: {entry: {}}}), null); + }); + + it('rejects a missing or null document, which the schema does not accept', () => { + for (const args of [{jmespath: 'anything'}, {jmespath: '@', data: null}]) { + assert.throws( + () => call(args), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'transformData'); + assert.ok(error.message.startsWith("Validation failed for function 'transformData'")); + return true; + }, + ); + } + }); + + it('does not evaluate directives found inside the data', () => { + // Tool output is data: an object that looks like a binding stays an object. + const data = {row: {'@path': '/secret'}}; + assert.deepStrictEqual(call({jmespath: 'row', data}, new DataModel({secret: 'leaked'})), { + '@path': '/secret', + }); + }); + + const unsupportedDialect: ReadonlyArray = [ + ['a let binding', 'let $x = `1` in $x'], + ['a ternary', 'a ? b : c'], + ['arithmetic', '`1` + `2`'], + ['a root reference', '$.foo'], + ]; + + for (const [label, expression] of unsupportedDialect) { + it(`rejects ${label} not supported in standard JMESPath`, () => { + assert.throws( + () => call({jmespath: expression, data: {a: 'x', b: 'y', c: 'z', foo: 1}}), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'transformData'); + assert.ok(error.message.includes(expression)); + return true; + }, + ); + }); + } + + const unparsableExpressions: ReadonlyArray = [ + ['an unterminated bracket', 'entries['], + ['an unterminated multiselect hash', '{a: b'], + ['a slice with a non-numeric bound', '[0:a]'], + ]; + + for (const [label, expression] of unparsableExpressions) { + it(`throws naming the expression for ${label} that does not parse`, () => { + assert.throws( + () => call({jmespath: expression, data: {}}), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.ok(error.message.startsWith(`Invalid JMESPath expression: ${expression}`)); + assert.ok(error.details instanceof Error); + return true; + }, + ); + }); + } + + it('evaluates a repeated expression the same way each time', () => { + for (let i = 0; i < 3; i++) { + assert.deepStrictEqual(call({jmespath: 'items[*].id', data: {items: [{id: i}]}}), [i]); + } + }); + + it('keeps rejecting an invalid expression on repeated calls', () => { + for (let i = 0; i < 2; i++) { + assert.throws( + () => call({jmespath: '{a: b', data: {}}), + (error: unknown) => + error instanceof A2uiExpressionError && + error.message.startsWith('Invalid JMESPath expression: {a: b'), + ); + } + }); + + const nonStringExpressions: ReadonlyArray = [ + ['null', null], + ['undefined', undefined], + ['number', 42], + ['array', ['entries']], + ['object', {jmespath: 'entries'}], + ]; + + for (const [label, expression] of nonStringExpressions) { + it(`rejects a ${label} expression at validation`, () => { + assert.throws( + () => call({jmespath: expression, data: {}}), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'transformData'); + assert.ok(error.message.startsWith("Validation failed for function 'transformData'")); + return true; + }, + ); + }); + } + + it('reports an evaluation failure, such as a function given the wrong type', () => { + assert.throws( + () => call({jmespath: 'length(count)', data: {count: 3}}), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'transformData'); + assert.ok(error.message.startsWith('JMESPath expression length(count) failed: ')); + return true; + }, + ); + }); + + describe('JMESPath compliance cases', () => { + // Taken from the official compliance suite (https://github.com/jmespath/jmespath.test), + // so the function is checked against standard JMESPath rather than one library. + const cases: ReadonlyArray<{ + readonly suite: string; + readonly data: unknown; + readonly jmespath: string; + readonly result: unknown; + }> = [ + { + suite: 'wildcard', + data: {foo: [{bar: 'one'}, {bar: 'two'}, {bar: 'three'}, {notbar: 'four'}]}, + jmespath: 'foo[*].bar', + result: ['one', 'two', 'three'], + }, + { + suite: 'wildcard', + data: {foo: {bar: {name: 'one'}, baz: {name: 'two'}}}, + jmespath: 'foo.*.name', + result: ['one', 'two'], + }, + { + suite: 'filters', + data: {foo: [{name: 'a'}, {name: 'b'}]}, + jmespath: "foo[?name == 'a']", + result: [{name: 'a'}], + }, + { + suite: 'filters', + data: {foo: [{age: 20}, {age: 25}, {age: 30}]}, + jmespath: 'foo[?age > `25`]', + result: [{age: 30}], + }, + { + suite: 'multiselect', + data: {foo: {bar: 'bar', baz: 'baz', qux: 'qux'}}, + jmespath: 'foo.{bar: bar, baz: baz}', + result: {bar: 'bar', baz: 'baz'}, + }, + { + suite: 'multiselect', + data: {foo: {bar: 'bar', baz: 'baz', qux: 'qux'}}, + jmespath: 'foo.[bar, baz]', + result: ['bar', 'baz'], + }, + { + suite: 'pipe', + data: {foo: {bar: {baz: 'subkey'}, other: {baz: 'subkey'}}}, + jmespath: 'foo.*.baz | [0]', + result: 'subkey', + }, + { + suite: 'pipe', + data: {foo: [{bar: 'one'}, {bar: 'two'}]}, + jmespath: 'foo[*].bar | [1]', + result: 'two', + }, + { + suite: 'functions', + data: {strings: ['a', 'b', 'c']}, + jmespath: 'length(strings)', + result: 3, + }, + {suite: 'functions', data: {}, jmespath: "length('abc')", result: 3}, + { + suite: 'functions', + data: {strings: ['a', 'b', 'c']}, + jmespath: "join(', ', strings)", + result: 'a, b, c', + }, + {suite: 'functions', data: {}, jmespath: "to_string('foo')", result: 'foo'}, + {suite: 'functions', data: {}, jmespath: 'to_string(`1.2`)', result: '1.2'}, + {suite: 'functions', data: {}, jmespath: 'to_string(`[0, 1]`)', result: '[0,1]'}, + ]; + + for (const {suite, data, jmespath, result} of cases) { + it(`${suite}: ${jmespath}`, () => { + assert.deepStrictEqual(call({jmespath, data}), result); + }); + } + }); + + it('stays synchronous when no argument is pending', () => { + const result = call({jmespath: 'rows[0]', data: {rows: ['first', 'second']}}); + assert.strictEqual(typeof (result as {then?: unknown} | null)?.then, 'undefined'); + assert.strictEqual(result, 'first'); + }); + + it('settles a pending data argument, such as a tool result', async () => { + const result = call({ + jmespath: 'entries[*].name', + data: Promise.resolve({entries: [{name: 'notes.md'}]}), + }); + assert.strictEqual(typeof (result as {then?: unknown}).then, 'function'); + assert.deepStrictEqual(await result, ['notes.md']); + }); + + it('settles a pending expression argument', async () => { + const result = await call({ + jmespath: Promise.resolve('rows[0]'), + data: {rows: ['first', 'second']}, + }); + assert.strictEqual(result, 'first'); + }); + + it('propagates a rejected argument', async () => { + const result = call({ + jmespath: 'rows[0]', + data: Promise.reject(new Error('the tool call failed')), + }); + await assert.rejects(async () => await result, /the tool call failed/); + }); +}); diff --git a/typescript/catalogs/mcp/src/functions/transform_data.ts b/typescript/catalogs/mcp/src/functions/transform_data.ts new file mode 100644 index 0000000000..05857852a0 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/transform_data.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + A2uiExpressionError, + DynamicStringSchema, + DynamicValueSchema, + createFunctionImplementation, + type FunctionImplementation, +} from '@a2ui/web_core/v1_0'; +import {compile, search} from 'jmespath'; +import {z} from 'zod'; + +declare module 'jmespath' { + /** Parses `expression` and throws when it is not valid JMESPath. Exported by the library, not declared by `@types/jmespath`. */ + export function compile(expression: string): unknown; +} + +/** The JMESPath specification that `transformData` implements, with no extensions. */ +const JMESPATH_SPEC_URL = 'https://jmespath.org/specification.html'; + +/** Maximum number of validated expressions remembered. */ +const MAX_CACHED_EXPRESSIONS = 100; + +/** Expressions that `compile` accepted, in insertion order, so repeated bindings parse once. */ +const validatedExpressions = new Set(); + +/** The message of `error`, for an `A2uiExpressionError` that wraps it. */ +function reason(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Checks that `expression` parses as JMESPath, remembering up to `MAX_CACHED_EXPRESSIONS` accepted + * expressions so a reactive binding that re-evaluates the same string does not parse it twice. + * + * @throws A2uiExpressionError if the expression does not parse. + */ +function assertValidExpression(expression: string): void { + if (validatedExpressions.has(expression)) { + return; + } + try { + compile(expression); + } catch (error) { + throw new A2uiExpressionError( + `Invalid JMESPath expression: ${expression} (${reason(error)})`, + 'transformData', + error, + ); + } + if (validatedExpressions.size >= MAX_CACHED_EXPRESSIONS) { + const oldest = validatedExpressions.values().next().value; + if (oldest !== undefined) { + validatedExpressions.delete(oldest); + } + } + validatedExpressions.add(expression); +} + +/** Function API definition for `transformData`. */ +export const TransformDataApi = { + name: 'transformData' as const, + returnType: 'any' as const, + schema: z.object({ + // Quoted to prevent Google Closure Compiler property renaming. + 'jmespath': DynamicStringSchema.describe( + `A JMESPath expression (${JMESPATH_SPEC_URL}) evaluated against \`data\`. Standard JMESPath only, no extensions.`, + ), + 'data': DynamicValueSchema.describe('The input data to evaluate the expression against.'), + }), + description: `Evaluates a JMESPath expression (${JMESPATH_SPEC_URL}) against \`data\`. Standard JMESPath only, no extensions.`, +}; + +/** + * Evaluates the JMESPath expression in `jmespath` against `data` and returns the result, or `null` + * when the expression selects nothing. + * + * @throws A2uiExpressionError if the expression does not parse, or if evaluating it fails, such as + * a function called with an argument of the wrong type. + */ +export const TransformDataImplementation: FunctionImplementation = createFunctionImplementation( + TransformDataApi, + args => { + const expression = String(args['jmespath']); + assertValidExpression(expression); + try { + return search(args['data'], expression); + } catch (error) { + throw new A2uiExpressionError( + `JMESPath expression ${expression} failed: ${reason(error)}`, + 'transformData', + error, + ); + } + }, +); diff --git a/typescript/catalogs/mcp/src/index.ts b/typescript/catalogs/mcp/src/index.ts index 38fe922a24..f87101e985 100644 --- a/typescript/catalogs/mcp/src/index.ts +++ b/typescript/catalogs/mcp/src/index.ts @@ -16,8 +16,8 @@ /** * `@a2ui/catalog-mcp`: the A2UI MCP catalog with the `McpApp` universal component, the - * `callMcpTool` function and `updateDataModel`, configured once per host with - * `configureMcpCatalog`. + * `callMcpTool` function and the data functions that shape its results, configured once per host + * with `configureMcpCatalog`. */ export {MCP_CATALOG_ID, mcpCatalog} from './catalog.js'; @@ -32,4 +32,5 @@ export { type McpToolDataPayload, type McpToolClient, } from './functions/callMcpTool.js'; +export {TransformDataApi, TransformDataImplementation} from './functions/transform_data.js'; export {UpdateDataModelApi, UpdateDataModelImplementation} from './functions/update_data_model.js'; diff --git a/yarn.lock b/yarn.lock index d20f3c22cf..75399ce433 100644 --- a/yarn.lock +++ b/yarn.lock @@ -170,12 +170,14 @@ __metadata: "@modelcontextprotocol/ext-apps": "npm:^1.7.4" "@modelcontextprotocol/sdk": "npm:^1.29.0" "@types/jasmine": "npm:^6.0.0" + "@types/jmespath": "npm:^0.15.2" "@types/node": "npm:^25.9.3" ajv: "npm:^8.17.1" esbuild: "npm:^0.28.1" eslint: "npm:^10.4.1" gts: "npm:^7.0.0" jasmine-core: "npm:^6.3.0" + jmespath: "npm:^0.16.0" karma: "npm:^6.4.4" karma-chrome-launcher: "npm:^3.2.0" karma-esbuild: "npm:^2.3.0" @@ -9327,6 +9329,13 @@ __metadata: languageName: node linkType: hard +"@types/jmespath@npm:^0.15.2": + version: 0.15.2 + resolution: "@types/jmespath@npm:0.15.2" + checksum: 10c0/61e45c2785f845f758df3cdb093ad30d32d3ca9c6e20c5ddf6eeb74125d7b6c0426ac175ca994fec47517b7fb0a4b89a91d47155709fec15ab03969ac3bcadc5 + languageName: node + linkType: hard + "@types/js-yaml@npm:^4.0.9": version: 4.0.9 resolution: "@types/js-yaml@npm:4.0.9" @@ -16311,6 +16320,13 @@ __metadata: languageName: node linkType: hard +"jmespath@npm:^0.16.0": + version: 0.16.0 + resolution: "jmespath@npm:0.16.0" + checksum: 10c0/84cdca62c4a3d339701f01cc53decf16581c76ce49e6455119be1c5f6ab09a19e6788372536bd261d348d21cd817981605f8debae67affadba966219a2bac1c5 + languageName: node + linkType: hard + "jose@npm:^5.1.0": version: 5.10.0 resolution: "jose@npm:5.10.0"