diff --git a/catalogs/mcp/README.md b/catalogs/mcp/README.md index 4dec2e7cb8..8de74bc76d 100644 --- a/catalogs/mcp/README.md +++ b/catalogs/mcp/README.md @@ -47,12 +47,18 @@ The optional `targetDataPath` argument writes the payload of each successful cal 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. | +| 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. | +| `split` | `value`, `separator` | Substrings of `value` split by the literal `separator`, or its characters if `separator` is empty. | +| `regexCapture` | `value`, `pattern` | The capture groups of the first match of `pattern`, or `null` if there is no match. | -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). +Every argument is required, and a `null` or missing argument is rejected. `transformData` implements standard JMESPath, with no extensions. `jmespath`, `separator` and `pattern` are `DynamicString`; `data`, `updates` and `value` are `DynamicValue`, so each can be a literal, a `@path` binding or a nested `@call`. `split` and `regexCapture` accept a single string or an array of strings in `value`, applying the operation to each element of an array. 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). + +`regexCapture` patterns use a portable subset that every renderer can implement: JavaScript regular expression syntax without lookahead or lookbehind assertions and without backreferences. Implementations also reject patterns that allow catastrophic backtracking. To test whether a string matches a pattern, use the basic catalog's `regex` function. + +The set of data functions is closed, because each one has to be specified, tested and implemented in every renderer. The four cover the JSON and text output of most MCP servers: `split` and `regexCapture` exist because many servers return list data only as text, mostly as records of `Key: value` lines. These functions are mainly for general-purpose hosts (such as chat apps or playgrounds) where users configure which MCP servers to connect, so the host does not need custom parsing code for each tool. A host that needs another parser, or that only connects to a few servers it controls, can define its own function in a custom catalog and call it with that `catalogId`. The [TypeScript package README](../../typescript/catalogs/mcp/README.md#data-functions) explains the choice of each function and shows how to add one. A surface is usually created on the basic catalog, so each call selects the MCP catalog with its own `catalogId`. The first button of [mcp-json-tool-result.json](v1/examples/mcp-json-tool-result.json) chains the functions this way: @@ -80,7 +86,7 @@ A surface is usually created on the basic catalog, so each call selects the MCP } ``` -`transformData` reads the parsed `json` of the payload and builds the `{path: value}` object, and `updateDataModel` writes it. The second button only sets `targetDataPath: "/issueResponse"`, and its list binds to `/issueResponse/structuredContent/issues`. The functions do not evaluate `@path` or `@call` objects found inside tool output: a tool result is data, not an expression. +`transformData` reads the parsed `json` of the payload and builds the `{path: value}` object, and `updateDataModel` writes it. The second button only sets `targetDataPath: "/issueResponse"`, and its list binds to `/issueResponse/structuredContent/issues`. A tool that answers in plain text goes through `split` and `regexCapture`, as [mcp-text-tool-result.json](v1/examples/mcp-text-tool-result.json) does with search results: `transformData` reads `text`, `split` cuts it on `"\n\n"` into records, `regexCapture` with `^Title: (.*)\nURL: (.*)` reads each record, and `transformData` with `{"/results": [?@ != null].{title: [0], url: [1]}}` drops the records that did not match and names the fields. The functions do not evaluate `@path` or `@call` objects found inside tool output: a tool result is data, not an expression. ### McpApp component @@ -102,6 +108,7 @@ The [v1/examples](v1/examples/) directory holds A2UI message sequences that vali - [mcp-app-3d-geometry.json](v1/examples/mcp-app-3d-geometry.json) renders a 3D Holographic Geometry Studio `McpApp`: a 60fps canvas renderer that A2UI components alone cannot express. It showcases two-way `data.paths` binding (the sibling `TextField` preset and the app's camera telemetry), `ui/update-model-context` publishing the camera pose and preset to `/scene3d/modelContext`, `tools/call` (`capture_3d_snapshot`), and `ui/notifications/host-context-changed` (`containerDimensions`) driving a responsive canvas; `tests/examples/mcp_app_3d_geometry.test.ts` of the TypeScript package runs it end to end. - [mcp-json-tool-result.json](v1/examples/mcp-json-tool-result.json) loads the open issues of a repository from a headless `list_issues` tool: one button chains `callMcpTool`, `transformData` and `updateDataModel` to fill a template list from the `json` of the payload, and a second button writes the payload to `targetDataPath` and binds a list to its `structuredContent`; `src/catalog.test.ts` of the TypeScript package runs both actions against a mock client. +- [mcp-text-tool-result.json](v1/examples/mcp-text-tool-result.json) searches the web through a headless `tavily-search` tool that answers in plain text: a button chains `callMcpTool`, `transformData`, `split`, `regexCapture` and `updateDataModel` to turn `Title:` and `URL:` records into rows; `src/catalog.test.ts` runs it against a mock client. ## Implementations diff --git a/catalogs/mcp/v1/catalog.json b/catalogs/mcp/v1/catalog.json index aad63de0ca..2698028de2 100644 --- a/catalogs/mcp/v1/catalog.json +++ b/catalogs/mcp/v1/catalog.json @@ -194,6 +194,58 @@ } }, "required": ["@call", "args"] + }, + "split": { + "type": "object", + "description": "Splits a string into an array of substrings using a literal separator.", + "returnType": "any", + "properties": { + "@call": { + "const": "split" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to split." + }, + "separator": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "The delimiter string to split on. An empty string splits into individual characters (Unicode code points)." + } + }, + "required": ["value", "separator"], + "additionalProperties": false + } + }, + "required": ["@call", "args"] + }, + "regexCapture": { + "type": "object", + "description": "Extracts capture groups from the first regular expression match.", + "returnType": "any", + "properties": { + "@call": { + "const": "regexCapture" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to match against." + }, + "pattern": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "A regular expression in the portable subset: JavaScript syntax without lookahead, lookbehind or backreferences. Returns an array of capture groups from the first match, or null if there is no match. Unmatched optional groups return empty strings." + } + }, + "required": ["value", "pattern"], + "additionalProperties": false + } + }, + "required": ["@call", "args"] } }, "$defs": { @@ -217,6 +269,12 @@ }, { "$ref": "#/functions/updateDataModel" + }, + { + "$ref": "#/functions/split" + }, + { + "$ref": "#/functions/regexCapture" } ] } diff --git a/catalogs/mcp/v1/examples/mcp-text-tool-result.json b/catalogs/mcp/v1/examples/mcp-text-tool-result.json new file mode 100644 index 0000000000..f148a46e53 --- /dev/null +++ b/catalogs/mcp/v1/examples/mcp-text-tool-result.json @@ -0,0 +1,137 @@ +{ + "name": "MCP text tool result", + "description": "Reads the plain text a headless MCP search tool returns and turns its records into data model rows. The tool answers with a header followed by one `Title:`, `URL:` and `Content:` record per result, separated by blank lines. The button chains the catalog's functions, each selecting the MCP catalog with its own catalogId: callMcpTool runs the search, transformData reads the `text` of the payload, split cuts it into records, regexCapture turns each record into a title and URL pair, a second transformData drops the records that did not match and names the fields, and updateDataModel writes them to /results.", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "gallery-mcp-text-tool-result", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "gallery-mcp-text-tool-result", + "value": { + "query": "A2UI protocol", + "results": [] + } + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "gallery-mcp-text-tool-result", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["heading", "query_field", "search_button", "result_list"], + "align": "stretch" + }, + { + "id": "heading", + "component": "Text", + "text": "Web search" + }, + { + "id": "query_field", + "component": "TextField", + "label": "Query", + "value": { + "@path": "/query" + } + }, + { + "id": "search_button", + "component": "Button", + "child": "search_button_label", + "variant": "primary", + "action": { + "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": "{\"/results\": [?@ != null].{title: [0], url: [1]}}", + "data": { + "@call": "regexCapture", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "value": { + "@call": "split", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "value": { + "@call": "transformData", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "jmespath": "text", + "data": { + "@call": "callMcpTool", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "name": "tavily-search", + "arguments": { + "query": { + "@path": "/query" + } + } + } + } + } + }, + "separator": "\n\n" + } + }, + "pattern": "^Title: (.*)\nURL: (.*)" + } + } + } + } + } + } + } + }, + { + "id": "search_button_label", + "component": "Text", + "text": "Search" + }, + { + "id": "result_list", + "component": "Column", + "children": { + "componentId": "result_row", + "path": "/results" + } + }, + { + "id": "result_row", + "component": "Column", + "children": ["result_title", "result_url"] + }, + { + "id": "result_title", + "component": "Text", + "text": { + "@path": "title" + } + }, + { + "id": "result_url", + "component": "Text", + "text": { + "@path": "url" + }, + "variant": "caption" + } + ] + } + } + ] +} diff --git a/typescript/catalogs/mcp/CHANGELOG.md b/typescript/catalogs/mcp/CHANGELOG.md index 4a6da101ca..b10e09c9b7 100644 --- a/typescript/catalogs/mcp/CHANGELOG.md +++ b/typescript/catalogs/mcp/CHANGELOG.md @@ -4,7 +4,7 @@ - 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 `transformData` (standard JMESPath) and `updateDataModel` data functions shape the `callMcpTool` payload into data model updates; their `*Api` and `*Implementation` are exported for hosts that register functions one by one. [#3573](https://github.com/a2ui-project/a2ui/pull/3573), [#3736](https://github.com/a2ui-project/a2ui/pull/3736) +- The `transformData` (standard JMESPath) and `updateDataModel` data functions shape the `callMcpTool` payload into data model updates; `split` and `regexCapture` turn text results into records, with patterns limited to a portable regular expression subset. Their `*Api` and `*Implementation` are exported for hosts that register functions one by one. [#3573](https://github.com/a2ui-project/a2ui/pull/3573), [#3736](https://github.com/a2ui-project/a2ui/pull/3736), [#3574](https://github.com/a2ui-project/a2ui/pull/3574) - 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 906c1f7118..9bcd4b125c 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, 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. +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, four data functions (`transformData`, `updateDataModel`, `split` and `regexCapture`), 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`. 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. +- `callMcpTool` returns the text, parsed JSON and structured content of each result, and can write them to `targetDataPath`. Four data functions shape them into data model updates, so a payload can render the JSON or plain text 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,12 +42,16 @@ 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`. -Two data functions turn that payload into data model updates (see [Shape a headless tool result](#shape-a-headless-tool-result)): +Four data functions turn that payload into data model updates (see [Data functions](#data-functions)). -| 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. | +## Data functions + +| 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. | +| `split` | `value: DynamicValue`, `separator: DynamicString` | Substrings of `value` split by the literal `separator`, or its characters if `separator` is empty. | +| `regexCapture` | `value: DynamicValue`, `pattern: DynamicString` | The capture groups of the first match of `pattern`, or `null` if there is no match. | 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. @@ -55,6 +59,79 @@ Every argument is required, and a `null` or missing argument is rejected by vali `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. +`split` and `regexCapture` take a single string or an array of strings in `value`, and apply the operation to each element of an array. A `null` element reads as the empty string, and a capture group that did not take part in the match reads as the empty string too. + +Patterns use a portable subset of regular expressions that every renderer can implement: JavaScript syntax without lookahead or lookbehind assertions (`(?=`, `(?!`, `(?<=`, `(?`). Patterns are compiled without flags, after a check with `redos-detector` (the same check as the basic catalog's `regex` function) rules out catastrophic backtracking. A pattern outside the subset, unsafe or not valid JavaScript syntax raises an `A2uiExpressionError`. RE2-only syntax such as the `(?i)` inline flag is rejected. To test whether a string matches a pattern, use the basic catalog's `regex` function. + +### Why these four + +The host app chooses which MCP servers to connect to, while A2UI payloads only refer to tools by name. These four functions let an A2UI payload parse tool output directly, so the host app does not need custom parsing code for each tool. This is most useful for general-purpose hosts (like chat apps or agent playgrounds) where users or admins configure which MCP servers to connect, and the host does not know every tool's output format in advance. If your app only connects to one or two MCP servers that you control, writing a custom function in your own catalog is usually simpler (see [Need a different function?](#need-a-different-function)). + +The set is closed. Every function of the catalog has to be specified, tested and implemented in every renderer, so a function earns its place only when many MCP servers need it. A survey of 28 public MCP servers picked these four: + +| Function | Why it is included | Example | +| :---------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- | +| `transformData` | Selects, filters and renames fields of `json`, `structuredContent` or captured rows, and builds the `{path: value}` object `updateDataModel` writes. Every chain ends in it. | `transformData({jmespath: "json[?state == 'open'].title", data: ...})` | +| `updateDataModel` | Writes the result of a chain to several data model paths at once. | `updateDataModel({updates: {"/issues": ..., "/count": ...}})` | +| `split` | 13 of the 28 servers return list data only as text, such as git, Tavily, Context7 and Slack. `split` cuts that text into one string per record. | `split({value: transformData({jmespath: "text", ...}), separator: "\n\n"})` | +| `regexCapture` | The most common text shape is a record of `Key: value` lines. Capture groups turn each record into fields. | `regexCapture({value: ..., pattern: "^Title: (.*)\nURL: (.*)"})` | + +### Need a different function? + +Define it in a catalog of your own instead of asking for it here. Write it with `createFunctionImplementation`, give its catalog an id of your own, and register that catalog next to `mcpCatalog`: + +```typescript +import { + Catalog, + createFunctionImplementation, + DynamicStringSchema, + MessageProcessor, + basicCatalog, +} from '@a2ui/web_core/v1_0'; +import {mcpCatalog} from '@a2ui/catalog-mcp'; +import {z} from 'zod'; + +const ParseCsvImplementation = createFunctionImplementation( + { + name: 'parseCsv', + returnType: 'any', + schema: z.object({value: DynamicStringSchema}), + description: 'Splits CSV text into rows of cells.', + }, + ({value}) => + String(value) + .split('\n') + .map(line => line.split(',')), +); + +const myCatalog = new Catalog( + 'https://example.com/catalogs/parsers.json', + '1.0', + [], + [ParseCsvImplementation], +); + +const processor = new MessageProcessor([basicCatalog, mcpCatalog, myCatalog], onAction); +``` + +A payload calls it with that `catalogId`, and it composes with the MCP functions like any other call: + +```json +{ + "@call": "parseCsv", + "catalogId": "https://example.com/catalogs/parsers.json", + "args": { + "value": { + "@call": "transformData", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": {"jmespath": "text", "data": {"@call": "callMcpTool", "...": "..."}} + } + } +} +``` + +The agent learns about the function from your catalog schema, the same way it learns about the MCP functions. A parser that several hosts share belongs in a package of its own. + ## Installation ```bash @@ -117,7 +194,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`, `TransformDataImplementation`, `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`, `SplitImplementation`, `RegexCaptureImplementation`) 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: @@ -226,6 +303,35 @@ Most MCP tools return plain `content` blocks rather than A2UI resources. The pay When the data needs no reshaping, `targetDataPath` alone is enough: the same example's second button 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`. +A tool that answers in plain text goes through `split` and `regexCapture`. [mcp-text-tool-result.json](../../../catalogs/mcp/v1/examples/mcp-text-tool-result.json) runs a search whose tool, like Tavily, prints a header and then one `Title:`, `URL:` and `Content:` record per result, separated by blank lines: + +```json +{ + "@call": "transformData", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "jmespath": "{\"/results\": [?@ != null].{title: [0], url: [1]}}", + "data": { + "@call": "regexCapture", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "pattern": "^Title: (.*)\nURL: (.*)", + "value": { + "@call": "split", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/mcp/catalog.json", + "args": { + "separator": "\n\n", + "value": {"...": "transformData(text) over callMcpTool(tavily-search)"} + } + } + } + } + } +} +``` + +`split` cuts the `text` of the payload into records, and `regexCapture` maps over them, yielding `null` for a record that does not match, such as the header. The outer `transformData` drops those with `[?@ != null]` and names the fields, and `updateDataModel` writes `/results`. + ## 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. @@ -378,6 +484,10 @@ The component owns its bridge: `McpAppBridge` (`src/components/mcp_app_bridge.ts | `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/functions/split.ts` | `split` | +| `src/functions/regex_capture.ts` | `regexCapture` | +| `src/functions/regex_support.ts` | `isPortableRegex`, `compileSafeRegex` (the cached compilation behind the portability and `isSafeRegex` checks) and `mapTextValue`, the string-or-array mapping of the text functions | +| `src/functions/safe_regex.ts` | `isSafeRegex`, the `redos-detector` check for catastrophic backtracking, the same check as the basic catalog's `regex` function | | `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/package.json b/typescript/catalogs/mcp/package.json index 87c20af3f6..64bc5832fe 100644 --- a/typescript/catalogs/mcp/package.json +++ b/typescript/catalogs/mcp/package.json @@ -201,6 +201,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "ajv": "^8.17.1", "jmespath": "^0.16.0", + "redos-detector": "^6.1.4", "zod": "^3.25.76" }, "peerDependencies": { diff --git a/typescript/catalogs/mcp/src/catalog.test.ts b/typescript/catalogs/mcp/src/catalog.test.ts index 361d12d0e3..3c7f317655 100644 --- a/typescript/catalogs/mcp/src/catalog.test.ts +++ b/typescript/catalogs/mcp/src/catalog.test.ts @@ -15,6 +15,7 @@ */ import jsonToolResultExample from '../../../../catalogs/mcp/v1/examples/mcp-json-tool-result.json' with {type: 'json'}; +import textToolResultExample from '../../../../catalogs/mcp/v1/examples/mcp-text-tool-result.json' with {type: 'json'}; import scene3dExample from '../../../../catalogs/mcp/v1/examples/mcp-app-3d-geometry.json' with {type: 'json'}; import { DataContext, @@ -28,6 +29,8 @@ import {MCP_CATALOG_ID, mcpCatalog} from './catalog.js'; import {A2uiMcpApp} from './components/mcp_app.js'; import {CallMcpToolImplementation, type McpToolClient} from './functions/callMcpTool.js'; import {TransformDataImplementation} from './functions/transform_data.js'; +import {RegexCaptureImplementation} from './functions/regex_capture.js'; +import {SplitImplementation} from './functions/split.js'; import {UpdateDataModelImplementation} from './functions/update_data_model.js'; import {configureMcpCatalog, resetMcpCatalogConfig} from './mcp_catalog_config.js'; import {EXAMPLE_CATALOGS, parseExampleMessages} from './testing/frame_test_support.js'; @@ -168,6 +171,8 @@ describe('mcpCatalog', () => { expect(mcpCatalog.functions.get('callMcpTool')).toBe(CallMcpToolImplementation); expect(mcpCatalog.functions.get('transformData')).toBe(TransformDataImplementation); expect(mcpCatalog.functions.get('updateDataModel')).toBe(UpdateDataModelImplementation); + expect(mcpCatalog.functions.get('split')).toBe(SplitImplementation); + expect(mcpCatalog.functions.get('regexCapture')).toBe(RegexCaptureImplementation); }); it('registers universal components with a2ui- prefixed tag names', () => { @@ -285,4 +290,56 @@ describe('mcpCatalog', () => { expect(errors).toEqual([]); }); }); + + describe('the text tool result example', () => { + // The shape of a Tavily search result: a header, then one record per result. + const searchOutput = [ + 'Detailed Results:', + 'Title: A2UI protocol\nURL: https://a2ui.org\nContent: Agent-to-user interface.', + 'Title: A2UI on GitHub\nURL: https://github.com/a2ui-project/a2ui\nContent: Source code.', + ].join('\n\n'); + let processor: MessageProcessor; + let client: ReturnType; + let errors: unknown[]; + + beforeEach(() => { + processor = new MessageProcessor([...EXAMPLE_CATALOGS]); + client = createToolClient({content: [{type: 'text', text: searchOutput}]}); + configureMcpCatalog({getMcpClientForTool: () => client, processor}); + errors = []; + processor.processMessages(parseExampleMessages(textToolResultExample)); + processor.model.getSurface('gallery-mcp-text-tool-result')!.onError.subscribe(error => { + errors.push(error); + }); + }); + + afterEach(() => { + processor.model.getSurface('gallery-mcp-text-tool-result')?.dispose(); + resetMcpCatalogConfig(); + }); + + it('processes next to the basic catalog, with the list bound to /results', () => { + const surface = processor.model.getSurface('gallery-mcp-text-tool-result')!; + expect(surface.componentsModel.get('search_button')?.type).toBe('Button'); + expect(surface.componentsModel.get('result_list')?.type).toBe('Column'); + expect(surface.dataModel.get('/results')).toEqual([]); + expect(errors).toEqual([]); + }); + + it('runs the button action: the records are split, captured, shaped and written to /results', async () => { + const surface = processor.model.getSurface('gallery-mcp-text-tool-result')!; + const context = new DataContext(surface, '/'); + + await context.resolveAction( + actionOf(textToolResultExample, 'search_button') as Record, + ); + + expect(client.calls).toEqual([{name: 'tavily-search', arguments: {query: 'A2UI protocol'}}]); + expect(surface.dataModel.get('/results')).toEqual([ + {title: 'A2UI protocol', url: 'https://a2ui.org'}, + {title: 'A2UI on GitHub', url: 'https://github.com/a2ui-project/a2ui'}, + ]); + expect(errors).toEqual([]); + }); + }); }); diff --git a/typescript/catalogs/mcp/src/catalog.ts b/typescript/catalogs/mcp/src/catalog.ts index b73a13d142..2af0cb001a 100644 --- a/typescript/catalogs/mcp/src/catalog.ts +++ b/typescript/catalogs/mcp/src/catalog.ts @@ -18,6 +18,8 @@ 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 {RegexCaptureImplementation} from './functions/regex_capture.js'; +import {SplitImplementation} from './functions/split.js'; import {UpdateDataModelImplementation} from './functions/update_data_model.js'; /** Identifier of the A2UI MCP catalog schema (`catalogs/mcp/v1/catalog.json`). */ @@ -25,8 +27,8 @@ export const MCP_CATALOG_ID = 'https://a2ui.org/specification/v1_0/catalogs/mcp/ /** * 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 + * tool result into data model updates (`transformData`, `updateDataModel`, `split`, + * `regexCapture`), 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 or function call of a surface @@ -36,5 +38,11 @@ export const mcpCatalog = new Catalog( MCP_CATALOG_ID, '1.0', [A2uiMcpApp], - [CallMcpToolImplementation, TransformDataImplementation, UpdateDataModelImplementation], + [ + CallMcpToolImplementation, + TransformDataImplementation, + UpdateDataModelImplementation, + SplitImplementation, + RegexCaptureImplementation, + ], ); diff --git a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts index 8765c3f966..bfd5789fe4 100644 --- a/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts +++ b/typescript/catalogs/mcp/src/functions/callMcpTool.test.ts @@ -48,6 +48,8 @@ import { } from './callMcpTool.js'; import mcpCatalogJson from '../catalog.json' with {type: 'json'}; import {TransformDataImplementation} from './transform_data.js'; +import {RegexCaptureImplementation} from './regex_capture.js'; +import {SplitImplementation} from './split.js'; import {UpdateDataModelImplementation} from './update_data_model.js'; const SURFACE_CATALOG_ID = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; @@ -57,7 +59,13 @@ const mcpCatalog = new Catalog( MCP_CATALOG_ID, '1.0', [], - [CallMcpToolImplementation, TransformDataImplementation, UpdateDataModelImplementation], + [ + CallMcpToolImplementation, + TransformDataImplementation, + UpdateDataModelImplementation, + SplitImplementation, + RegexCaptureImplementation, + ], ); /** A function that returns `{path: data}`, the shape a tool argument named `path` needs. */ diff --git a/typescript/catalogs/mcp/src/functions/regex_capture.test.ts b/typescript/catalogs/mcp/src/functions/regex_capture.test.ts new file mode 100644 index 0000000000..cfc24bb8bf --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/regex_capture.test.ts @@ -0,0 +1,135 @@ +/* + * 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 {RegexCaptureApi, RegexCaptureImplementation} from './regex_capture.js'; +import {TransformDataImplementation} from './transform_data.js'; + +const catalog = new Catalog( + 'https://a2ui.org/test/regexCapture', + '1.0', + [], + [RegexCaptureImplementation, TransformDataImplementation], +); + +const call = (name: string, args: Record) => + catalog.invoker(name, args, new DataContext(new DataModel({}), '/')); + +describe('regexCapture', () => { + it('has the metadata the catalog schema publishes', () => { + assert.strictEqual(RegexCaptureApi.name, 'regexCapture'); + assert.strictEqual(RegexCaptureApi.returnType, 'any'); + }); + + it('returns the capture groups of the first match', () => { + assert.deepStrictEqual( + call('regexCapture', {value: 'notes.md 2048', pattern: '^(\\S+) (\\d+)$'}), + ['notes.md', '2048'], + ); + }); + + it('returns only the first match of a pattern that matches more than once', () => { + assert.deepStrictEqual(call('regexCapture', {value: 'a1 b2', pattern: '(\\w)(\\d)'}), [ + 'a', + '1', + ]); + }); + + it('returns null when the pattern does not match', () => { + assert.strictEqual(call('regexCapture', {value: 'prose', pattern: '^(\\d+)$'}), null); + }); + + it('returns an empty array when the pattern matches but has no groups', () => { + assert.deepStrictEqual(call('regexCapture', {value: 'abc', pattern: 'b'}), []); + }); + + it('reads a group that did not participate as an empty string', () => { + assert.deepStrictEqual(call('regexCapture', {value: 'a', pattern: '(a)(b)?'}), ['a', '']); + }); + + it('turns directory listing lines into kind and name pairs', () => { + assert.deepStrictEqual( + call('regexCapture', { + value: ['[DIR] src', '[FILE] README.md'], + pattern: '^\\[(DIR|FILE)\\] (.+)$', + }), + [ + ['DIR', 'src'], + ['FILE', 'README.md'], + ], + ); + }); + + it('leaves null in place for a non-matching element, which an expression filters out', () => { + const rows = call('regexCapture', { + value: ['notes.md 2048', 'not a data line', 'todo.txt 91'], + pattern: '^(\\S+) (\\d+)$', + }); + + assert.deepStrictEqual(rows, [['notes.md', '2048'], null, ['todo.txt', '91']]); + assert.deepStrictEqual(call('transformData', {jmespath: 'rows[?@ != null]', data: {rows}}), [ + ['notes.md', '2048'], + ['todo.txt', '91'], + ]); + }); + + it('matches the same cached pattern again after a non-matching element', () => { + const pattern = '^(\\d+)$'; + assert.deepStrictEqual(call('regexCapture', {value: ['1', 'x', '2'], pattern}), [ + ['1'], + null, + ['2'], + ]); + }); + + it('throws an A2uiExpressionError for an invalid, unsafe or non-portable pattern', () => { + for (const pattern of ['(unbalanced', '(a+)+$', '(?<=\\[)\\w+', '(\\w)\\1']) { + assert.throws( + () => call('regexCapture', {value: 'a', pattern}), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'regexCapture'); + return true; + }, + ); + } + }); + + it('stays synchronous when no argument is pending', () => { + const result = call('regexCapture', {value: 'notes.md', pattern: '^(\\w+)\\.(\\w+)$'}); + assert.strictEqual(typeof (result as {then?: unknown} | null)?.then, 'undefined'); + assert.deepStrictEqual(result, ['notes', 'md']); + }); + + it('returns a promise when an argument is pending', async () => { + const result = call('regexCapture', { + value: Promise.resolve('notes.md'), + pattern: '^(\\w+)\\.(\\w+)$', + }); + assert.strictEqual(typeof (result as {then?: unknown}).then, 'function'); + assert.deepStrictEqual(await result, ['notes', 'md']); + }); + + it('propagates a rejected argument', async () => { + const result = call('regexCapture', { + value: Promise.reject(new Error('the tool call failed')), + pattern: '^(\\w+)\\.(\\w+)$', + }); + await assert.rejects(async () => await result, /the tool call failed/); + }); +}); diff --git a/typescript/catalogs/mcp/src/functions/regex_capture.ts b/typescript/catalogs/mcp/src/functions/regex_capture.ts new file mode 100644 index 0000000000..e3261fe027 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/regex_capture.ts @@ -0,0 +1,56 @@ +/* + * 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 { + DynamicStringSchema, + DynamicValueSchema, + createFunctionImplementation, + type FunctionImplementation, +} from '@a2ui/web_core/v1_0'; +import {z} from 'zod'; + +import {compileSafeRegex, mapTextValue} from './regex_support.js'; + +/** Function API definition for `regexCapture`. */ +export const RegexCaptureApi = { + name: 'regexCapture' as const, + returnType: 'any' as const, + schema: z.object({ + // Quoted to prevent Google Closure Compiler property renaming. + 'value': DynamicValueSchema.describe('The string or array of strings to match against.'), + 'pattern': DynamicStringSchema.describe( + 'A regular expression in the portable subset: JavaScript syntax without lookahead, lookbehind or backreferences. Returns an array of capture groups from the first match, or null if there is no match. Unmatched optional groups return empty strings.', + ), + }), + description: 'Extracts capture groups from the first regular expression match.', +}; + +/** + * Matches a string, or each element of an array of strings, against a pattern and returns the + * capture groups of the first match, or `null` when there is none. + * + * @throws A2uiExpressionError if `pattern` is not a valid, safe and portable regular expression. + */ +export const RegexCaptureImplementation: FunctionImplementation = createFunctionImplementation( + RegexCaptureApi, + args => { + const compiled = compileSafeRegex(String(args['pattern']), 'regexCapture'); + return mapTextValue(args['value'], item => { + const match = compiled.exec(item); + return match ? match.slice(1).map(group => group ?? '') : null; + }); + }, +); diff --git a/typescript/catalogs/mcp/src/functions/regex_support.test.ts b/typescript/catalogs/mcp/src/functions/regex_support.test.ts new file mode 100644 index 0000000000..ecd966d75b --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/regex_support.test.ts @@ -0,0 +1,134 @@ +/* + * 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} from '@a2ui/web_core/v1_0'; +import {compileSafeRegex, isPortableRegex, mapTextValue} from './regex_support.js'; + +describe('isPortableRegex', () => { + const nonPortable: ReadonlyArray = [ + ['a lookahead', 'a(?=b)'], + ['a negative lookahead', 'a(?!b)'], + ['a lookbehind', '(?<=a)b'], + ['a negative lookbehind', '(?\\w)\\k'], + ]; + + for (const [label, pattern] of nonPortable) { + it(`rejects ${label}`, () => { + assert.strictEqual(isPortableRegex(pattern), false); + }); + } + + const portable: ReadonlyArray = [ + ['capture groups and anchors', '^Title: (.+)$'], + ['a non-capturing group', '(?:DIR|FILE) (.+)'], + ['a named capture group', '(?\\w+)'], + ['an escaped parenthesis before a question mark', '\\(?=x'], + ['an escaped backslash before a digit', '\\\\1'], + ['lookaround characters inside a character class', '[(?=]+'], + ['a backreference-like escape inside a character class', '[\\1]'], + ['an escaped closing bracket inside a character class', '[\\](?=]'], + ]; + + for (const [label, pattern] of portable) { + it(`accepts ${label}`, () => { + assert.strictEqual(isPortableRegex(pattern), true); + }); + } +}); + +describe('compileSafeRegex', () => { + it('compiles a pattern', () => { + const compiled = compileSafeRegex('^(\\w+)\\.(\\w+)$', 'test'); + assert.deepStrictEqual(compiled.exec('notes.md')?.slice(1), ['notes', 'md']); + assert.strictEqual(compiled.flags, ''); + }); + + it('returns the same instance for the same source', () => { + const first = compileSafeRegex('cached', 'test'); + assert.strictEqual(compileSafeRegex('cached', 'test'), first); + }); + + const rejected: ReadonlyArray = [ + ['a lookaround assertion', '(?<=\\[)\\w+(?=\\])'], + ['a backreference', '(\\w)\\1'], + ['an unbalanced group', '(abc'], + ['nested quantifiers that backtrack catastrophically', '(a+)+$'], + ['an RE2 inline flag', '(?i)dir'], + ]; + + for (const [label, source] of rejected) { + it(`rejects ${label} with an A2uiExpressionError naming the function`, () => { + assert.throws( + () => compileSafeRegex(source, 'regexCapture'), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'regexCapture'); + assert.ok(error.message.includes(source)); + return true; + }, + ); + }); + } + + it('reads RE2 Unicode and POSIX classes as JavaScript does, not as classes', () => { + // Without the `u` flag, `\p` is a literal `p`, and `[[:alpha:]]` is the character class + // `[[:alpha:]` followed by a literal `]`. Payloads written for RE2 need JavaScript syntax. + assert.strictEqual(compileSafeRegex('^\\pL+$', 'test').test('pLLL'), true); + assert.strictEqual(compileSafeRegex('^\\pL+$', 'test').test('abc'), false); + assert.strictEqual(compileSafeRegex('^[[:alpha:]]+$', 'test').test('a]'), true); + assert.strictEqual(compileSafeRegex('^[[:alpha:]]+$', 'test').test('xyz'), false); + }); + + it('evicts the oldest entry once the cache is full', () => { + const first = compileSafeRegex('evict_0', 'test'); + for (let i = 1; i <= 100; i++) { + compileSafeRegex(`evict_${i}`, 'test'); + } + assert.notStrictEqual(compileSafeRegex('evict_0', 'test'), first); + }); +}); + +describe('mapTextValue', () => { + it('applies the function to a single string', () => { + assert.strictEqual( + mapTextValue('abc', item => item.toUpperCase()), + 'ABC', + ); + }); + + it('maps the function over an array, converting each element to text', () => { + assert.deepStrictEqual( + mapTextValue(['a', 1, null, undefined, true], item => item + '!'), + ['a!', '1!', '!', '!', 'true!'], + ); + }); + + it('reads null and undefined as the empty string', () => { + assert.strictEqual( + mapTextValue(null, item => item.length), + 0, + ); + assert.strictEqual( + mapTextValue(undefined, item => item.length), + 0, + ); + }); +}); diff --git a/typescript/catalogs/mcp/src/functions/regex_support.ts b/typescript/catalogs/mcp/src/functions/regex_support.ts new file mode 100644 index 0000000000..40702e0662 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/regex_support.ts @@ -0,0 +1,119 @@ +/* + * 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. + */ + +/** + * Regular expression support for the text functions of the catalog: compilation of + * agent-supplied patterns behind a portability check and the ReDoS check in `safe_regex.ts`, and + * the string-or-array mapping that `split` and `regexCapture` share. + */ + +import {A2uiExpressionError} from '@a2ui/web_core/v1_0'; + +import {isSafeRegex} from './safe_regex.js'; + +/** Maximum number of compiled patterns kept in the cache. */ +const MAX_CACHED_PATTERNS = 100; + +/** Compiled patterns keyed by source, in insertion order. */ +const compiledPatterns = new Map(); + +/** Group openers of lookahead and lookbehind assertions. */ +const LOOKAROUND_OPENERS = ['(?=', '(?!', '(?<=', '(?`) backreferences. + * Escaped characters and the contents of character classes are read as literals. + */ +export function isPortableRegex(pattern: string): boolean { + let inClass = false; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]; + if (char === '\\') { + const next = pattern[i + 1] ?? ''; + if (!inClass && (/[1-9]/.test(next) || pattern.startsWith('k<', i + 1))) { + return false; + } + i++; + } else if (inClass) { + inClass = char !== ']'; + } else if (char === '[') { + inClass = true; + } else if (char === '(' && LOOKAROUND_OPENERS.some(opener => pattern.startsWith(opener, i))) { + return false; + } + } + return true; +} + +/** + * Compiles a JavaScript regular expression after checking that it stays inside the portable + * subset (see `isPortableRegex`) and does not allow catastrophic backtracking, caching up to + * `MAX_CACHED_PATTERNS` instances. + * + * Patterns are compiled without flags, so the accepted syntax matches the `new RegExp(pattern)` + * check inside `isSafeRegex` (`safe_regex.ts`) and the basic catalog's `regex` function. + * + * @param source The pattern text. + * @param functionName The calling function, named in the error. + * @throws A2uiExpressionError if the pattern is not portable, invalid or unsafe. + */ +export function compileSafeRegex(source: string, functionName: string): RegExp { + let compiled = compiledPatterns.get(source); + if (compiled) { + return compiled; + } + if (!isPortableRegex(source)) { + throw new A2uiExpressionError( + `${functionName} rejected the pattern '${source}': lookaround assertions and backreferences are not supported.`, + functionName, + ); + } + if (!isSafeRegex(source)) { + throw new A2uiExpressionError( + `${functionName} rejected the pattern '${source}': it is not a valid regular expression or it allows catastrophic backtracking.`, + functionName, + ); + } + try { + compiled = new RegExp(source); + } catch (error) { + throw new A2uiExpressionError( + `${functionName} could not compile the pattern '${source}': ${error instanceof Error ? error.message : String(error)}`, + functionName, + error, + ); + } + if (compiledPatterns.size >= MAX_CACHED_PATTERNS) { + const oldestKey = compiledPatterns.keys().next().value; + if (oldestKey !== undefined) { + compiledPatterns.delete(oldestKey); + } + } + compiledPatterns.set(source, compiled); + return compiled; +} + +/** + * Applies `fn` to `value` as text, or to each element of an array `value`. + * + * A `null` or `undefined` element of an array reads as the empty string; any other non-string is + * converted with `String`. + */ +export function mapTextValue(value: unknown, fn: (item: string) => T): T | T[] { + const asText = (item: unknown) => String(item ?? ''); + return Array.isArray(value) ? value.map(item => fn(asText(item))) : fn(asText(value)); +} diff --git a/typescript/catalogs/mcp/src/functions/safe_regex.test.ts b/typescript/catalogs/mcp/src/functions/safe_regex.test.ts new file mode 100644 index 0000000000..c42ce55f53 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/safe_regex.test.ts @@ -0,0 +1,135 @@ +/* + * 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 {isSafeRegex} from './safe_regex.js'; + +describe('isSafeRegex (CWE-1333 ReDoS Safety)', () => { + describe('Catastrophic Backtracking Patterns (Unsafe)', () => { + it('blocks nested plus quantifiers', () => { + assert.strictEqual(isSafeRegex('(a+)+b'), false); + assert.strictEqual(isSafeRegex('(a+)+'), false); + assert.strictEqual(isSafeRegex('((a+)+)+'), false); + assert.strictEqual(isSafeRegex('(\\d+)+'), false); + assert.strictEqual(isSafeRegex('([a-z]+)+'), false); + }); + + it('blocks nested star quantifiers', () => { + assert.strictEqual(isSafeRegex('(a*)*b'), false); + assert.strictEqual(isSafeRegex('(a*)*'), false); + assert.strictEqual(isSafeRegex('([a-zA-Z]+)*$'), false); + assert.strictEqual(isSafeRegex('(?:[0-9]+)+'), false); + }); + + it('blocks bounded nested quantifiers that cause exponential growth', () => { + assert.strictEqual(isSafeRegex('(a{1,}){2,}'), false); + assert.strictEqual(isSafeRegex('(a+){2,}'), false); + assert.strictEqual(isSafeRegex('(a{2,})+'), false); + }); + + it('blocks overlapping alternations in quantified groups', () => { + assert.strictEqual(isSafeRegex('(a|aa)+$'), false); + assert.strictEqual(isSafeRegex('(a|a+)+$'), false); + assert.strictEqual(isSafeRegex('(\\d|\\w)+'), false); + assert.strictEqual(isSafeRegex('(a|b|ab)+$'), false); + }); + + it('blocks adjacent overlapping quantifiers inside quantified groups', () => { + assert.strictEqual(isSafeRegex('(x+x+)+y'), false); + assert.strictEqual(isSafeRegex('(\\d+\\d+)+'), false); + }); + + it('blocks wildcard dot overlap patterns', () => { + assert.strictEqual(isSafeRegex('(a|.)+'), false); + assert.strictEqual(isSafeRegex('(a|.)+$'), false); + assert.strictEqual(isSafeRegex('(.|a)+'), false); + assert.strictEqual(isSafeRegex('(.*)+'), false); + assert.strictEqual(isSafeRegex('(.+)+'), false); + }); + + it('blocks repeated non-disjoint prefixes and suffixes', () => { + assert.strictEqual(isSafeRegex('(aa+)*'), false); + assert.strictEqual(isSafeRegex('(a+a)*'), false); + assert.strictEqual(isSafeRegex('(1\\d+)*'), false); + }); + + it('blocks invalid regex syntax safely without crashing', () => { + assert.strictEqual(isSafeRegex('['), false); + assert.strictEqual(isSafeRegex('(?'), false); + assert.strictEqual(isSafeRegex('*abc'), false); + assert.strictEqual(isSafeRegex('a{2,1}'), false); // numbers out of order + assert.strictEqual(isSafeRegex('a{invalid}'), true); // safely parsed as literal without crash + }); + }); + + describe('Legitimate Form Validation Patterns (Safe)', () => { + it('allows simple and alphanumeric patterns', () => { + assert.strictEqual(isSafeRegex('^[a-z]+$'), true); + assert.strictEqual(isSafeRegex('^[0-9]+$'), true); + assert.strictEqual(isSafeRegex('^[a-zA-Z0-9_]+$'), true); + assert.strictEqual(isSafeRegex('^[a-zA-Z0-9_-]{3,16}$'), true); + assert.strictEqual(isSafeRegex('^[a-fA-F0-9]{32}$'), true); + }); + + it('allows standard email and URL patterns', () => { + assert.strictEqual(isSafeRegex('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z]{2,}$'), true); + assert.strictEqual(isSafeRegex('^https?://[^\\s/$.?#].[^\\s]*$'), true); + }); + + it('allows phone and postal code patterns', () => { + assert.strictEqual(isSafeRegex('^\\+?[1-9]\\d{1,14}$'), true); + assert.strictEqual(isSafeRegex('^\\d{5}$'), true); + assert.strictEqual(isSafeRegex('^\\d{5}(-\\d{4})?$'), true); + }); + + it('allows date and time patterns', () => { + assert.strictEqual(isSafeRegex('^\\d{4}-\\d{2}-\\d{2}$'), true); + assert.strictEqual(isSafeRegex('^\\d{2}:\\d{2}(:\\d{2})?$'), true); + }); + + it('allows hex color codes', () => { + assert.strictEqual(isSafeRegex('^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'), true); + }); + + it('allows UUID pattern', () => { + assert.strictEqual( + isSafeRegex( + '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ), + true, + ); + }); + + it('allows disjoint delimited lists', () => { + assert.strictEqual(isSafeRegex('^[a-z]+(,[a-z]+)*$'), true); + assert.strictEqual(isSafeRegex('^(\\d{1,3}\\.){3}\\d{1,3}$'), true); + assert.strictEqual(isSafeRegex('^\\d+(\\.\\d+)?$'), true); + assert.strictEqual(isSafeRegex('^\\$\\d+(\\.\\d{2})?$'), true); + }); + + it('handles empty or nullish safely', () => { + assert.strictEqual(isSafeRegex(''), true); + assert.strictEqual(isSafeRegex(null as unknown as string), true); + assert.strictEqual(isSafeRegex(undefined as unknown as string), true); + }); + + it('rejects patterns exceeding maxPatternLength', () => { + assert.strictEqual(isSafeRegex('a'.repeat(257)), false); + assert.strictEqual(isSafeRegex('a'.repeat(20), {maxPatternLength: 10}), false); + }); + }); +}); diff --git a/typescript/catalogs/mcp/src/functions/safe_regex.ts b/typescript/catalogs/mcp/src/functions/safe_regex.ts new file mode 100644 index 0000000000..9d1b323f1d --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/safe_regex.ts @@ -0,0 +1,65 @@ +/* + * 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 {isSafePattern} from 'redos-detector'; + +/** + * ReDoS check for agent-supplied patterns, a copy of the check behind the basic catalog's + * `regex` function in `@a2ui/web_core`. The catalog keeps its own copy so that `@a2ui/web_core` + * does not have to expose `redos-detector` through its public API. + */ + +/** + * Options for regular expression safety validation. + */ +export interface SafeRegexOptions { + /** Maximum allowable length of the regex pattern string (default: 256). */ + maxPatternLength?: number; +} + +/** + * Validates whether a regular expression pattern is safe from catastrophic + * backtracking (ReDoS - CWE-1333) using `redos-detector`. + * + * Both the raw pattern and its anchored form (`^(?:pattern)$`, which is also + * used by HTML ``) are checked so that unanchored trailing + * nested quantifiers such as `(a+)+` are detected alongside `(a+)+b`. + * + * @param pattern The regular expression pattern string to validate. + * @param options Optional configuration for pattern length bounds. + * @returns `true` if the pattern is safe to execute; `false` if unsafe or invalid. + */ +export function isSafeRegex(pattern: string, options: SafeRegexOptions = {}): boolean { + if (pattern === null || pattern === undefined || typeof pattern !== 'string') { + return true; + } + if (pattern.length === 0) { + return true; + } + + const maxPatternLength = options.maxPatternLength ?? 256; + if (pattern.length > maxPatternLength) { + return false; + } + + try { + // Verify that the pattern is syntactically valid in the host JS RegExp engine. + new RegExp(pattern); + return isSafePattern(pattern).safe && isSafePattern(`^(?:${pattern})$`).safe; + } catch { + return false; + } +} diff --git a/typescript/catalogs/mcp/src/functions/split.test.ts b/typescript/catalogs/mcp/src/functions/split.test.ts new file mode 100644 index 0000000000..0ffe8746a6 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/split.test.ts @@ -0,0 +1,104 @@ +/* + * 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 {SplitApi, SplitImplementation} from './split.js'; + +const catalog = new Catalog('https://a2ui.org/test/split', '1.0', [], [SplitImplementation]); + +const call = (args: Record) => + catalog.invoker('split', args, new DataContext(new DataModel({}), '/')); + +describe('split', () => { + it('has the metadata the catalog schema publishes', () => { + assert.strictEqual(SplitApi.name, 'split'); + assert.strictEqual(SplitApi.returnType, 'any'); + }); + + it('splits a string on a separator', () => { + assert.deepStrictEqual(call({value: 'a,b,c', separator: ','}), ['a', 'b', 'c']); + }); + + it('splits the lines of a tool text block', () => { + assert.deepStrictEqual(call({value: '[DIR] src\n[FILE] README.md', separator: '\n'}), [ + '[DIR] src', + '[FILE] README.md', + ]); + }); + + it('splits into characters when the separator is empty', () => { + assert.deepStrictEqual(call({value: 'abc', separator: ''}), ['a', 'b', 'c']); + }); + + it('rejects a null separator at validation', () => { + assert.throws( + () => call({value: 'ab', separator: null}), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'split'); + assert.ok(error.message.startsWith("Validation failed for function 'split'")); + return true; + }, + ); + }); + + it('keeps surrogate pairs whole when splitting into characters', () => { + assert.deepStrictEqual(call({value: 'a😀b', separator: ''}), ['a', '😀', 'b']); + assert.deepStrictEqual(call({value: '😀', separator: ''}), ['😀']); + }); + + it('treats the separator as literal text, not as a pattern', () => { + assert.deepStrictEqual(call({value: 'a.b', separator: '.'}), ['a', 'b']); + }); + + it('splits each element of an array, giving an array of arrays', () => { + assert.deepStrictEqual(call({value: ['a:1', 'b:2'], separator: ':'}), [ + ['a', '1'], + ['b', '2'], + ]); + }); + + it('rejects a null value at validation', () => { + assert.throws( + () => call({value: null, separator: ','}), + (error: unknown) => { + assert.ok(error instanceof A2uiExpressionError); + assert.strictEqual(error.expression, 'split'); + assert.ok(error.message.startsWith("Validation failed for function 'split'")); + return true; + }, + ); + }); + + it('stays synchronous when no argument is pending', () => { + const result = call({value: 'a,b', separator: ','}); + assert.strictEqual(typeof (result as {then?: unknown} | null)?.then, 'undefined'); + assert.deepStrictEqual(result, ['a', 'b']); + }); + + it('returns a promise when an argument is pending', async () => { + const result = call({value: Promise.resolve('a,b'), separator: ','}); + assert.strictEqual(typeof (result as {then?: unknown}).then, 'function'); + assert.deepStrictEqual(await result, ['a', 'b']); + }); + + it('propagates a rejected argument', async () => { + const result = call({value: Promise.reject(new Error('the tool call failed')), separator: ','}); + await assert.rejects(async () => await result, /the tool call failed/); + }); +}); diff --git a/typescript/catalogs/mcp/src/functions/split.ts b/typescript/catalogs/mcp/src/functions/split.ts new file mode 100644 index 0000000000..2c3170fbd9 --- /dev/null +++ b/typescript/catalogs/mcp/src/functions/split.ts @@ -0,0 +1,55 @@ +/* + * 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 { + DynamicStringSchema, + DynamicValueSchema, + createFunctionImplementation, + type FunctionImplementation, +} from '@a2ui/web_core/v1_0'; +import {z} from 'zod'; + +import {mapTextValue} from './regex_support.js'; + +/** Function API definition for `split`. */ +export const SplitApi = { + name: 'split' as const, + returnType: 'any' as const, + schema: z.object({ + // Quoted to prevent Google Closure Compiler property renaming. + 'value': DynamicValueSchema.describe('The string or array of strings to split.'), + 'separator': DynamicStringSchema.describe( + 'The delimiter string to split on. An empty string splits into individual characters (Unicode code points).', + ), + }), + description: 'Splits a string into an array of substrings using a literal separator.', +}; + +/** + * Splits a string on a literal separator, or each element of an array of strings, giving an + * array of arrays. An empty separator splits into Unicode code points, so an emoji or other + * character outside the Basic Multilingual Plane stays whole instead of becoming two surrogate + * halves. + */ +export const SplitImplementation: FunctionImplementation = createFunctionImplementation( + SplitApi, + args => { + const separator = String(args['separator']); + return mapTextValue(args['value'], item => + separator === '' ? Array.from(item) : item.split(separator), + ); + }, +); diff --git a/typescript/catalogs/mcp/src/index.ts b/typescript/catalogs/mcp/src/index.ts index f87101e985..dada873cc6 100644 --- a/typescript/catalogs/mcp/src/index.ts +++ b/typescript/catalogs/mcp/src/index.ts @@ -32,5 +32,7 @@ export { type McpToolDataPayload, type McpToolClient, } from './functions/callMcpTool.js'; +export {RegexCaptureApi, RegexCaptureImplementation} from './functions/regex_capture.js'; +export {SplitApi, SplitImplementation} from './functions/split.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 75399ce433..6f08162b33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -184,6 +184,7 @@ __metadata: karma-jasmine: "npm:^5.1.0" lit: "npm:^3.3.3" prettier: "npm:^3.8.4" + redos-detector: "npm:^6.1.4" tsx: "npm:^4.22.4" typescript: "npm:5.9.3" wireit: "npm:^0.15.0-pre.2"