From 84ac81d7052b84070ee40d1be80536acacb88cbf Mon Sep 17 00:00:00 2001 From: Jose Montes Date: Thu, 29 Oct 2026 00:10:09 +0000 Subject: [PATCH] feat(catalog-mcp): add updateDataModel --- catalogs/mcp/README.md | 10 +- catalogs/mcp/v1/catalog.json | 27 ++- typescript/catalogs/mcp/CHANGELOG.md | 1 + typescript/catalogs/mcp/README.md | 24 ++- typescript/catalogs/mcp/src/catalog.test.ts | 2 + typescript/catalogs/mcp/src/catalog.ts | 10 +- .../mcp/src/functions/callMcpTool.test.ts | 8 +- .../src/functions/update_data_model.test.ts | 195 ++++++++++++++++++ .../mcp/src/functions/update_data_model.ts | 98 +++++++++ typescript/catalogs/mcp/src/index.ts | 6 +- 10 files changed, 366 insertions(+), 15 deletions(-) create mode 100644 typescript/catalogs/mcp/src/functions/update_data_model.test.ts create mode 100644 typescript/catalogs/mcp/src/functions/update_data_model.ts diff --git a/catalogs/mcp/README.md b/catalogs/mcp/README.md index 285abd0ce7..c12449cc8c 100644 --- a/catalogs/mcp/README.md +++ b/catalogs/mcp/README.md @@ -35,7 +35,15 @@ 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`, 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 `updateDataModel`, with argument and return types taken from the v1.0 `common_types.json`, and the `McpApp` component. + +### 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. ### McpApp component diff --git a/catalogs/mcp/v1/catalog.json b/catalogs/mcp/v1/catalog.json index 8eb77db3af..59ceafe07a 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 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 `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.", "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", "components": { "McpApp": { @@ -146,6 +146,28 @@ } }, "required": ["@call", "args"] + }, + "updateDataModel": { + "type": "object", + "description": "Writes key-value pairs to the surface data model.", + "returnType": "any", + "properties": { + "@call": { + "const": "updateDataModel" + }, + "args": { + "type": "object", + "properties": { + "updates": { + "$ref": "common_types.json#/$defs/DynamicValue", + "description": "An object mapping data model paths to the values to write (e.g., {\"/entries\": [...], \"/title\": \"Home\"}). Paths starting with \"/\" are absolute; relative paths resolve against the current data context." + } + }, + "required": ["updates"], + "additionalProperties": false + } + }, + "required": ["@call", "args"] } }, "$defs": { @@ -163,6 +185,9 @@ "oneOf": [ { "$ref": "#/functions/callMcpTool" + }, + { + "$ref": "#/functions/updateDataModel" } ] } diff --git a/typescript/catalogs/mcp/CHANGELOG.md b/typescript/catalogs/mcp/CHANGELOG.md index 2658655fd1..a2f6745701 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) +- 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) ## 0.0.1-alpha.1 diff --git a/typescript/catalogs/mcp/README.md b/typescript/catalogs/mcp/README.md index fe6415ccef..3c98b996e2 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 and render [MCP Apps](https://github.com/modelcontextprotocol/ext-apps). It defines the `callMcpTool` 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, 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. ## Overview @@ -11,6 +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. - 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`. @@ -30,6 +31,14 @@ Tools are addressed by name only. A2UI payloads never name a server, because mul 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`. +`updateDataModel` writes path-value pairs to the surface data model: + +| Function | Arguments | Returns | +| :---------------- | :---------------------- | :-------------------------------------------------------------------------- | +| `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. + ## Installation ```bash @@ -70,13 +79,13 @@ configureMcpCatalog({ Push every catalog before the first message arrives, since `getClientCapabilities` reports whatever the array holds when it is called. -A surface resolves components through its default catalog, the `catalogId` of its `createSurface` message, unless a component names another registered catalog with its own `catalogId`. A payload that mixes basic components with `McpApp` 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, as the [catalog examples](../../../catalogs/mcp/v1/examples) do. +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): ```typescript import {basicCatalog, Catalog, MessageProcessor} from '@a2ui/web_core/v1_0'; -import {CallMcpToolImplementation, configureMcpCatalog, mcpCatalog} from '@a2ui/catalog-mcp'; +import {configureMcpCatalog, mcpCatalog} from '@a2ui/catalog-mcp'; const processor = new MessageProcessor( [ @@ -84,7 +93,7 @@ const processor = new MessageProcessor( mcpCatalog.id, '1.0', [...basicCatalog.components.values(), ...mcpCatalog.components.values()], - [...basicCatalog.functions.values(), CallMcpToolImplementation], + [...basicCatalog.functions.values(), ...mcpCatalog.functions.values()], ), ], onAction, @@ -92,6 +101,8 @@ 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`. + 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: ```typescript @@ -318,10 +329,11 @@ The component owns its bridge: `McpAppBridge` (`src/components/mcp_app_bridge.ts | File | Responsibility | | :------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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 `CallMcpToolImplementation` | -| `src/catalog.ts` | `MCP_CATALOG_ID` and `mcpCatalog`: the `McpApp` component and `callMcpTool` | +| `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/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 | | `src/components/mcp_app_bridge.ts` | `McpAppBridge`: `AppBridge` setup, tool calls, function calls and data model sync | diff --git a/typescript/catalogs/mcp/src/catalog.test.ts b/typescript/catalogs/mcp/src/catalog.test.ts index 350c920e31..37e9876b77 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 {UpdateDataModelImplementation} from './functions/update_data_model.js'; import {EXAMPLE_CATALOGS, parseExampleMessages} from './testing/frame_test_support.js'; const toolCallExample = { @@ -127,6 +128,7 @@ describe('mcpCatalog', () => { Object.keys(catalogJson['functions']).sort(), ); expect(mcpCatalog.functions.get('callMcpTool')).toBe(CallMcpToolImplementation); + expect(mcpCatalog.functions.get('updateDataModel')).toBe(UpdateDataModelImplementation); }); it('registers universal components with a2ui- prefixed tag names', () => { diff --git a/typescript/catalogs/mcp/src/catalog.ts b/typescript/catalogs/mcp/src/catalog.ts index 6f65387945..cae852921a 100644 --- a/typescript/catalogs/mcp/src/catalog.ts +++ b/typescript/catalogs/mcp/src/catalog.ts @@ -17,21 +17,23 @@ import {Catalog, type WebComponentImplementation} from '@a2ui/web_core/v1_0'; import {A2uiMcpApp} from './components/mcp_app.js'; import {CallMcpToolImplementation} from './functions/callMcpTool.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 and `callMcpTool`, ready to hand to a + * The MCP catalog: the `McpApp` component, `callMcpTool`, and `updateDataModel`, ready to hand + * to a * `MessageProcessor`. `callMcpTool` runs once the host has called * `configureMcpCatalog({getMcpClientForTool, processor})`. * - * Register it next to the other catalogs of the host; a component of a surface created with - * another catalog selects this one with its own `catalogId`. + * Register it next to the other catalogs of the host; a component or function call of a surface + * created with another catalog selects this one with its own `catalogId`. */ export const mcpCatalog = new Catalog( MCP_CATALOG_ID, '1.0', [A2uiMcpApp], - [CallMcpToolImplementation], + [CallMcpToolImplementation, UpdateDataModelImplementation], ); diff --git a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts index fcc67e4abd..fc72e58a1f 100644 --- a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts +++ b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts @@ -45,11 +45,17 @@ import { type McpToolClient, } from './callMcpTool.js'; import mcpCatalogJson from '../catalog.json' with {type: 'json'}; +import {UpdateDataModelImplementation} from './update_data_model.js'; const SURFACE_CATALOG_ID = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; /** The functions this package implements, as a host registers them. */ -const mcpCatalog = new Catalog(MCP_CATALOG_ID, '1.0', [], [CallMcpToolImplementation]); +const mcpCatalog = new Catalog( + MCP_CATALOG_ID, + '1.0', + [], + [CallMcpToolImplementation, UpdateDataModelImplementation], +); /** A function that returns `{path: data}`, the shape a tool argument named `path` needs. */ const PathArgumentImplementation = createFunctionImplementation( diff --git a/typescript/catalogs/mcp/src/functions/update_data_model.test.ts b/typescript/catalogs/mcp/src/functions/update_data_model.test.ts new file mode 100644 index 0000000000..eb7a1937d1 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/update_data_model.test.ts @@ -0,0 +1,195 @@ +/* + * 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 {UpdateDataModelApi, UpdateDataModelImplementation} from './update_data_model.js'; + +const catalog = new Catalog( + 'https://a2ui.org/test/updateDataModel', + '1.0', + [], + [UpdateDataModelImplementation], +); + +const call = (args: Record, model: DataModel, path = '/') => + catalog.invoker('updateDataModel', args, new DataContext(model, path)); + +describe('updateDataModel', () => { + it('has the metadata the catalog schema publishes', () => { + assert.strictEqual(UpdateDataModelApi.name, 'updateDataModel'); + assert.strictEqual(UpdateDataModelApi.returnType, 'any'); + }); + + it('writes several paths from one call and leaves the rest alone', () => { + const model = new DataModel({untouched: 'kept'}); + + call({updates: {'/title': 'Home', '/entries': [{name: 'notes.md'}], '/count': 1}}, model); + + assert.deepStrictEqual(model.get('/'), { + untouched: 'kept', + title: 'Home', + entries: [{name: 'notes.md'}], + count: 1, + }); + }); + + it('resolves a relative path against the row scope the call was made from', () => { + const model = new DataModel({rows: [{name: 'notes.md'}, {name: 'todo.txt'}]}); + + call({updates: {selected: true, '/lastTouched': 'notes.md'}}, model, '/rows/0'); + + assert.deepStrictEqual(model.get('/'), { + rows: [{name: 'notes.md', selected: true}, {name: 'todo.txt'}], + lastTouched: 'notes.md', + }); + }); + + it('rejects null or undefined updates at validation, writing nothing', () => { + const model = new DataModel({kept: 'original'}); + + for (const updates of [null, undefined]) { + assert.throws( + () => call({updates}, model), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'updateDataModel'); + assert.ok(error.message.startsWith("Validation failed for function 'updateDataModel'")); + return true; + }, + ); + } + + assert.deepStrictEqual(model.get('/'), {kept: 'original'}); + }); + + const nonObjectUpdates: ReadonlyArray = [ + ['array', [{'/title': 'Home'}]], + ['string', '/title'], + ['number', 42], + ]; + + for (const [label, updates] of nonObjectUpdates) { + it(`throws naming the type when updates is ${label}`, () => { + const model = new DataModel({kept: 'original'}); + + assert.throws( + () => call({updates}, model), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'updateDataModel'); + assert.strictEqual( + error.message, + `updateDataModel expects an object of data model paths, got ${label}.`, + ); + return true; + }, + ); + assert.deepStrictEqual(model.get('/'), {kept: 'original'}); + }); + } + + const forbiddenKeys: ReadonlyArray = [ + ['/__proto__/polluted', '__proto__'], + ['/constructor/prototype/polluted', 'constructor'], + ['/safe/prototype', 'prototype'], + ['__proto__/polluted', '__proto__'], + ]; + + for (const [key, segment] of forbiddenKeys) { + it(`throws naming the key when it contains the segment '${segment}' (${key})`, () => { + const model = new DataModel({kept: 'original'}); + + assert.throws( + () => call({updates: {'/written': 'first', [key]: 'bad'}}, model), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'updateDataModel'); + assert.strictEqual( + error.message, + `updateDataModel rejected the key '${key}': the segment '${segment}' is not allowed.`, + ); + return true; + }, + ); + assert.deepStrictEqual(model.get('/'), {kept: 'original'}); + assert.strictEqual(({} as {polluted?: unknown}).polluted, undefined); + }); + } + + it('throws on an empty key and writes nothing', () => { + const model = new DataModel({kept: 'original'}); + + assert.throws( + () => call({updates: {'/written': 'first', '': 'bad'}}, model), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'updateDataModel'); + assert.strictEqual( + error.message, + 'updateDataModel rejected an empty key: every key must be a data model path.', + ); + return true; + }, + ); + assert.deepStrictEqual(model.get('/'), {kept: 'original'}); + }); + + it('stores a copy of an object, so the stored value is detached from the source', () => { + const entry = {name: 'notes.md', tags: ['a']}; + const model = new DataModel({}); + + call({updates: {'/entry': entry}}, model); + entry.tags.push('b'); + + assert.deepStrictEqual(model.get('/entry'), {name: 'notes.md', tags: ['a']}); + }); + + it('writes a Date nested in updates as is, without flattening it to a record', () => { + const model = new DataModel({}); + const when = new Date('2026-10-13T00:00:00Z'); + + call({updates: {'/when': when, '/meta': {when}}}, model); + + assert.ok(model.get('/when') instanceof Date); + assert.strictEqual((model.get('/when') as Date).toISOString(), when.toISOString()); + assert.ok((model.get('/meta') as {when: unknown}).when instanceof Date); + }); + + it('stays synchronous when updates is not pending', () => { + const model = new DataModel({}); + const result = call({updates: {'/settled': 'yes'}}, model); + assert.strictEqual(typeof (result as {then?: unknown} | null)?.then, 'undefined'); + assert.strictEqual(result, undefined); + assert.strictEqual(model.get('/settled'), 'yes'); + }); + + it('returns a promise when updates is pending', async () => { + const model = new DataModel({}); + const result = call({updates: Promise.resolve({'/settled': 'yes'})}, model); + assert.strictEqual(typeof (result as {then?: unknown}).then, 'function'); + assert.strictEqual(await result, undefined); + assert.strictEqual(model.get('/settled'), 'yes'); + }); + + it('propagates a rejected argument and writes nothing', async () => { + const model = new DataModel({kept: 'original'}); + const result = call({updates: Promise.reject(new Error('the tool call failed'))}, model); + await assert.rejects(async () => await result, /the tool call failed/); + assert.deepStrictEqual(model.get('/'), {kept: 'original'}); + }); +}); diff --git a/typescript/catalogs/mcp/src/functions/update_data_model.ts b/typescript/catalogs/mcp/src/functions/update_data_model.ts new file mode 100644 index 0000000000..2a8b9ca745 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/update_data_model.ts @@ -0,0 +1,98 @@ +/* + * 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, + DynamicValueSchema, + createFunctionImplementation, + type FunctionImplementation, +} from '@a2ui/web_core/v1_0'; +import {z} from 'zod'; + +/** Function API definition for `updateDataModel`. */ +export const UpdateDataModelApi = { + name: 'updateDataModel' as const, + returnType: 'any' as const, + schema: z.object({ + // Quoted to prevent Google Closure Compiler property renaming. + 'updates': DynamicValueSchema.describe( + 'An object mapping data model paths to the values to write (e.g., {"/entries": [...], "/title": "Home"}). Paths starting with "/" are absolute; relative paths resolve against the current data context.', + ), + }), + description: 'Writes key-value pairs to the surface data model.', +}; + +/** + * Path segments that would reach the prototype chain instead of the data model. `DataModel` + * rejects them too; checking here keeps the whole update from being applied partially. + */ +const FORBIDDEN_SEGMENTS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Throws if `path` is empty or contains a forbidden segment. + * + * @throws A2uiExpressionError naming the offending key. + */ +function validateUpdatePath(path: string): void { + if (path === '') { + throw new A2uiExpressionError( + 'updateDataModel rejected an empty key: every key must be a data model path.', + 'updateDataModel', + ); + } + for (const segment of path.split('/')) { + if (FORBIDDEN_SEGMENTS.has(segment)) { + throw new A2uiExpressionError( + `updateDataModel rejected the key '${path}': the segment '${segment}' is not allowed.`, + 'updateDataModel', + ); + } + } +} + +/** + * Writes each path-value pair of `updates` to the surface data model. + * + * Objects and arrays are cloned before they are stored, so later writes through the data model + * cannot alter a cached tool result. Every key is checked before anything is written, so an + * invalid key leaves the data model unchanged. + * + * @throws A2uiExpressionError if `updates` is a primitive or an array, or if a key is empty or + * contains a `__proto__`, `constructor` or `prototype` segment. + */ +export const UpdateDataModelImplementation: FunctionImplementation = createFunctionImplementation( + UpdateDataModelApi, + (args, context) => { + const updates: unknown = args['updates']; + if (Array.isArray(updates) || typeof updates !== 'object' || updates === null) { + const kind = Array.isArray(updates) ? 'array' : updates === null ? 'null' : typeof updates; + throw new A2uiExpressionError( + `updateDataModel expects an object of data model paths, got ${kind}.`, + 'updateDataModel', + ); + } + const entries = Object.entries(updates as Record); + for (const [path] of entries) { + validateUpdatePath(path); + } + for (const [path, value] of entries) { + context.set( + path, + typeof value === 'object' && value !== null ? structuredClone(value) : value, + ); + } + }, +); diff --git a/typescript/catalogs/mcp/src/index.ts b/typescript/catalogs/mcp/src/index.ts index 11402e6948..2aa2f13b23 100644 --- a/typescript/catalogs/mcp/src/index.ts +++ b/typescript/catalogs/mcp/src/index.ts @@ -15,8 +15,9 @@ */ /** - * `@a2ui/catalog-mcp`: the A2UI MCP catalog with the `McpApp` universal component and the - * `callMcpTool` function, configured once per host with `configureMcpCatalog`. + * `@a2ui/catalog-mcp`: the A2UI MCP catalog with the `McpApp` universal component, the + * `callMcpTool` function and `updateDataModel`, configured once per host with + * `configureMcpCatalog`. */ export {MCP_CATALOG_ID, mcpCatalog} from './catalog.js'; @@ -30,3 +31,4 @@ export { type McpClientResolver, type McpToolClient, } from './functions/callMcpTool.js'; +export {UpdateDataModelApi, UpdateDataModelImplementation} from './functions/update_data_model.js';