diff --git a/README.md b/README.md index 1eb6e110e..c3311b45f 100644 --- a/README.md +++ b/README.md @@ -92,11 +92,11 @@ flowchart TD The ecosystem is architected as a modular, highly cohesive monorepo utilizing **Yarn v4 Workspaces**: -| Workspace | Package Name | Description & Core Responsibilities | -| :------------- | :----------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`shell/`** | `a2ui-composer-shell` | Standalone web application hosting chat panel, live JSON editors, real-time iframe preview wrapper, debugging suite, interactive mock rules manager, and IndexedDB storage engines. | -| **`bridge/`** | `a2ui-bridge` | ESBuild-bundled lightweight cross-frame JavaScript library embedded inside child rendering iframes. | -| **`samples/`** | `ng-basic-catalog`
`lit-basic-catalog`
`react-basic-catalog` | Plug-and-play developer renderer sandbox applications demonstrating zero-boilerplate integration across Lit, Angular, and React rendering stacks. | +| Workspace | Package Name | Description & Core Responsibilities | +| :------------- | :------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`shell/`** | `a2ui-composer-shell` | Standalone web application hosting chat panel, live JSON editors, real-time iframe preview wrapper, debugging suite, interactive mock rules manager, and IndexedDB storage engines. | +| **`bridge/`** | `a2ui-bridge` | ESBuild-bundled lightweight cross-frame JavaScript library embedded inside child rendering iframes. | +| **`samples/`** | `ng-basic-catalog`
`ng-basic-with-mcp-catalog`
`lit-basic-catalog`
`react-basic-catalog` | Plug-and-play developer renderer sandbox applications demonstrating zero-boilerplate integration across Lit, Angular, and React rendering stacks, plus an Angular renderer with MCP catalog integration. | ## Getting Started @@ -118,14 +118,20 @@ yarn install # ng-basic-catalog starts on localhost:3456 # lit-basic-catalog starts on localhost:3457 # react-basic-catalog starts on localhost:3458 +# ng-basic-with-mcp-catalog starts on localhost:3459 yarn --cwd samples/ng-basic-catalog start yarn --cwd samples/lit-basic-catalog start yarn --cwd samples/react-basic-catalog start +yarn --cwd samples/ng-basic-with-mcp-catalog start # Launch standalone interactive development shell on http://localhost:4200 yarn --cwd shell start ``` +### Angular Basic with MCP Renderer (`ng-basic-with-mcp-catalog`) + +`samples/ng-basic-with-mcp-catalog` provides a standalone Angular renderer configured with the `basic_with_mcp` catalog (`https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json`). Unlike the standard basic catalog renderers (`ng-basic-catalog`, `lit-basic-catalog`, and `react-basic-catalog`), this renderer exposes the Model Context Protocol (**MCP**) catalog functions (`callMcpTool`, `jmespath`, `split`, `regexCapture`, `regexReplace`, and `updateDataModel`). When selected in Settings alongside active HTTP MCP servers, the Gemini assistant automatically includes the discovered MCP tools in its system instructions so generated A2UI surfaces can invoke live MCP tools directly. + When the A2UI Composer starts, if this is your first time using it, you'll be automatically routed to the Settings page, where you will need to enter the URL of the renderer app you want to use (e.g., "http://localhost:3456). diff --git a/bridge/package.json b/bridge/package.json index edc5589b3..ec3655c99 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -29,9 +29,17 @@ "prettier:check": "prettier --ignore-path ../.prettierignore --check .", "lint": "eslint ." }, + "dependencies": { + "@a2ui/web_core": "0.10.5", + "@modelcontextprotocol/sdk": "1.30.0", + "jmespath": "0.16.0", + "re2js": "2.8.6", + "zod": "^4.1.12" + }, "devDependencies": { "@angular/core": "22.0.7", "@lit/context": "1.1.6", + "@types/jmespath": "0.15.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitest/coverage-v8": "4.1.10", diff --git a/bridge/src/bridge-message.ts b/bridge/src/bridge-message.ts index 7ed544bdc..b91364a36 100644 --- a/bridge/src/bridge-message.ts +++ b/bridge/src/bridge-message.ts @@ -25,6 +25,8 @@ export enum PreviewBridgeMessageType { FORCE_UNBLOCK = 'FORCE_UNBLOCK', GET_CATALOG = 'GET_CATALOG', GET_COMPONENT_USAGES = 'GET_COMPONENT_USAGES', + MCP_REQUEST = 'MCP_REQUEST', + MCP_RESPONSE = 'MCP_RESPONSE', RENDER_A2UI = 'RENDER_A2UI', RENDER_ERROR = 'RENDER_ERROR', RENDER_SUCCESS = 'RENDER_SUCCESS', @@ -201,3 +203,17 @@ export declare interface A2uiComponentInstance { id?: string; [key: string]: unknown; } + +/** Payload for MCP_REQUEST message type. */ +export declare interface McpRequestPayload { + requestId: string; + toolName: string; + args: Record; +} + +/** Payload for MCP_RESPONSE message type. */ +export declare interface McpResponsePayload { + requestId: string; + result?: unknown; + error?: string; +} diff --git a/bridge/src/iframe-mcp-client.spec.ts b/bridge/src/iframe-mcp-client.spec.ts new file mode 100644 index 000000000..939f741e5 --- /dev/null +++ b/bridge/src/iframe-mcp-client.spec.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2026 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 + * + * http://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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {IframeMcpClient} from './iframe-mcp-client.js'; + +describe('IframeMcpClient', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves callTool when matching MCP_RESPONSE is received', async () => { + const sentPayloads: Array<{ + requestId: string; + toolName: string; + args: Record; + }> = []; + const client = new IframeMcpClient(payload => { + sentPayloads.push(payload); + }); + + const promise = client.callTool({name: 'list_dir', arguments: {path: '/root'}}); + expect(sentPayloads).toHaveLength(1); + expect(sentPayloads[0].toolName).toBe('list_dir'); + expect(sentPayloads[0].args).toEqual({path: '/root'}); + + client.handleResponse({ + requestId: sentPayloads[0].requestId, + result: {content: [{type: 'text', text: 'file1.txt'}]}, + }); + + const result = await promise; + expect(result).toEqual({content: [{type: 'text', text: 'file1.txt'}]}); + }); + + it('rejects callTool when MCP_RESPONSE contains error', async () => { + let reqId = ''; + const client = new IframeMcpClient(payload => { + reqId = payload.requestId; + }); + + const promise = client.callTool({name: 'fail_tool'}); + client.handleResponse({ + requestId: reqId, + error: 'Server unreachable', + }); + + await expect(promise).rejects.toThrow('Server unreachable'); + }); + + it('times out if no response is received within timeoutMs', async () => { + const client = new IframeMcpClient(() => {}, 1000); + const promise = client.callTool({name: 'slow_tool'}); + vi.advanceTimersByTime(1001); + await expect(promise).rejects.toThrow(/timed out/); + }); +}); diff --git a/bridge/src/iframe-mcp-client.ts b/bridge/src/iframe-mcp-client.ts new file mode 100644 index 000000000..4266d73ab --- /dev/null +++ b/bridge/src/iframe-mcp-client.ts @@ -0,0 +1,73 @@ +/** + * Copyright 2026 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 + * + * http://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 {McpRequestPayload, McpResponsePayload} from './bridge-message.js'; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + timer: ReturnType; +} + +/** + * Proxies MCP `callTool` requests from inside the preview iframe to the parent shell + * via postMessage (`MCP_REQUEST` / `MCP_RESPONSE`). + */ +export class IframeMcpClient { + private pendingRequests = new Map(); + + constructor( + private readonly sendMcpRequest: (payload: McpRequestPayload) => void, + private readonly timeoutMs = 30000, + ) {} + + async callTool(params: {name: string; arguments?: Record}): Promise { + const requestId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(requestId); + reject(new Error(`MCP tool call "${params.name}" timed out after ${this.timeoutMs}ms`)); + }, this.timeoutMs); + + this.pendingRequests.set(requestId, {resolve, reject, timer}); + + this.sendMcpRequest({ + requestId, + toolName: params.name, + args: params.arguments ?? {}, + }); + }); + } + + handleResponse(payload: McpResponsePayload): void { + const pending = this.pendingRequests.get(payload.requestId); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendingRequests.delete(payload.requestId); + + if (payload.error) { + pending.reject(new Error(payload.error)); + } else { + pending.resolve(payload.result); + } + } +} diff --git a/bridge/src/index.ts b/bridge/src/index.ts index afa864c72..ead5b52f2 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -17,6 +17,9 @@ export * from './bridge-message.js'; export * from './domain-origin-verification.js'; export * from './instrumentation-overrides.js'; +// TODO: Replace './mcp/index.js' with '@a2ui/mcp-catalog/v0_9' once published to NPM. +export * from './mcp/index.js'; +export * from './iframe-mcp-client.js'; export * from './preview-bridge.js'; export * from './render-config.js'; export * from './surface-resize-observer.js'; diff --git a/bridge/src/mcp/README.md b/bridge/src/mcp/README.md new file mode 100644 index 000000000..a3d35eec7 --- /dev/null +++ b/bridge/src/mcp/README.md @@ -0,0 +1,95 @@ +# Vendored A2UI MCP Catalog + +This directory contains vendored TypeScript source files from the upstream +`@a2ui/mcp-catalog` package (version `v0_9`). + +## Summary + +The upstream package `@a2ui/mcp-catalog` is not yet published to the NPM registry +and remains private within the A2UI repository. Composer requires the MCP catalog +functions to support Model Context Protocol (MCP) tool execution and dynamic data +transformations in preview surfaces. + +To unblock Composer development without waiting for the upstream release, we +vendor the catalog source files into this isolated directory. Once the upstream +package is published to NPM, remove this directory and reference the official +NPM package. + +## Scope and Non-Scope + +- **Scope**: Catalog function implementations, schemas, dynamic expression + resolvers, and unit tests adhering to the A2UI MCP catalog specification. +- **Non-Scope**: Composer bridge infrastructure and iframe communication. The + `IframeMcpClient` class resides in `bridge/src/iframe-mcp-client.ts`, outside + this directory. + +## File Inventory and Upstream Mapping + +The following table maps each vendored file in this directory to its upstream +source in `a2ui/catalogs/mcp/v0_9/`: + +| Local Path | Upstream Source | Description | +| :----------------------------- | :--------------------------------- | :--------------------------------------------------------------------------- | +| `index.ts` | `src/index.ts` | Catalog function factory (`createMcpCatalogFunctions`) and catalog constants | +| `dynamic-values.ts` | `src/dynamic-values.ts` | Recursive resolution of data bindings and dynamic function calls | +| `functions/callMcpTool.ts` | `src/functions/callMcpTool.ts` | MCP tool execution function and UI resource extractor | +| `functions/common.ts` | `src/functions/common.ts` | Schema helpers and Promise settling utilities | +| `functions/jmespath.ts` | `src/functions/jmespath.ts` | JMESPath query evaluation function | +| `functions/regexCapture.ts` | `src/functions/regexCapture.ts` | RE2 regular expression capture group extractor | +| `functions/regexReplace.ts` | `src/functions/regexReplace.ts` | RE2 regular expression replacement function | +| `functions/split.ts` | `src/functions/split.ts` | String delimiter split function | +| `functions/updateDataModel.ts` | `src/functions/updateDataModel.ts` | Surface data model mutation function | +| `mcp.spec.ts` | `src/**/*.test.ts` | Vitest test suite adapted from upstream unit tests | + +## Temporary Dependencies + +The `bridge/package.json` file includes several dependencies solely to support +these vendored files: + +- `@modelcontextprotocol/sdk`: MCP client type definitions and result schemas. +- `jmespath`: Query engine for the `jmespath` catalog function. +- `re2js`: Linear-time regular expression engine for `regexCapture` and `regexReplace`. +- `zod`: Runtime schema validation for function signatures. +- `@types/jmespath`: TypeScript definitions for JMESPath. + +## Migration Steps + +Follow these steps to replace this vendored directory once `@a2ui/mcp-catalog` +is published to NPM: + +1. Add `@a2ui/mcp-catalog` to `bridge/package.json`: + + ```bash + yarn workspace a2ui-bridge add @a2ui/mcp-catalog + ``` + +2. Remove temporary dependencies from `bridge/package.json`: + + Remove `jmespath`, `re2js`, `zod`, `@types/jmespath`, and + `@modelcontextprotocol/sdk` (unless required elsewhere in `a2ui-bridge`). + +3. Update `bridge/src/index.ts` to re-export from the NPM package: + + ```typescript + export * from '@a2ui/mcp-catalog/v0_9'; + export * from './iframe-mcp-client.js'; + ``` + +4. Delete this directory: + + ```bash + rm -rf bridge/src/mcp + ``` + +5. Update `samples/ng-basic-with-mcp-catalog`: + + - Add `@a2ui/mcp-catalog` to `samples/ng-basic-with-mcp-catalog/package.json`. + - In `samples/ng-basic-with-mcp-catalog/src/app/basic-with-mcp.catalog.ts`, + replace the import from `a2ui-bridge` with `@a2ui/mcp-catalog/v0_9`. + +6. Run the test suite and linter to verify the migration: + + ```bash + yarn test + yarn lint + ``` diff --git a/bridge/src/mcp/dynamic-values.ts b/bridge/src/mcp/dynamic-values.ts new file mode 100644 index 000000000..8ca98acef --- /dev/null +++ b/bridge/src/mcp/dynamic-values.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + * + * Type guards and recursive resolution utilities for A2UI dynamic values. + * + * While `DataContext.resolveDynamicValue` resolves a single top-level `DynamicValue`, + * these helpers recursively traverse nested objects and arrays to resolve embedded + * data bindings and function calls. + */ + +import type {DataBinding, DataContext, FunctionCall} from '@a2ui/web_core/v0_9'; + +/** Union of dynamic expression types (`DataBinding` or `FunctionCall`) that require runtime evaluation. */ +export type DynamicExpression = DataBinding | FunctionCall; + +/** Maximum recursion depth when resolving nested dynamic values to prevent infinite loops. */ +const MAX_RESOLUTION_DEPTH = 100; + +/** Checks whether a value is a non-null, non-array object. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Checks whether `value` is a `DataBinding` (`{path: string}`). + * + * Objects with additional properties alongside `path` are treated as literal objects. + */ +export function isDataBinding(value: unknown): value is DataBinding { + return isRecord(value) && typeof value['path'] === 'string' && Object.keys(value).length === 1; +} + +/** + * Checks whether `value` is a `FunctionCall` (`{call: string, args?, returnType?}`). + */ +export function isFunctionCall(value: unknown): value is FunctionCall { + if (!isRecord(value) || typeof value['call'] !== 'string') { + return false; + } + const args = value['args']; + const returnType = value['returnType']; + return ( + (args === undefined || isRecord(args)) && + (returnType === undefined || typeof returnType === 'string') + ); +} + +/** Checks whether `value` is a `DataBinding` or a `FunctionCall`. */ +export function isDynamicExpression(value: unknown): value is DynamicExpression { + return isDataBinding(value) || isFunctionCall(value); +} + +/** + * Recursively resolves all dynamic expressions (`DataBinding` and `FunctionCall`) + * nested within `value`. + * + * Traverses arrays and plain objects while preserving their structure, and + * repeatedly resolves chained expressions until a concrete value is reached or + * `MAX_RESOLUTION_DEPTH` is hit. Pending Promises from async functions are + * passed through as-is. + * + * @param value The value or container to resolve. + * @param context The data context used for evaluation. + * @param depth Internal recursion depth counter. + */ +export function resolveDynamicValueDeep( + value: unknown, + context: DataContext, + depth = 0, +): T { + if (depth >= MAX_RESOLUTION_DEPTH) { + return value as T; + } + if (!isRecord(value) && !Array.isArray(value)) { + return value as T; + } + if (typeof (value as {then?: unknown}).then === 'function') { + return value as T; + } + if (Array.isArray(value)) { + return value.map(item => resolveDynamicValueDeep(item, context, depth + 1)) as unknown as T; + } + if (isDataBinding(value)) { + const resolved = context.resolveDynamicValue(value); + // Stop if the binding could not be resolved to avoid an infinite loop. + if (resolved === value) { + return resolved as unknown as T; + } + return resolveDynamicValueDeep(resolved, context, depth + 1); + } + if (isFunctionCall(value)) { + // Supply default values for optional `args` and `returnType` fields before resolving. + const dynamicCall = + value.args !== undefined && value.returnType !== undefined + ? value + : { + call: value.call, + args: value.args ?? {}, + returnType: value.returnType ?? 'any', + }; + const resolved = context.resolveDynamicValue(dynamicCall); + if (resolved === value || resolved === dynamicCall) { + return resolved as unknown as T; + } + return resolveDynamicValueDeep(resolved, context, depth + 1); + } + return resolveDynamicRecord(value, context, depth) as T; +} + +/** + * Resolves each property value in a key-value record (such as function arguments). + * + * Resolves entries individually rather than evaluating the record itself as a + * dynamic expression, so records containing `path` or `call` keys are preserved + * as plain objects. + */ +export function resolveDynamicRecord( + record: Record, + context: DataContext, + depth = 0, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(record)) { + resolved[key] = resolveDynamicValueDeep(value, context, depth + 1); + } + return resolved; +} diff --git a/bridge/src/mcp/functions/callMcpTool.ts b/bridge/src/mcp/functions/callMcpTool.ts new file mode 100644 index 000000000..42ff8a1a2 --- /dev/null +++ b/bridge/src/mcp/functions/callMcpTool.ts @@ -0,0 +1,311 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + */ + +import { + A2uiExpressionError, + DynamicStringSchema, + DynamicValueSchema, + createFunctionImplementation, + type A2uiMessage, + type CreateSurfaceMessage, + type FunctionImplementation, + type SurfaceModel, +} from '@a2ui/web_core/v0_9'; +import type {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js'; +import type {CallToolResult, ReadResourceResult} from '@modelcontextprotocol/sdk/types.js'; +import {z} from 'zod/v3'; + +import {resolveDynamicRecord} from '../dynamic-values.js'; +import type {FunctionApiDefinition} from './common.js'; + +/** MIME type identifying an A2UI payload in an MCP resource. */ +export const A2UI_MIME_TYPE = 'application/a2ui+json'; + +/** Maximum number of decoded UI resources to cache per implementation. */ +const MAX_CACHED_RESOURCES = 100; + +/** Subset of the MCP `Client` or `IframeMcpClient` interface used by this catalog. */ +export interface McpToolClient { + callTool?: (params: {name: string; arguments?: Record}) => Promise; + request?: Client['request']; + readResource?: Client['readResource']; + listTools?: Client['listTools']; +} + +/** Processor interface capable of processing A2UI messages and optionally looking up surfaces. */ +export interface McpMessageProcessor { + processMessages(messages: A2uiMessage[]): void; + model?: { + getSurface?(surfaceId: string): SurfaceModel | undefined; + }; +} + +/** + * Resolves the connected MCP client for a given tool name. + * + * May return `null` or `undefined` if no client is currently available, + * which causes the tool call to fail with an error. + */ +export type McpClientResolver = ( + toolName: string, +) => McpToolClient | undefined | null | Promise; + +/** + * Function API definition for `callMcpTool`. + * + * Payloads reference tools by name; the host application is responsible for + * routing each tool name to the appropriate MCP server client. + */ +export const CallMcpToolApi: FunctionApiDefinition = { + name: 'callMcpTool', + returnType: 'any', + schema: z.object({ + name: (DynamicStringSchema as unknown as z.ZodTypeAny).describe( + 'The name of the MCP tool to execute.', + ), + arguments: z + .record(DynamicValueSchema as unknown as z.ZodTypeAny) + .optional() + .default({}) + .describe('The arguments to pass to the MCP tool.'), + }) as unknown as FunctionApiDefinition['schema'], +}; + +/** + * Creates the `callMcpTool` function implementation. + * + * Invokes the named MCP tool, processes any referenced or inline `application/a2ui+json` + * resources through `processor`, and returns the raw `CallToolResult`. + * + * @param getMcpClientForTool Callback that resolves the MCP client for a tool name. + * @param processor Message processor that receives A2UI messages decoded from tool results. + */ +export function createCallMcpToolImplementation( + getMcpClientForTool: McpClientResolver, + processor: McpMessageProcessor, +): FunctionImplementation { + const declaredUiResourceUris = new WeakMap>>(); + const cachedA2uiResources = new Map(); + + async function readA2uiResource(client: McpToolClient, uri: string): Promise { + const cached = cachedA2uiResources.get(uri); + if (cached !== undefined) { + cachedA2uiResources.delete(uri); + cachedA2uiResources.set(uri, cached); + return cached; + } + + if (!client.readResource) { + return []; + } + const resource = await client.readResource({uri}); + const messages = parseA2uiMessages(resource, uri); + if (cachedA2uiResources.size >= MAX_CACHED_RESOURCES) { + const oldestKey = cachedA2uiResources.keys().next().value; + if (oldestKey !== undefined) { + cachedA2uiResources.delete(oldestKey); + } + } + cachedA2uiResources.set(uri, messages); + return messages; + } + + function getDeclaredUiResourceUris(client: McpToolClient): Promise> { + const cached = declaredUiResourceUris.get(client); + if (cached) { + return cached; + } + const discovery = (async () => { + const uris = new Map(); + if (!client.listTools) { + return uris; + } + try { + const res = await client.listTools(); + const tools = res?.tools ?? []; + for (const tool of tools) { + const toolUris = readUiResourceUris(tool); + if (toolUris.length > 0) { + uris.set(tool.name, toolUris); + } + } + } catch (err) { + console.warn('Could not query MCP tool UI resources:', err); + } + return uris; + })(); + declaredUiResourceUris.set(client, discovery); + return discovery; + } + + return createFunctionImplementation( + CallMcpToolApi as Parameters[0], + async (args, context) => { + const toolName = context.resolveDynamicValue(args['name']); + + try { + const resolvedArguments = resolveDynamicRecord( + (args['arguments'] as Record | undefined) ?? {}, + context, + ); + + const client = await getMcpClientForTool(toolName); + if (!client) { + throw new Error(`MCP client for tool '${toolName}' could not be resolved.`); + } + let result: CallToolResult; + if (typeof client.callTool === 'function') { + result = (await client.callTool({ + name: toolName, + arguments: resolvedArguments, + })) as CallToolResult; + } else if (typeof client.request === 'function') { + result = await client.request( + {method: 'tools/call', params: {name: toolName, arguments: resolvedArguments}}, + CallToolResultSchema, + {onprogress: () => {}, resetTimeoutOnProgress: true}, + ); + } else { + throw new Error(`MCP client for tool '${toolName}' does not support tool calls.`); + } + + if (!result) { + throw new Error(`MCP tool '${toolName}' did not return a result.`); + } + if (result.isError) { + throw new Error(`MCP tool '${toolName}' execution failed: ${JSON.stringify(result)}`); + } + + // Prefer resource URIs from the tool result, falling back to tool-declared URIs. + const named = readUiResourceUris(result); + const uris = + named.length > 0 + ? named + : ((await getDeclaredUiResourceUris(client)).get(toolName) ?? []); + for (const uri of uris) { + const resourceMessages = await readA2uiResource(client, uri); + if (!createsExistingSurface(resourceMessages, processor)) { + processor.processMessages(resourceMessages); + } + } + + const messages = extractA2uiMessages(result.content); + if (messages.length > 0) { + processor.processMessages(messages); + } + + return result; + } catch (error: unknown) { + if (error instanceof A2uiExpressionError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new A2uiExpressionError( + `Failed to execute MCP tool '${toolName}': ${message}`, + 'callMcpTool', + error, + ); + } + }, + ); +} + +/** + * Extracts unique string URIs from `_meta.ui.resourceUri` on a tool descriptor or result. + * Accepts either a single URI string or an array of URI strings. + */ +export function readUiResourceUris(source: {_meta?: unknown} | undefined): string[] { + const meta = source?._meta as {ui?: {resourceUri?: unknown}} | undefined; + const declared = meta?.ui?.resourceUri; + const uris = Array.isArray(declared) ? declared : [declared]; + return [...new Set(uris.filter((uri): uri is string => typeof uri === 'string' && uri !== ''))]; +} + +/** + * Extracts inline A2UI messages from `CallToolResult.content` blocks. + * + * Only embedded resource blocks with `mimeType: "application/a2ui+json"` are + * parsed; plain text blocks are ignored. + * + * @throws Error if an A2UI resource block contains invalid JSON. + */ +export function extractA2uiMessages(content: CallToolResult['content'] | undefined): A2uiMessage[] { + const messages: A2uiMessage[] = []; + for (const item of content ?? []) { + const block = item as { + type?: string; + resource?: {uri?: string; mimeType?: string; text?: unknown}; + }; + if (block?.type !== 'resource' || block.resource?.mimeType !== A2UI_MIME_TYPE) { + continue; + } + + const {text, uri} = block.resource; + if (typeof text !== 'string') { + continue; + } + + let parsed: A2uiMessage | A2uiMessage[]; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Resource ${uri} declares ${A2UI_MIME_TYPE} but does not hold valid JSON.`); + } + messages.push(...(Array.isArray(parsed) ? parsed : [parsed])); + } + return messages; +} + +/** + * Parses A2UI messages from a `resources/read` response for all content blocks + * matching `application/a2ui+json`. + * + * @throws Error if an A2UI content block contains invalid JSON. + */ +export function parseA2uiMessages( + resource: ReadResourceResult | undefined, + uri: string, +): A2uiMessage[] { + const texts = (resource?.contents ?? []) + .filter(content => content.mimeType === A2UI_MIME_TYPE) + .map(content => (content as {text?: unknown}).text) + .filter((text): text is string => typeof text === 'string'); + return texts.flatMap(text => { + let parsed: A2uiMessage | A2uiMessage[]; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Resource ${uri} declares ${A2UI_MIME_TYPE} but does not hold valid JSON.`); + } + return Array.isArray(parsed) ? parsed : [parsed]; + }); +} + +/** Checks whether any message attempts to create a surface that already exists in `processor`. */ +function createsExistingSurface(messages: A2uiMessage[], processor: McpMessageProcessor): boolean { + return messages.some(message => { + if (!message || typeof message !== 'object' || !('createSurface' in message)) { + return false; + } + const surfaceId = (message as CreateSurfaceMessage).createSurface?.surfaceId; + return !!surfaceId && !!processor.model?.getSurface?.(surfaceId); + }); +} diff --git a/bridge/src/mcp/functions/common.ts b/bridge/src/mcp/functions/common.ts new file mode 100644 index 000000000..77692ee82 --- /dev/null +++ b/bridge/src/mcp/functions/common.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + * + * Shared utilities for MCP catalog data functions. + * + * Because A2UI resolves arguments before invoking a function, nested async + * calls (like `callMcpTool`) arrive as pending Promises. Data functions use + * `asyncable` and `withSettledArgs` to accept and await Promise arguments + * automatically while remaining synchronous when all arguments are already + * resolved. + */ + +import {A2uiExpressionError, type createFunctionImplementation} from '@a2ui/web_core/v0_9'; +import {RE2JS} from 're2js'; +import {z} from 'zod/v3'; + +export interface FunctionApiDefinition { + name: string; + returnType: Parameters[0]['returnType']; + schema: unknown; + description?: string; +} + +/** Checks whether a value is a Promise-like object. */ +export function isThenable(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + typeof (value as {then?: unknown}).then === 'function' + ); +} + +/** + * Widens a Zod schema to accept either a resolved value or a pending Promise. + */ +export function asyncable(schema: T): z.ZodTypeAny { + return z.union([schema, z.custom>(isThenable)]); +} + +/** Checks whether a value is a plain object (not an array, null, or class instance). */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Recursively awaits all Promises nested inside `value`. + * + * Returns `value` synchronously if no nested Promises are found. + */ +function settleDeep(value: unknown): unknown | Promise { + if (isThenable(value)) { + return Promise.resolve(value).then(settleDeep); + } + if (Array.isArray(value)) { + const items = value.map(settleDeep); + return items.some(isThenable) ? Promise.all(items) : value; + } + if (isPlainObject(value)) { + const entries = Object.entries(value).map(([key, item]) => [key, settleDeep(item)] as const); + if (!entries.some(([, item]) => isThenable(item))) { + return value; + } + return Promise.all(entries.map(([, item]) => item)).then(settled => + Object.fromEntries(entries.map(([key], i) => [key, settled[i]])), + ); + } + return value; +} + +/** + * Executes `body` once all arguments have resolved. + * + * Runs synchronously if no arguments contain Promises, preserving reactive bindings, + * or returns a Promise if any argument is asynchronous. + */ +export function withSettledArgs( + args: Record, + body: (settled: Record) => T, +): T | Promise { + const settled = settleDeep(args); + return isThenable(settled) + ? Promise.resolve(settled).then(value => body(value as Record)) + : body(settled as Record); +} + +/** Maximum number of compiled regex patterns to cache. */ +const MAX_CACHED_PATTERNS = 100; + +const compiledPatterns = new Map>(); + +/** + * Compiles and caches an RE2 regular expression. + * + * @param source The regular expression string to compile. + * @param fn The name of the calling function for error reporting. + * @throws A2uiExpressionError if the pattern is invalid. + */ +export function pattern(source: string, fn: string): ReturnType { + let compiled = compiledPatterns.get(source); + if (!compiled) { + try { + compiled = RE2JS.compile(source); + } catch (error) { + throw new A2uiExpressionError( + `Invalid RE2 pattern '${source}': ${error instanceof Error ? error.message : error}`, + fn, + 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 a single string or maps it across an array of strings. + * + * Converts null and undefined values to empty strings. + */ +export function overValue(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/bridge/src/mcp/functions/jmespath.ts b/bridge/src/mcp/functions/jmespath.ts new file mode 100644 index 000000000..f881fc72b --- /dev/null +++ b/bridge/src/mcp/functions/jmespath.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + */ + +import { + A2uiExpressionError, + createFunctionImplementation, + type FunctionImplementation, +} from '@a2ui/web_core/v0_9'; +import {search as searchJmespath} from 'jmespath'; +import {z} from 'zod/v3'; + +import {resolveDynamicValueDeep} from '../dynamic-values.js'; +import {asyncable, type FunctionApiDefinition, withSettledArgs} from './common.js'; + +/** + * Function API definition for `jmespath`, using standard JMESPath syntax + * (https://jmespath.org). + */ +export const JmespathApi: FunctionApiDefinition = { + name: 'jmespath', + returnType: 'any', + schema: z.object({ + expression: asyncable(z.any()).describe( + 'A standard JMESPath expression evaluated against `data`.', + ), + data: asyncable(z.any()).describe('The input data to evaluate the expression against.'), + }) as unknown as FunctionApiDefinition['schema'], +}; + +/** + * Evaluates a JMESPath expression against `data` and returns the result. + * + * Deeply resolves dynamic values in both `expression` and `data` before evaluation. + * Returns `null` if a queried field does not exist. + * + * @throws A2uiExpressionError if `expression` is not a valid JMESPath string. + */ +export const JmespathImplementation: FunctionImplementation = createFunctionImplementation( + JmespathApi as Parameters[0], + (args, context) => { + const document = resolveDynamicValueDeep(args['data'], context); + const expression = resolveDynamicValueDeep(args['expression'], context); + return withSettledArgs({expression, data: document}, settled => { + const expr = settled['expression']; + if (typeof expr !== 'string') { + const kind = expr === null ? 'null' : Array.isArray(expr) ? 'an array' : `a ${typeof expr}`; + throw new A2uiExpressionError( + `jmespath expects a string expression, got ${kind}.`, + 'jmespath', + ); + } + try { + return searchJmespath(settled['data'] ?? null, expr); + } catch (error) { + throw new A2uiExpressionError( + `${error instanceof Error ? error.message : String(error)} in JMESPath expression: ${expr}`, + 'jmespath', + error, + ); + } + }); + }, +); diff --git a/bridge/src/mcp/functions/regexCapture.ts b/bridge/src/mcp/functions/regexCapture.ts new file mode 100644 index 000000000..9ef2c7300 --- /dev/null +++ b/bridge/src/mcp/functions/regexCapture.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + */ + +import {createFunctionImplementation, type FunctionImplementation} from '@a2ui/web_core/v0_9'; +import {z} from 'zod/v3'; + +import { + asyncable, + type FunctionApiDefinition, + overValue, + pattern, + withSettledArgs, +} from './common.js'; + +/** Function API definition for `regexCapture`. */ +export const RegexCaptureApi: FunctionApiDefinition = { + name: 'regexCapture', + returnType: 'any', + schema: z.object({ + value: asyncable(z.any()).describe('The string or array of strings to match against.'), + pattern: asyncable(z.any()).describe( + 'An RE2 regular expression. Returns an array of capture groups from the first match, or null if there is no match. Unmatched optional groups return empty strings.', + ), + }) as unknown as FunctionApiDefinition['schema'], +}; + +/** + * Matches a string (or each element of an array of strings) against an RE2 pattern + * and returns the capture groups from the first match, or `null` if no match is found. + * + * @throws A2uiExpressionError if `pattern` is not a valid RE2 regular expression. + */ +export const RegexCaptureImplementation: FunctionImplementation = createFunctionImplementation( + RegexCaptureApi as Parameters[0], + args => + withSettledArgs(args, settled => { + const compiled = pattern(String(settled['pattern'] ?? ''), 'regexCapture'); + return overValue(settled['value'], item => { + const matcher = compiled.matcher(item); + if (!matcher.find()) { + return null; + } + const groups: string[] = []; + for (let i = 1; i <= matcher.groupCount(); i++) { + groups.push(matcher.group(i) ?? ''); + } + return groups; + }); + }), +); diff --git a/bridge/src/mcp/functions/regexReplace.ts b/bridge/src/mcp/functions/regexReplace.ts new file mode 100644 index 000000000..cd859e18b --- /dev/null +++ b/bridge/src/mcp/functions/regexReplace.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + */ + +import {createFunctionImplementation, type FunctionImplementation} from '@a2ui/web_core/v0_9'; +import {RE2JS} from 're2js'; +import {z} from 'zod/v3'; + +import { + asyncable, + type FunctionApiDefinition, + overValue, + pattern, + withSettledArgs, +} from './common.js'; + +/** Function API definition for `regexReplace`. */ +export const RegexReplaceApi: FunctionApiDefinition = { + name: 'regexReplace', + returnType: 'any', + schema: z.object({ + value: asyncable(z.any()).describe( + 'The string or array of strings to perform replacements on.', + ), + pattern: asyncable(z.any()).describe( + 'An RE2 regular expression matching substrings to replace.', + ), + replacement: asyncable(z.any()).describe( + 'Literal replacement string. Capture group references like $1 are treated as literal text.', + ), + }) as unknown as FunctionApiDefinition['schema'], +}; + +/** + * Replaces all matches of an RE2 pattern with literal replacement text. + * + * Replacement text is escaped so `$1` and similar sequences are inserted literally + * rather than interpreted as capture group references. + * + * @throws A2uiExpressionError if `pattern` is not a valid RE2 regular expression. + */ +export const RegexReplaceImplementation: FunctionImplementation = createFunctionImplementation( + RegexReplaceApi as Parameters[0], + args => + withSettledArgs(args, settled => { + const compiled = pattern(String(settled['pattern'] ?? ''), 'regexReplace'); + const replacement = RE2JS.quoteReplacement(String(settled['replacement'] ?? '')); + return overValue(settled['value'], item => compiled.matcher(item).replaceAll(replacement)); + }), +); diff --git a/bridge/src/mcp/functions/split.ts b/bridge/src/mcp/functions/split.ts new file mode 100644 index 000000000..0e4a82045 --- /dev/null +++ b/bridge/src/mcp/functions/split.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + */ + +import {createFunctionImplementation, type FunctionImplementation} from '@a2ui/web_core/v0_9'; +import {z} from 'zod/v3'; + +import {asyncable, type FunctionApiDefinition, overValue, withSettledArgs} from './common.js'; + +/** Function API definition for `split`. */ +export const SplitApi: FunctionApiDefinition = { + name: 'split', + returnType: 'any', + schema: z.object({ + value: asyncable(z.any()).describe('The string or array of strings to split.'), + separator: asyncable(z.any()).describe( + 'The delimiter string to split on. An empty string splits into individual characters.', + ), + }) as unknown as FunctionApiDefinition['schema'], +}; + +/** + * Splits a string into an array of substrings using a literal separator, or maps + * the split operation across each element of an array of strings. + */ +export const SplitImplementation: FunctionImplementation = createFunctionImplementation( + SplitApi as Parameters[0], + args => + withSettledArgs(args, settled => { + const separator = String(settled['separator'] ?? ''); + return overValue(settled['value'], item => item.split(separator)); + }), +); diff --git a/bridge/src/mcp/functions/updateDataModel.ts b/bridge/src/mcp/functions/updateDataModel.ts new file mode 100644 index 000000000..458a57b69 --- /dev/null +++ b/bridge/src/mcp/functions/updateDataModel.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this file with imports from `@a2ui/mcp-catalog/v0_9` once published to NPM. + */ + +import { + A2uiExpressionError, + createFunctionImplementation, + type FunctionImplementation, +} from '@a2ui/web_core/v0_9'; +import {z} from 'zod/v3'; + +import {resolveDynamicValueDeep} from '../dynamic-values.js'; +import {asyncable, type FunctionApiDefinition, withSettledArgs} from './common.js'; + +/** Function API definition for `updateDataModel`. */ +export const UpdateDataModelApi: FunctionApiDefinition = { + name: 'updateDataModel', + returnType: 'any', + schema: z.object({ + updates: asyncable(z.any()).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.', + ), + }) as unknown as FunctionApiDefinition['schema'], +}; + +/** + * Writes multiple path-value pairs to the surface data model. + * + * Ignores `null` or `undefined` updates without throwing an error. + * + * @throws A2uiExpressionError if `updates` is not a plain object, `null`, or `undefined`. + */ +export const UpdateDataModelImplementation: FunctionImplementation = createFunctionImplementation( + UpdateDataModelApi as Parameters[0], + (args, context) => { + const requested = resolveDynamicValueDeep(args['updates'], context); + return withSettledArgs({updates: requested}, settled => { + const updates = settled['updates']; + if (updates === null || updates === undefined) { + return; + } + if (typeof updates !== 'object' || Array.isArray(updates)) { + const kind = Array.isArray(updates) ? 'an array' : `a ${typeof updates}`; + throw new A2uiExpressionError( + `updateDataModel expects an object of data model paths, got ${kind}.`, + 'updateDataModel', + ); + } + for (const [path, value] of Object.entries(updates as Record)) { + // Clone objects and arrays to prevent mutations from affecting cached tool results. + context.set( + path, + typeof value === 'object' && value !== null ? structuredClone(value) : value, + ); + } + }); + }, +); diff --git a/bridge/src/mcp/index.ts b/bridge/src/mcp/index.ts new file mode 100644 index 000000000..4e7bf482d --- /dev/null +++ b/bridge/src/mcp/index.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2026 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. + */ + +/** + * TODO: Replace this module with `@a2ui/mcp-catalog/v0_9` once published to NPM. + * See README.md in this directory for details and the migration plan. + */ + +import type {FunctionImplementation} from '@a2ui/web_core/v0_9'; +import { + createCallMcpToolImplementation, + type McpClientResolver, + type McpMessageProcessor, +} from './functions/callMcpTool.js'; +import type {FunctionApiDefinition} from './functions/common.js'; +import {JmespathApi, JmespathImplementation} from './functions/jmespath.js'; +import {RegexCaptureApi, RegexCaptureImplementation} from './functions/regexCapture.js'; +import {RegexReplaceApi, RegexReplaceImplementation} from './functions/regexReplace.js'; +import {SplitApi, SplitImplementation} from './functions/split.js'; +import {UpdateDataModelApi, UpdateDataModelImplementation} from './functions/updateDataModel.js'; + +/** Identifier of the catalog these functions implement. */ +export const MCP_CATALOG_ID = 'https://a2ui.org/specification/v0_9/catalogs/mcp/mcp_catalog.json'; + +/** + * API definitions and schemas for the catalog's data transformation functions. + */ +export const DATA_FUNCTION_APIS: readonly FunctionApiDefinition[] = [ + JmespathApi, + SplitApi, + RegexCaptureApi, + RegexReplaceApi, + UpdateDataModelApi, +]; + +/** Function implementations for the catalog's data transformation functions. */ +export const DATA_FUNCTIONS: FunctionImplementation[] = [ + JmespathImplementation, + SplitImplementation, + RegexCaptureImplementation, + RegexReplaceImplementation, + UpdateDataModelImplementation, +]; + +/** + * Creates all functions defined in the MCP catalog, including `callMcpTool` + * and the data transformation functions. + * Supports both `(processor, getMcpClientForTool)` and `(getMcpClientForTool, processor)` parameter orders. + */ +export function createMcpCatalogFunctions( + processor: McpMessageProcessor, + getMcpClientForTool: McpClientResolver, +): FunctionImplementation[]; +export function createMcpCatalogFunctions( + getMcpClientForTool: McpClientResolver, + processor: McpMessageProcessor, +): FunctionImplementation[]; +export function createMcpCatalogFunctions( + first: McpClientResolver | McpMessageProcessor, + second: McpClientResolver | McpMessageProcessor, +): FunctionImplementation[] { + if (typeof first === 'function') { + return [ + createCallMcpToolImplementation(first as McpClientResolver, second as McpMessageProcessor), + ...DATA_FUNCTIONS, + ]; + } + return [ + createCallMcpToolImplementation(second as McpClientResolver, first as McpMessageProcessor), + ...DATA_FUNCTIONS, + ]; +} + +export { + A2UI_MIME_TYPE, + CallMcpToolApi, + createCallMcpToolImplementation, + extractA2uiMessages, + parseA2uiMessages, + readUiResourceUris, + type McpClientResolver, + type McpMessageProcessor, + type McpToolClient, +} from './functions/callMcpTool.js'; + +export {JmespathApi, JmespathImplementation} from './functions/jmespath.js'; +export {RegexCaptureApi, RegexCaptureImplementation} from './functions/regexCapture.js'; +export {RegexReplaceApi, RegexReplaceImplementation} from './functions/regexReplace.js'; +export {SplitApi, SplitImplementation} from './functions/split.js'; +export {UpdateDataModelApi, UpdateDataModelImplementation} from './functions/updateDataModel.js'; +export {isThenable} from './functions/common.js'; diff --git a/bridge/src/mcp/mcp.spec.ts b/bridge/src/mcp/mcp.spec.ts new file mode 100644 index 000000000..0a17feec9 --- /dev/null +++ b/bridge/src/mcp/mcp.spec.ts @@ -0,0 +1,356 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +/** + * TODO: Replace with upstream test coverage from `@a2ui/mcp-catalog` once published to NPM. + */ + +import {describe, it, expect, vi} from 'vitest'; +import {Catalog, MessageProcessor, type ComponentApi} from '@a2ui/web_core/v0_9'; +import { + createMcpCatalogFunctions, + PreviewBridge, + PreviewBridgeMessageType, + extractA2uiMessages, + parseA2uiMessages, + readUiResourceUris, + A2UI_MIME_TYPE, +} from '../index.js'; +import {resolveDynamicValueDeep} from './dynamic-values.js'; +import {withSettledArgs} from './functions/common.js'; + +describe('MCP Bridge & Catalog Functions', () => { + describe('resolveDynamicValueDeep & withSettledArgs', () => { + it('resolves nested DataBindings, FunctionCalls, arrays, and enforces depth limit', () => { + const mockContext = { + resolveDynamicValue: (v: unknown) => { + if (typeof v === 'object' && v !== null && 'path' in v) { + return `resolved:${(v as {path: string}).path}`; + } + if (typeof v === 'object' && v !== null && 'call' in v) { + return `called:${(v as {call: string}).call}`; + } + return v; + }, + } as never; + + expect(resolveDynamicValueDeep('primitive', mockContext)).toBe('primitive'); + expect(resolveDynamicValueDeep(null, mockContext)).toBeNull(); + expect(resolveDynamicValueDeep({path: '/user/name'}, mockContext)).toBe( + 'resolved:/user/name', + ); + expect(resolveDynamicValueDeep({call: 'formatString', args: {val: 'x'}}, mockContext)).toBe( + 'called:formatString', + ); + expect( + resolveDynamicValueDeep( + { + nested: [{path: '/a'}, {b: {call: 'fn', args: {}}}], + }, + mockContext, + ), + ).toEqual({ + nested: ['resolved:/a', {b: 'called:fn'}], + }); + + // Depth > 10 stops recursion + let deepObj: unknown = {path: '/too-deep'}; + for (let i = 0; i < 12; i++) { + deepObj = {child: deepObj}; + } + const resolvedDeep = resolveDynamicValueDeep(deepObj, mockContext); + expect(resolvedDeep).toBeDefined(); + }); + + it('settles nested Promise arguments in withSettledArgs', async () => { + const result = await withSettledArgs( + { + directPromise: Promise.resolve('done'), + nestedList: [Promise.resolve(1), {deep: Promise.resolve('ok')}], + }, + settled => settled, + ); + expect(result).toEqual({ + directPromise: 'done', + nestedList: [1, {deep: 'ok'}], + }); + }); + }); + + describe('createMcpCatalogFunctions & Data Functions', () => { + it('evaluates jmespath, split, regexCapture, regexReplace, updateDataModel, and callMcpTool', async () => { + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + content: [{type: 'text', text: 'alpha,beta,gamma'}], + }), + }; + + const procRef: {current?: MessageProcessor} = {}; + const mcpFunctions = createMcpCatalogFunctions( + { + processMessages: msgs => procRef.current?.processMessages(msgs), + }, + async () => mockClient, + ); + + const catalog = new Catalog( + 'https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json', + [], + mcpFunctions, + ); + const processor = new MessageProcessor([catalog], () => {}); + procRef.current = processor; + + processor.processMessages([ + { + version: 'v0.9', + createSurface: { + surfaceId: 's1', + catalogId: 'https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json', + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 's1', + value: { + raw: 'item-123', + items: 'one|two|three', + nested: {a: {b: 'found'}}, + }, + }, + }, + ]); + + const surface = processor.model.getSurface('s1')!; + expect(surface).toBeDefined(); + + const splitFn = mcpFunctions.find(f => f.name === 'split')!; + const jmesFn = mcpFunctions.find(f => f.name === 'jmespath')!; + const captureFn = mcpFunctions.find(f => f.name === 'regexCapture')!; + const replaceFn = mcpFunctions.find(f => f.name === 'regexReplace')!; + + const mockCtx = { + resolveDynamicValue: (v: unknown) => v, + set: (path: string, val: unknown) => surface.dataModel.set(path, val), + } as unknown as Parameters[1]; + + expect(splitFn.execute({value: 'a,b,c', separator: ','}, mockCtx)).toEqual(['a', 'b', 'c']); + expect(jmesFn.execute({expression: 'a.b', data: {a: {b: 'found'}}}, mockCtx)).toBe('found'); + expect(() => jmesFn.execute({expression: 'invalid[[[', data: {}}, mockCtx)).toThrow( + /JMESPath expression/, + ); + expect(captureFn.execute({value: 'user-42', pattern: 'user-(\\d+)'}, mockCtx)).toEqual([ + '42', + ]); + expect(captureFn.execute({value: 'no-match', pattern: 'user-(\\d+)'}, mockCtx)).toBeNull(); + expect( + replaceFn.execute({value: 'hello world', pattern: 'world', replacement: 'MCP'}, mockCtx), + ).toBe('hello MCP'); + + const updateFn = mcpFunctions.find(f => f.name === 'updateDataModel')!; + updateFn.execute({updates: {'/title': 'Updated'}}, mockCtx); + expect(surface.dataModel.get('/title')).toBe('Updated'); + expect(() => + updateFn.execute({updates: {'/a': 1}}, {resolveDynamicValue: (v: unknown) => v} as never), + ).toThrow(/context\.set is not a function/); + + const callMcpFn = mcpFunctions.find(f => f.name === 'callMcpTool')!; + const toolRes = await callMcpFn.execute({name: 'list_dir', arguments: {path: '/'}}, mockCtx); + expect(mockClient.callTool).toHaveBeenCalledWith({ + name: 'list_dir', + arguments: {path: '/'}, + }); + expect(toolRes).toEqual({ + content: [{type: 'text', text: 'alpha,beta,gamma'}], + }); + }); + + it('supports callMcpTool with client.request, listTools, readResource, and error handling', async () => { + const processedMsgs: unknown[] = []; + const mockToolResult = { + content: [ + { + type: 'resource', + resource: { + uri: 'ui://inline', + mimeType: A2UI_MIME_TYPE, + text: JSON.stringify({ + version: 'v0.9', + deleteSurface: {surfaceId: 'inline-surface'}, + }), + }, + }, + ], + }; + const fullClient = { + request: vi.fn().mockResolvedValue(mockToolResult), + listTools: vi.fn().mockResolvedValue({ + tools: [ + { + name: 'rich_tool', + _meta: {ui: {resourceUri: 'ui://tool-res'}}, + }, + ], + }), + readResource: vi.fn().mockResolvedValue({ + contents: [ + { + uri: 'ui://tool-res', + mimeType: A2UI_MIME_TYPE, + text: JSON.stringify([ + {version: 'v0.9', deleteSurface: {surfaceId: 'fetched-surface'}}, + ]), + }, + ], + }), + }; + + const mcpFunctions = createMcpCatalogFunctions( + { + processMessages: msgs => processedMsgs.push(...msgs), + }, + async toolName => { + if (toolName === 'broken_tool') { + throw new Error('Server offline'); + } + return fullClient; + }, + ); + + const callMcpFn = mcpFunctions.find(f => f.name === 'callMcpTool')!; + const mockCtx = { + resolveDynamicValue: (v: unknown) => v, + } as never; + + const res = await callMcpFn.execute({name: 'rich_tool', arguments: {x: 1}}, mockCtx); + expect(res).toBeDefined(); + expect(fullClient.request).toHaveBeenCalled(); + expect(fullClient.listTools).toHaveBeenCalled(); + expect(fullClient.readResource).toHaveBeenCalledTimes(1); + expect(processedMsgs.length).toBeGreaterThanOrEqual(2); + + await expect( + callMcpFn.execute({name: 'broken_tool', arguments: {}}, mockCtx), + ).rejects.toThrow(/Server offline/); + }); + + it('parses A2UI resources and UI resource URIs', async () => { + expect( + readUiResourceUris({_meta: {ui: {resourceUri: ['ui://a', 'ui://a', 'ui://b']}}}), + ).toEqual(['ui://a', 'ui://b']); + expect(readUiResourceUris(null)).toEqual([]); + + const inlineMsgs = extractA2uiMessages([ + { + type: 'resource', + resource: { + uri: 'ui://test', + mimeType: A2UI_MIME_TYPE, + text: JSON.stringify({ + version: 'v0.9', + deleteSurface: {surfaceId: 's1'}, + }), + }, + } as never, + ]); + expect(inlineMsgs).toEqual([{version: 'v0.9', deleteSurface: {surfaceId: 's1'}}]); + + const parsedMsgs = parseA2uiMessages( + { + contents: [ + { + uri: 'ui://res', + mimeType: A2UI_MIME_TYPE, + text: JSON.stringify([{version: 'v0.9', deleteSurface: {surfaceId: 's2'}}]), + }, + ], + }, + 'ui://res', + ); + expect(parsedMsgs).toEqual([{version: 'v0.9', deleteSurface: {surfaceId: 's2'}}]); + + const localProcessed: unknown[] = []; + const nullToolsClient = { + listTools: vi.fn().mockResolvedValue(undefined), + callTool: vi.fn().mockResolvedValue({ + content: [ + { + type: 'resource', + resource: { + uri: 'ui://prim', + mimeType: A2UI_MIME_TYPE, + text: JSON.stringify([ + 'primitive-entry', + {version: 'v0.9', createSurface: {surfaceId: 's3'}}, + ]), + }, + }, + ], + }), + }; + const fnNullTools = createMcpCatalogFunctions( + {processMessages: msgs => localProcessed.push(msgs)}, + async () => nullToolsClient as never, + ).find(f => f.name === 'callMcpTool')!; + const mockCtx = { + surfaceId: 'surf-1', + dataModel: {set: vi.fn()}, + resolveDynamicValue: (v: T) => v, + } as never; + await fnNullTools.execute({name: 'any_tool'}, mockCtx); + expect(localProcessed).toContainEqual([ + 'primitive-entry', + {version: 'v0.9', createSurface: {surfaceId: 's3'}}, + ]); + }); + }); + + describe('PreviewBridge MCP integration', () => { + it('routes MCP_REQUEST and MCP_RESPONSE across PreviewBridge', async () => { + const bridge = new PreviewBridge(); + const sendSpy = vi.spyOn(bridge, 'sendMessage'); + + const client = bridge.getMcpClient(); + expect(bridge.getMcpClient()).toBe(client); + + const callPromise = client.callTool({name: 'read_file', arguments: {path: '/a'}}); + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: PreviewBridgeMessageType.MCP_REQUEST, + }), + ); + const sentRequest = sendSpy.mock.lastCall![0].payload as {requestId: string}; + + window.dispatchEvent( + new MessageEvent('message', { + origin: window.location.origin, + source: window.parent, + data: { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: { + requestId: sentRequest.requestId, + result: {ok: true}, + }, + }, + }), + ); + + await expect(callPromise).resolves.toEqual({ok: true}); + bridge.destroy(); + }); + }); +}); diff --git a/bridge/src/preview-bridge.ts b/bridge/src/preview-bridge.ts index 39dc5fdb8..3126396d7 100644 --- a/bridge/src/preview-bridge.ts +++ b/bridge/src/preview-bridge.ts @@ -32,8 +32,11 @@ import { CreateSurfaceCommand, CatalogDetails, ThemePreference, + McpResponsePayload, } from './bridge-message'; +import {IframeMcpClient} from './iframe-mcp-client'; + import {SurfaceResizeObserver} from './surface-resize-observer'; export * from './surface-resize-observer'; @@ -220,6 +223,31 @@ export class PreviewBridge { private readonly cachedParentOrigin: string | null = null; + /** Cached iframe MCP proxy client. */ + private mcpClient: IframeMcpClient | null = null; + + /** + * Returns the IframeMcpClient proxy, creating and caching it if needed. + */ + getMcpClient(): IframeMcpClient { + if (!this.mcpClient) { + this.mcpClient = new IframeMcpClient(payload => { + this.sendMessage({ + type: PreviewBridgeMessageType.MCP_REQUEST, + payload, + }); + }); + } + return this.mcpClient; + } + + /** + * Returns the currently attached renderer processor, if any. + */ + getActiveProcessor(): RendererProcessor | undefined { + return this.activeRenderer?.processor; + } + /** * Initializes a new PreviewBridge instance. * Sets up the global window message listener, observes layout dimensions, and applies initial theme from URL if present. @@ -386,6 +414,7 @@ export class PreviewBridge { } } this.activeConnections.clear(); + this.mcpClient = null; } private resolveExpectedParentOrigin(): string { @@ -475,6 +504,10 @@ export class PreviewBridge { void this.handleGetComponentUsages(); break; + case PreviewBridgeMessageType.MCP_RESPONSE: + this.handleMcpResponse(data.payload); + break; + case PreviewBridgeMessageType.SET_THEME: this.handleSetTheme(data.payload); break; @@ -484,6 +517,15 @@ export class PreviewBridge { } }; + /** + * Routes incoming MCP_RESPONSE messages to the IframeMcpClient pending request. + */ + private handleMcpResponse(payload: unknown): void { + const payloadObj = payload as McpResponsePayload | undefined; + if (!payloadObj || typeof payloadObj.requestId !== 'string') return; + this.mcpClient?.handleResponse(payloadObj); + } + /** * Handles incoming theme change requests. */ @@ -633,7 +675,15 @@ export class PreviewBridge { } } - this.activeRenderer.processor.processMessages(payload as A2uiMessage[]); + let clonedPayload = payload as A2uiMessage[]; + if (typeof structuredClone === 'function') { + try { + clonedPayload = structuredClone(payload) as A2uiMessage[]; + } catch { + clonedPayload = payload as A2uiMessage[]; + } + } + this.activeRenderer.processor.processMessages(clonedPayload); if (hasCreateSurface && surfaceId) { this.activeRenderer.config.onSurfaceReady(surfaceId); diff --git a/eslint.config.js b/eslint.config.js index e7b35bd5b..33791777e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -94,10 +94,10 @@ export default tseslint.config( // 4. Angular Sample Catalog Rules ...angular.configs.tsRecommended.map(c => ({ ...c, - files: ['samples/ng-basic-catalog/**/*.ts'], + files: ['samples/ng-basic-catalog/**/*.ts', 'samples/ng-basic-with-mcp-catalog/**/*.ts'], })), { - files: ['samples/ng-basic-catalog/**/*.ts'], + files: ['samples/ng-basic-catalog/**/*.ts', 'samples/ng-basic-with-mcp-catalog/**/*.ts'], processor: angular.processInlineTemplates, rules: { '@angular-eslint/directive-selector': [ @@ -120,10 +120,10 @@ export default tseslint.config( }, ...angular.configs.templateRecommended.map(c => ({ ...c, - files: ['samples/ng-basic-catalog/**/*.html'], + files: ['samples/ng-basic-catalog/**/*.html', 'samples/ng-basic-with-mcp-catalog/**/*.html'], })), { - files: ['samples/ng-basic-catalog/**/*.html'], + files: ['samples/ng-basic-catalog/**/*.html', 'samples/ng-basic-with-mcp-catalog/**/*.html'], rules: {}, }, ); diff --git a/package.json b/package.json index a14538cfc..a7c309dd3 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "workspaces": [ "shell", "samples/ng-basic-catalog", + "samples/ng-basic-with-mcp-catalog", "samples/lit-basic-catalog", "samples/react-basic-catalog", "bridge" diff --git a/samples/ng-basic-with-mcp-catalog/README.md b/samples/ng-basic-with-mcp-catalog/README.md new file mode 100644 index 000000000..309b12b41 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/README.md @@ -0,0 +1,23 @@ +# Angular Basic with MCP Sample A2UI Composer Integration + +Sample application demonstrating how to integrate Angular-based renderers for the +`basic_with_mcp` catalog into the A2UI Composer. + +## Catalog Composition + +This sample combines two catalogs: + +- **Components**: Standard UI components from `@a2ui/angular/v0_9` (`BasicCatalog`). +- **Functions**: MCP tool execution and data transformation functions (`createMcpCatalogFunctions`). + +## Upstream MCP Package Status + +The MCP catalog functions are currently vendored in `bridge/src/mcp/` and +re-exported through `a2ui-bridge` because `@a2ui/mcp-catalog` has not yet been +published to NPM. + +Once `@a2ui/mcp-catalog` is published: + +1. Add `@a2ui/mcp-catalog` to `package.json`. +2. Update `src/app/basic-with-mcp.catalog.ts` to import `createMcpCatalogFunctions` + directly from `@a2ui/mcp-catalog/v0_9`. diff --git a/samples/ng-basic-with-mcp-catalog/angular.json b/samples/ng-basic-with-mcp-catalog/angular.json new file mode 100644 index 000000000..7165658ce --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/angular.json @@ -0,0 +1,48 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "ng-basic-with-mcp-catalog": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/ng-basic-with-mcp-catalog", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "baseHref": "./", + "assets": [ + "src/favicon.ico", + "src/assets", + { + "glob": "catalog.json", + "input": "src/assets", + "output": "/" + } + ], + "styles": ["src/styles.scss"], + "scripts": [] + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "buildTarget": "ng-basic-with-mcp-catalog:build", + "port": 3459 + } + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/samples/ng-basic-with-mcp-catalog/package.json b/samples/ng-basic-with-mcp-catalog/package.json new file mode 100644 index 000000000..a852ebe2d --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/package.json @@ -0,0 +1,45 @@ +{ + "name": "ng-basic-with-mcp-catalog", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "ng serve --port 3459", + "build": "yarn lint && ng build", + "test": "vitest run", + "prettier": "prettier --ignore-path ../../.prettierignore --write . | grep -v '(unchanged)'", + "prettier:check": "prettier --ignore-path ../../.prettierignore --check .", + "clean": "rm -rf dist .angular node_modules/.vite", + "lint": "eslint ." + }, + "dependencies": { + "@a2ui/angular": "0.10.4", + "@a2ui/markdown-it": "0.1.0", + "@a2ui/web_core": "0.10.5", + "@angular/animations": "22.0.7", + "@angular/common": "22.0.7", + "@angular/compiler": "22.0.7", + "@angular/core": "22.0.7", + "@angular/forms": "22.0.7", + "@angular/platform-browser": "22.0.7", + "@angular/router": "22.0.7", + "a2ui-bridge": "0.1.0", + "rxjs": "7.8.2", + "tslib": "2.8.1" + }, + "devDependencies": { + "@analogjs/vite-plugin-angular": "2.6.3", + "@angular-devkit/build-angular": "22.0.7", + "@angular/build": "22.0.7", + "@angular/cli": "22.0.7", + "@angular/compiler-cli": "22.0.7", + "@angular/platform-browser-dynamic": "22.0.7", + "@types/jsdom": "28.0.3", + "angular-eslint": "22.1.0", + "eslint": "10.7.0", + "jsdom": "29.1.1", + "prettier": "3.9.6", + "typescript": "6.0.3", + "typescript-eslint": "8.65.0", + "vitest": "4.1.11" + } +} diff --git a/samples/ng-basic-with-mcp-catalog/src/app/app.component.ng.html b/samples/ng-basic-with-mcp-catalog/src/app/app.component.ng.html new file mode 100644 index 000000000..c513d77e3 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/app/app.component.ng.html @@ -0,0 +1,25 @@ + + +
+ @if (sandbox.surfaceId()) { + + } @else { +

+ A2UI Angular Sandbox active. Waiting for RENDER_A2UI payloads... +

+ } +
diff --git a/samples/ng-basic-with-mcp-catalog/src/app/app.component.scss b/samples/ng-basic-with-mcp-catalog/src/app/app.component.scss new file mode 100644 index 000000000..cab9e8527 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/app/app.component.scss @@ -0,0 +1,17 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +/* Empty styles for app component */ diff --git a/samples/ng-basic-with-mcp-catalog/src/app/app.component.ts b/samples/ng-basic-with-mcp-catalog/src/app/app.component.ts new file mode 100644 index 000000000..fc86316dd --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/app/app.component.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2026 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 + * + * http://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 {Component, inject} from '@angular/core'; +import {SurfaceComponent} from '@a2ui/angular/v0_9'; +import {A2uiSandboxConnection} from 'a2ui-bridge/angular'; + +/** + * The component for the sandboxed renderer client application. + */ +@Component({ + selector: 'app-root', + standalone: true, + imports: [SurfaceComponent], + templateUrl: './app.component.ng.html', + styleUrl: './app.component.scss', +}) +export class AppComponent { + /** + * Leverage the library-provided provider factory to bind cross-frame window + * telemetries reactively. + */ + protected sandbox = inject(A2uiSandboxConnection); +} diff --git a/samples/ng-basic-with-mcp-catalog/src/app/basic-with-mcp.catalog.ts b/samples/ng-basic-with-mcp-catalog/src/app/basic-with-mcp.catalog.ts new file mode 100644 index 000000000..677acdb72 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/app/basic-with-mcp.catalog.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2026 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 + * + * http://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 {Injectable, inject} from '@angular/core'; +import {BasicCatalog} from '@a2ui/angular/v0_9'; +import {Catalog, ComponentApi, FunctionImplementation} from '@a2ui/web_core/v0_9'; +// TODO: Replace import from 'a2ui-bridge' with '@a2ui/mcp-catalog/v0_9' once published to NPM. +import {a2uiBridge, createMcpCatalogFunctions} from 'a2ui-bridge'; + +export const BASIC_WITH_MCP_CATALOG_ID = + 'https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json'; + +function extractCatalogEntries(collection?: ReadonlyMap | Record): T[] { + if (!collection) return []; + if (typeof (collection as ReadonlyMap).values === 'function') { + return Array.from((collection as ReadonlyMap).values()); + } + return Object.values(collection); +} + +/** + * Angular catalog combining the standard BasicCatalog components and functions + * with the A2UI Model Context Protocol (MCP) catalog functions. + */ +@Injectable() +export class BasicWithMcpCatalog extends Catalog { + constructor() { + const basicCatalog = inject(BasicCatalog); + const baseComponents = extractCatalogEntries(basicCatalog.components); + const baseFunctions = extractCatalogEntries(basicCatalog.functions); + const mcpFunctions = createMcpCatalogFunctions( + { + processMessages: msgs => { + a2uiBridge.getActiveProcessor()?.processMessages(msgs); + }, + }, + async () => a2uiBridge.getMcpClient(), + ); + super(BASIC_WITH_MCP_CATALOG_ID, baseComponents, [...baseFunctions, ...mcpFunctions]); + } +} diff --git a/samples/ng-basic-with-mcp-catalog/src/assets/catalog.json b/samples/ng-basic-with-mcp-catalog/src/assets/catalog.json new file mode 100644 index 000000000..5bd48db1e --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/assets/catalog.json @@ -0,0 +1,3113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json", + "title": "Basic with MCP Catalog", + "description": "The standard A2UI basic components combined with the MCP catalog functions.", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text content to display. While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation." + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"], + "default": "body" + } + }, + "required": ["component", "text"] + } + ], + "unevaluatedProperties": false + }, + "Image": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Image" + }, + "url": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The URL of the image to display." + }, + "description": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "Accessibility text for the image." + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": ["contain", "cover", "fill", "none", "scaleDown"], + "default": "fill" + }, + "variant": { + "type": "string", + "description": "A hint for the image size and style.", + "enum": ["icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header"], + "default": "mediumFeature" + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, + "Icon": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Icon" + }, + "name": { + "description": "The name of the icon to display.", + "oneOf": [ + { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "fastForward", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "pause", + "payment", + "person", + "phone", + "photo", + "play", + "print", + "refresh", + "rewind", + "search", + "send", + "settings", + "share", + "shoppingCart", + "skipNext", + "skipPrevious", + "star", + "starHalf", + "starOff", + "stop", + "upload", + "visibility", + "visibilityOff", + "volumeDown", + "volumeMute", + "volumeOff", + "volumeUp", + "warning" + ] + }, + { + "type": "object", + "properties": { + "svgPath": { + "type": "string" + } + }, + "required": ["svgPath"], + "additionalProperties": false + }, + { + "$ref": "#/$defs/common_types_DataBinding" + } + ] + } + }, + "required": ["component", "name"] + } + ], + "unevaluatedProperties": false + }, + "Video": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Video" + }, + "url": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The URL of the video to display." + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, + "AudioPlayer": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "AudioPlayer" + }, + "url": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The URL of the audio to be played." + }, + "description": { + "description": "A description of the audio, such as a title or summary.", + "$ref": "#/$defs/common_types_DynamicString" + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, + "Row": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "description": "A layout component that arranges its children horizontally. To create a grid layout, nest Columns within this Row.", + "properties": { + "component": { + "const": "Row" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", + "$ref": "#/$defs/common_types_ChildList" + }, + "justify": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). Use 'spaceBetween' to push items to the edges, or 'start'/'end'/'center' to pack them together.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start", + "stretch" + ], + "default": "start" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This is similar to the CSS 'align-items' property, but uses camelCase values (e.g., 'start').", + "enum": ["start", "center", "end", "stretch"], + "default": "stretch" + } + }, + "required": ["component", "children"] + } + ], + "unevaluatedProperties": false + }, + "Column": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "description": "A layout component that arranges its children vertically. To create a grid layout, nest Rows within this Column.", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", + "$ref": "#/$defs/common_types_ChildList" + }, + "justify": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). Use 'spaceBetween' to push items to the edges (e.g. header at top, footer at bottom), or 'start'/'end'/'center' to pack them together.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly", + "stretch" + ], + "default": "start" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This is similar to the CSS 'align-items' property.", + "enum": ["center", "end", "start", "stretch"], + "default": "stretch" + } + }, + "required": ["component", "children"] + } + ], + "unevaluatedProperties": false + }, + "List": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "List" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list.", + "$ref": "#/$defs/common_types_ChildList" + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"], + "default": "vertical" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": ["start", "center", "end", "stretch"], + "default": "stretch" + } + }, + "required": ["component", "children"] + } + ], + "unevaluatedProperties": false + }, + "Card": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the single child component to be rendered inside the card. To display multiple elements, you MUST wrap them in a layout component (like Column or Row) and pass that container's ID here. Do NOT pass multiple IDs or a non-existent ID. Do NOT define the child component inline." + } + }, + "required": ["component", "child"] + } + ], + "unevaluatedProperties": false + }, + "Tabs": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Tabs" + }, + "tabs": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "title": { + "description": "The tab title.", + "$ref": "#/$defs/common_types_DynamicString" + }, + "child": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the child component. Do NOT define the component inline." + } + }, + "required": ["title", "child"], + "additionalProperties": false + } + } + }, + "required": ["component", "tabs"] + } + ], + "unevaluatedProperties": false + }, + "Modal": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Modal" + }, + "trigger": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button). Do NOT define the component inline." + }, + "content": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the component to be displayed inside the modal. Do NOT define the component inline." + } + }, + "required": ["component", "trigger", "content"] + } + ], + "unevaluatedProperties": false + }, + "Divider": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Divider" + }, + "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"], + "default": "horizontal" + } + }, + "required": ["component"] + } + ], + "unevaluatedProperties": false + }, + "Button": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "child": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the child component. Use a 'Text' component for a labeled button. Only use an 'Icon' if the requirements explicitly ask for an icon-only button. Do NOT define the child component inline." + }, + "variant": { + "type": "string", + "description": "A hint for the button style. If omitted, a default button style is used. 'primary' indicates this is the main call-to-action button. 'borderless' means the button has no visual border or background, making its child content appear like a clickable link.", + "enum": ["default", "primary", "borderless"], + "default": "default" + }, + "action": { + "$ref": "#/$defs/common_types_Action" + } + }, + "required": ["component", "child", "action"] + } + ], + "unevaluatedProperties": false + }, + "TextField": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "TextField" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text label for the input field." + }, + "value": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The value of the text field." + }, + "variant": { + "type": "string", + "description": "The type of input field to display.", + "enum": ["longText", "number", "shortText", "obscured"], + "default": "shortText" + }, + "validationRegexp": { + "type": "string", + "description": "A regular expression used for client-side validation of the input." + } + }, + "required": ["component", "label"] + } + ], + "unevaluatedProperties": false + }, + "CheckBox": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "CheckBox" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text to display next to the checkbox." + }, + "value": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "The current state of the checkbox (true for checked, false for unchecked)." + } + }, + "required": ["component", "label", "value"] + } + ], + "unevaluatedProperties": false + }, + "ChoicePicker": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "description": "A component that allows selecting one or more options from a list.", + "properties": { + "component": { + "const": "ChoicePicker" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The label for the group of options." + }, + "variant": { + "type": "string", + "description": "A hint for how the choice picker should be displayed and behave.", + "enum": ["multipleSelection", "mutuallyExclusive"], + "default": "mutuallyExclusive" + }, + "options": { + "type": "array", + "description": "The list of available options to choose from.", + "items": { + "type": "object", + "properties": { + "label": { + "description": "The text to display for this option.", + "$ref": "#/$defs/common_types_DynamicString" + }, + "value": { + "type": "string", + "description": "The stable value associated with this option." + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "value": { + "$ref": "#/$defs/common_types_DynamicStringList", + "description": "The list of currently selected values. This should be bound to a string array in the data model." + }, + "displayStyle": { + "type": "string", + "description": "The display style of the component.", + "enum": ["checkbox", "chips"], + "default": "checkbox" + }, + "filterable": { + "type": "boolean", + "description": "If true, displays a search input to filter the options.", + "default": false + } + }, + "required": ["component", "options", "value"] + } + ], + "unevaluatedProperties": false + }, + "Slider": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Slider" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The label for the slider." + }, + "min": { + "type": "number", + "description": "The minimum value of the slider.", + "default": 0 + }, + "max": { + "type": "number", + "description": "The maximum value of the slider." + }, + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The current value of the slider." + } + }, + "required": ["component", "value", "max"] + } + ], + "unevaluatedProperties": false + }, + "DateTimeInput": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "DateTimeInput" + }, + "value": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The selected date and/or time value in ISO 8601 format. If not yet set, initialize with an empty string." + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date.", + "default": false + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time.", + "default": false + }, + "min": { + "allOf": [ + { + "$ref": "#/$defs/common_types_DynamicString" + }, + { + "if": { + "type": "string" + }, + "then": { + "oneOf": [ + { + "format": "date" + }, + { + "format": "time" + }, + { + "format": "date-time" + } + ] + } + } + ], + "description": "The minimum allowed date/time in ISO 8601 format." + }, + "max": { + "allOf": [ + { + "$ref": "#/$defs/common_types_DynamicString" + }, + { + "if": { + "type": "string" + }, + "then": { + "oneOf": [ + { + "format": "date" + }, + { + "format": "time" + }, + { + "format": "date-time" + } + ] + } + } + ], + "description": "The maximum allowed date/time in ISO 8601 format." + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text label for the input field." + } + }, + "required": ["component", "value"] + } + ], + "unevaluatedProperties": false + } + }, + "functions": { + "callMcpTool": { + "type": "object", + "description": "Invokes a tool on a connected Model Context Protocol (MCP) server and returns the result. Can be combined with `jmespath` and `updateDataModel` to update the surface data model from tool output.", + "properties": { + "call": { + "const": "callMcpTool" + }, + "args": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The name of the MCP tool to execute." + }, + "arguments": { + "type": "object", + "description": "The arguments to pass to the MCP tool.", + "additionalProperties": { + "$ref": "#/$defs/common_types_DynamicValue" + } + } + }, + "required": ["name"], + "additionalProperties": false + }, + "returnType": { + "const": "any" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "jmespath": { + "type": "object", + "description": "Evaluates a JMESPath expression against input data.", + "properties": { + "call": { + "const": "jmespath" + }, + "args": { + "type": "object", + "properties": { + "expression": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "A standard JMESPath expression evaluated against `data`." + }, + "data": { + "$ref": "#/$defs/common_types_DynamicValue", + "description": "The input data to evaluate the expression against." + } + }, + "required": ["expression", "data"], + "additionalProperties": false + }, + "returnType": { + "const": "any" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "split": { + "type": "object", + "description": "Splits a string or array of strings by a separator.", + "properties": { + "call": { + "const": "split" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicValue", + "description": "The string or array of strings to split." + }, + "separator": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The delimiter string to split on. An empty string splits into individual characters." + } + }, + "required": ["value", "separator"], + "additionalProperties": false + }, + "returnType": { + "const": "any" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "regexCapture": { + "type": "object", + "description": "Extracts capture groups from the first RE2 regular expression match.", + "properties": { + "call": { + "const": "regexCapture" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicValue", + "description": "The string or array of strings to match against." + }, + "pattern": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "An RE2 regular expression. 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 + }, + "returnType": { + "const": "any" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "regexReplace": { + "type": "object", + "description": "Replaces all matches of an RE2 regular expression with literal text.", + "properties": { + "call": { + "const": "regexReplace" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicValue", + "description": "The string or array of strings to perform replacements on." + }, + "pattern": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "An RE2 regular expression matching substrings to replace." + }, + "replacement": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "Literal replacement string. Capture group references like $1 are treated as literal text." + } + }, + "required": ["value", "pattern", "replacement"], + "additionalProperties": false + }, + "returnType": { + "const": "any" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "updateDataModel": { + "type": "object", + "description": "Writes key-value pairs to the surface data model.", + "properties": { + "call": { + "const": "updateDataModel" + }, + "args": { + "type": "object", + "properties": { + "updates": { + "$ref": "#/$defs/common_types_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 + }, + "returnType": { + "const": "any" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined, or empty.", + "properties": { + "call": { + "const": "required" + }, + "args": { + "type": "object", + "properties": { + "value": { + "description": "The value to check." + } + }, + "required": ["value"], + "additionalProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "regex": { + "type": "object", + "description": "Checks that the value matches a regular expression string.", + "properties": { + "call": { + "const": "regex" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + }, + "pattern": { + "type": "string", + "description": "The regex pattern to match against." + } + }, + "required": ["value", "pattern"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "length": { + "type": "object", + "description": "Checks string length constraints.", + "properties": { + "call": { + "const": "length" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + }, + "min": { + "type": "integer", + "minimum": 0, + "description": "The minimum allowed length." + }, + "max": { + "type": "integer", + "minimum": 0, + "description": "The maximum allowed length." + } + }, + "required": ["value"], + "anyOf": [ + { + "required": ["min"] + }, + { + "required": ["max"] + } + ], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "numeric": { + "type": "object", + "description": "Checks numeric range constraints.", + "properties": { + "call": { + "const": "numeric" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber" + }, + "min": { + "type": "number", + "description": "The minimum allowed value." + }, + "max": { + "type": "number", + "description": "The maximum allowed value." + } + }, + "required": ["value"], + "anyOf": [ + { + "required": ["min"] + }, + { + "required": ["max"] + } + ], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "email": { + "type": "object", + "description": "Checks that the value is a valid email address.", + "properties": { + "call": { + "const": "email" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "formatString": { + "type": "object", + "description": "Performs string interpolation of data model values and other functions in the catalog functions list and returns the resulting string. The value string can contain interpolated expressions in the `${expression}` format. Supported expression types include: JSON Pointer paths to the data model (e.g., `${/absolute/path}` or `${relative/path}`), and client-side function calls (e.g., `${now()}`). Function arguments must be named (e.g., `${formatDate(value:${/currentDate}, format:'MM-dd')}`). To include a literal `${` sequence, escape it as `\\${`.", + "properties": { + "call": { + "const": "formatString" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "formatNumber": { + "type": "object", + "description": "Formats a number with the specified grouping and decimal precision.", + "properties": { + "call": { + "const": "formatNumber" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The number to format." + }, + "decimals": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." + }, + "grouping": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "formatCurrency": { + "type": "object", + "description": "Formats a number as a currency string.", + "properties": { + "call": { + "const": "formatCurrency" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The monetary amount." + }, + "currency": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The ISO 4217 currency code (e.g., 'USD', 'EUR')." + }, + "decimals": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." + }, + "grouping": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." + } + }, + "required": ["currency", "value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "formatDate": { + "type": "object", + "description": "Formats a timestamp into a string using a pattern.", + "properties": { + "call": { + "const": "formatDate" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicValue", + "description": "The date to format." + }, + "format": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "A Unicode TR35 date pattern string.\n\nToken Reference:\n- Year: 'yy' (26), 'yyyy' (2026)\n- Month: 'M' (1), 'MM' (01), 'MMM' (Jan), 'MMMM' (January)\n- Day: 'd' (1), 'dd' (01), 'E' (Tue), 'EEEE' (Tuesday)\n- Hour (12h): 'h' (1-12), 'hh' (01-12) - requires 'a' for AM/PM\n- Hour (24h): 'H' (0-23), 'HH' (00-23) - Military Time\n- Minute: 'mm' (00-59)\n- Second: 'ss' (00-59)\n- Period: 'a' (AM/PM)\n\nExamples:\n- 'MMM dd, yyyy' -> 'Jan 16, 2026'\n- 'HH:mm' -> '14:30' (Military)\n- 'h:mm a' -> '2:30 PM'\n- 'EEEE, d MMMM' -> 'Friday, 16 January'" + } + }, + "required": ["format", "value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "pluralize": { + "type": "object", + "description": "Returns a localized string based on the Common Locale Data Repository (CLDR) plural category of the count (zero, one, two, few, many, other). Requires an 'other' fallback. For English, just use 'one' and 'other'.", + "properties": { + "call": { + "const": "pluralize" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The numeric value used to determine the plural category." + }, + "zero": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'zero' category (e.g., 0 items)." + }, + "one": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'one' category (e.g., 1 item)." + }, + "two": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'two' category (used in Arabic, Welsh, etc.)." + }, + "few": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'few' category (e.g., small groups in Slavic languages)." + }, + "many": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'many' category (e.g., large groups in various languages)." + }, + "other": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The default/fallback string (used for general plural cases)." + } + }, + "required": ["value", "other"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "openUrl": { + "type": "object", + "description": "Opens the specified URL in a browser or handler. This function has no return value.", + "properties": { + "call": { + "const": "openUrl" + }, + "args": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The URL to open." + } + }, + "required": ["url"], + "additionalProperties": false + }, + "returnType": { + "const": "void" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "and": { + "type": "object", + "description": "Performs a logical AND operation on a list of boolean values.", + "properties": { + "call": { + "const": "and" + }, + "args": { + "type": "object", + "properties": { + "values": { + "type": "array", + "description": "The list of boolean values to evaluate.", + "items": { + "$ref": "#/$defs/common_types_DynamicBoolean" + }, + "minItems": 2 + } + }, + "required": ["values"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "or": { + "type": "object", + "description": "Performs a logical OR operation on a list of boolean values.", + "properties": { + "call": { + "const": "or" + }, + "args": { + "type": "object", + "properties": { + "values": { + "type": "array", + "description": "The list of boolean values to evaluate.", + "items": { + "$ref": "#/$defs/common_types_DynamicBoolean" + }, + "minItems": 2 + } + }, + "required": ["values"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "not": { + "type": "object", + "description": "Performs a logical NOT operation on a boolean value.", + "properties": { + "call": { + "const": "not" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "The boolean value to negate." + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + } + }, + "$defs": { + "common_types_ComponentId": { + "type": "string", + "description": "The unique identifier for a component, used for both definitions and references within the same surface." + }, + "common_types_DataBinding": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "A JSON Pointer path to a value in the data model." + } + }, + "required": ["path"], + "additionalProperties": false + }, + "common_types_DynamicValue": { + "description": "A value that can be a literal, a path, or a function call returning any type.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "$ref": "#/$defs/common_types_DataBinding" + }, + { + "$ref": "#/$defs/common_types_FunctionCall" + } + ] + }, + "common_types_FunctionCall": { + "type": "object", + "description": "Invokes a named function on the client.", + "properties": { + "call": { + "type": "string", + "description": "The name of the function to call." + }, + "args": { + "type": "object", + "description": "Arguments passed to the function.", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/common_types_DynamicValue" + }, + { + "type": "object", + "description": "A literal object argument (e.g. configuration)." + } + ] + } + }, + "returnType": { + "type": "string", + "description": "The expected return type of the function call.", + "enum": ["string", "number", "boolean", "array", "object", "any", "void"], + "default": "boolean" + } + }, + "required": ["call"], + "oneOf": [ + { + "$ref": "#/$defs/anyFunction" + } + ] + }, + "common_types_DynamicString": { + "description": "Represents a string", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/common_types_DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/common_types_FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "string" + } + } + } + ] + } + ] + }, + "common_types_AccessibilityAttributes": { + "type": "object", + "description": "Attributes to enhance accessibility when using assistive technologies like screen readers.", + "properties": { + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "A short string, typically 1 to 3 words, used by assistive technologies to convey the purpose or intent of an element. For example, an input field might have an accessible label of 'User ID' or a button might be labeled 'Submit'." + }, + "description": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "Additional information provided by assistive technologies about an element such as instructions, format requirements, or result of an action. For example, a mute button might have a label of 'Mute' and a description of 'Silences notifications about this conversation'." + } + } + }, + "common_types_ComponentCommon": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/common_types_ComponentId" + }, + "accessibility": { + "$ref": "#/$defs/common_types_AccessibilityAttributes" + } + }, + "required": ["id"] + }, + "catalog_CatalogComponentCommon": { + "type": "object", + "properties": { + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + } + }, + "common_types_ChildList": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/common_types_ComponentId" + }, + "description": "A static list of child component IDs." + }, + { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. The `componentId` is the component to use as a template.", + "properties": { + "componentId": { + "$ref": "#/$defs/common_types_ComponentId" + }, + "path": { + "type": "string", + "description": "The path to the list of component property objects in the data model." + } + }, + "required": ["componentId", "path"], + "additionalProperties": false + } + ] + }, + "common_types_DynamicBoolean": { + "description": "A boolean value that can be a literal, a path, or a function call returning a boolean.", + "oneOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/common_types_DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/common_types_FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "boolean" + } + } + } + ] + } + ] + }, + "common_types_CheckRule": { + "type": "object", + "description": "A single validation rule applied to an input component.", + "properties": { + "condition": { + "$ref": "#/$defs/common_types_DynamicBoolean" + }, + "message": { + "type": "string", + "description": "The error message to display if the check fails." + } + }, + "required": ["condition", "message"], + "additionalProperties": false + }, + "common_types_Checkable": { + "description": "Properties for components that support client-side checks.", + "type": "object", + "properties": { + "checks": { + "type": "array", + "description": "A list of checks to perform. These are function calls that must return a boolean indicating validity.", + "items": { + "$ref": "#/$defs/common_types_CheckRule" + } + } + } + }, + "common_types_Action": { + "description": "Defines an interaction handler that can either trigger a server-side event or execute a local client-side function.", + "oneOf": [ + { + "type": "object", + "description": "Triggers a server-side event.", + "properties": { + "event": { + "type": "object", + "description": "The event to dispatch to the server.", + "properties": { + "name": { + "type": "string", + "description": "The name of the action to be dispatched to the server." + }, + "context": { + "type": "object", + "description": "A JSON object containing the key-value pairs for the action context. Values can be literals or paths. Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.", + "additionalProperties": { + "$ref": "#/$defs/common_types_DynamicValue" + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["event"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Executes a local client-side function.", + "properties": { + "functionCall": { + "$ref": "#/$defs/common_types_FunctionCall" + } + }, + "required": ["functionCall"], + "additionalProperties": false + } + ] + }, + "common_types_DynamicStringList": { + "description": "Represents a value that can be either a literal array of strings, a path to a string array in the data model, or a function call returning a string array.", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/common_types_DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/common_types_FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "array" + } + } + } + ] + } + ] + }, + "common_types_DynamicNumber": { + "description": "Represents a value that can be either a literal number, a path to a number in the data model, or a function call returning a number.", + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/common_types_DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/common_types_FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "number" + } + } + } + ] + } + ] + }, + "catalog_Text": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text content to display. While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation." + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"], + "default": "body" + } + }, + "required": ["component", "text"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Image": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Image" + }, + "url": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The URL of the image to display." + }, + "description": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "Accessibility text for the image." + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": ["contain", "cover", "fill", "none", "scaleDown"], + "default": "fill" + }, + "variant": { + "type": "string", + "description": "A hint for the image size and style.", + "enum": ["icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header"], + "default": "mediumFeature" + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Icon": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Icon" + }, + "name": { + "description": "The name of the icon to display.", + "oneOf": [ + { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "fastForward", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "pause", + "payment", + "person", + "phone", + "photo", + "play", + "print", + "refresh", + "rewind", + "search", + "send", + "settings", + "share", + "shoppingCart", + "skipNext", + "skipPrevious", + "star", + "starHalf", + "starOff", + "stop", + "upload", + "visibility", + "visibilityOff", + "volumeDown", + "volumeMute", + "volumeOff", + "volumeUp", + "warning" + ] + }, + { + "type": "object", + "properties": { + "svgPath": { + "type": "string" + } + }, + "required": ["svgPath"], + "additionalProperties": false + }, + { + "$ref": "#/$defs/common_types_DataBinding" + } + ] + } + }, + "required": ["component", "name"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Video": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Video" + }, + "url": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The URL of the video to display." + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, + "catalog_AudioPlayer": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "AudioPlayer" + }, + "url": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The URL of the audio to be played." + }, + "description": { + "description": "A description of the audio, such as a title or summary.", + "$ref": "#/$defs/common_types_DynamicString" + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Row": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "description": "A layout component that arranges its children horizontally. To create a grid layout, nest Columns within this Row.", + "properties": { + "component": { + "const": "Row" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", + "$ref": "#/$defs/common_types_ChildList" + }, + "justify": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). Use 'spaceBetween' to push items to the edges, or 'start'/'end'/'center' to pack them together.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start", + "stretch" + ], + "default": "start" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This is similar to the CSS 'align-items' property, but uses camelCase values (e.g., 'start').", + "enum": ["start", "center", "end", "stretch"], + "default": "stretch" + } + }, + "required": ["component", "children"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Column": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "description": "A layout component that arranges its children vertically. To create a grid layout, nest Rows within this Column.", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", + "$ref": "#/$defs/common_types_ChildList" + }, + "justify": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). Use 'spaceBetween' to push items to the edges (e.g. header at top, footer at bottom), or 'start'/'end'/'center' to pack them together.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly", + "stretch" + ], + "default": "start" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This is similar to the CSS 'align-items' property.", + "enum": ["center", "end", "start", "stretch"], + "default": "stretch" + } + }, + "required": ["component", "children"] + } + ], + "unevaluatedProperties": false + }, + "catalog_List": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "List" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list.", + "$ref": "#/$defs/common_types_ChildList" + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"], + "default": "vertical" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": ["start", "center", "end", "stretch"], + "default": "stretch" + } + }, + "required": ["component", "children"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Card": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the single child component to be rendered inside the card. To display multiple elements, you MUST wrap them in a layout component (like Column or Row) and pass that container's ID here. Do NOT pass multiple IDs or a non-existent ID. Do NOT define the child component inline." + } + }, + "required": ["component", "child"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Tabs": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Tabs" + }, + "tabs": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "title": { + "description": "The tab title.", + "$ref": "#/$defs/common_types_DynamicString" + }, + "child": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the child component. Do NOT define the component inline." + } + }, + "required": ["title", "child"], + "additionalProperties": false + } + } + }, + "required": ["component", "tabs"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Modal": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Modal" + }, + "trigger": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button). Do NOT define the component inline." + }, + "content": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the component to be displayed inside the modal. Do NOT define the component inline." + } + }, + "required": ["component", "trigger", "content"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Divider": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Divider" + }, + "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"], + "default": "horizontal" + } + }, + "required": ["component"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Button": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "child": { + "$ref": "#/$defs/common_types_ComponentId", + "description": "The ID of the child component. Use a 'Text' component for a labeled button. Only use an 'Icon' if the requirements explicitly ask for an icon-only button. Do NOT define the child component inline." + }, + "variant": { + "type": "string", + "description": "A hint for the button style. If omitted, a default button style is used. 'primary' indicates this is the main call-to-action button. 'borderless' means the button has no visual border or background, making its child content appear like a clickable link.", + "enum": ["default", "primary", "borderless"], + "default": "default" + }, + "action": { + "$ref": "#/$defs/common_types_Action" + } + }, + "required": ["component", "child", "action"] + } + ], + "unevaluatedProperties": false + }, + "catalog_TextField": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "TextField" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text label for the input field." + }, + "value": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The value of the text field." + }, + "variant": { + "type": "string", + "description": "The type of input field to display.", + "enum": ["longText", "number", "shortText", "obscured"], + "default": "shortText" + }, + "validationRegexp": { + "type": "string", + "description": "A regular expression used for client-side validation of the input." + } + }, + "required": ["component", "label"] + } + ], + "unevaluatedProperties": false + }, + "catalog_CheckBox": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "CheckBox" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text to display next to the checkbox." + }, + "value": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "The current state of the checkbox (true for checked, false for unchecked)." + } + }, + "required": ["component", "label", "value"] + } + ], + "unevaluatedProperties": false + }, + "catalog_ChoicePicker": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "description": "A component that allows selecting one or more options from a list.", + "properties": { + "component": { + "const": "ChoicePicker" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The label for the group of options." + }, + "variant": { + "type": "string", + "description": "A hint for how the choice picker should be displayed and behave.", + "enum": ["multipleSelection", "mutuallyExclusive"], + "default": "mutuallyExclusive" + }, + "options": { + "type": "array", + "description": "The list of available options to choose from.", + "items": { + "type": "object", + "properties": { + "label": { + "description": "The text to display for this option.", + "$ref": "#/$defs/common_types_DynamicString" + }, + "value": { + "type": "string", + "description": "The stable value associated with this option." + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "value": { + "$ref": "#/$defs/common_types_DynamicStringList", + "description": "The list of currently selected values. This should be bound to a string array in the data model." + }, + "displayStyle": { + "type": "string", + "description": "The display style of the component.", + "enum": ["checkbox", "chips"], + "default": "checkbox" + }, + "filterable": { + "type": "boolean", + "description": "If true, displays a search input to filter the options.", + "default": false + } + }, + "required": ["component", "options", "value"] + } + ], + "unevaluatedProperties": false + }, + "catalog_Slider": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Slider" + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The label for the slider." + }, + "min": { + "type": "number", + "description": "The minimum value of the slider.", + "default": 0 + }, + "max": { + "type": "number", + "description": "The maximum value of the slider." + }, + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The current value of the slider." + } + }, + "required": ["component", "value", "max"] + } + ], + "unevaluatedProperties": false + }, + "catalog_DateTimeInput": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/common_types_ComponentCommon" + }, + { + "$ref": "#/$defs/catalog_CatalogComponentCommon" + }, + { + "$ref": "#/$defs/common_types_Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "DateTimeInput" + }, + "value": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The selected date and/or time value in ISO 8601 format. If not yet set, initialize with an empty string." + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date.", + "default": false + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time.", + "default": false + }, + "min": { + "allOf": [ + { + "$ref": "#/$defs/common_types_DynamicString" + }, + { + "if": { + "type": "string" + }, + "then": { + "oneOf": [ + { + "format": "date" + }, + { + "format": "time" + }, + { + "format": "date-time" + } + ] + } + } + ], + "description": "The minimum allowed date/time in ISO 8601 format." + }, + "max": { + "allOf": [ + { + "$ref": "#/$defs/common_types_DynamicString" + }, + { + "if": { + "type": "string" + }, + "then": { + "oneOf": [ + { + "format": "date" + }, + { + "format": "time" + }, + { + "format": "date-time" + } + ] + } + } + ], + "description": "The maximum allowed date/time in ISO 8601 format." + }, + "label": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The text label for the input field." + } + }, + "required": ["component", "value"] + } + ], + "unevaluatedProperties": false + }, + "catalog_required": { + "type": "object", + "description": "Checks that the value is not null, undefined, or empty.", + "properties": { + "call": { + "const": "required" + }, + "args": { + "type": "object", + "properties": { + "value": { + "description": "The value to check." + } + }, + "required": ["value"], + "additionalProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_regex": { + "type": "object", + "description": "Checks that the value matches a regular expression string.", + "properties": { + "call": { + "const": "regex" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + }, + "pattern": { + "type": "string", + "description": "The regex pattern to match against." + } + }, + "required": ["value", "pattern"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_length": { + "type": "object", + "description": "Checks string length constraints.", + "properties": { + "call": { + "const": "length" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + }, + "min": { + "type": "integer", + "minimum": 0, + "description": "The minimum allowed length." + }, + "max": { + "type": "integer", + "minimum": 0, + "description": "The maximum allowed length." + } + }, + "required": ["value"], + "anyOf": [ + { + "required": ["min"] + }, + { + "required": ["max"] + } + ], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_numeric": { + "type": "object", + "description": "Checks numeric range constraints.", + "properties": { + "call": { + "const": "numeric" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber" + }, + "min": { + "type": "number", + "description": "The minimum allowed value." + }, + "max": { + "type": "number", + "description": "The maximum allowed value." + } + }, + "required": ["value"], + "anyOf": [ + { + "required": ["min"] + }, + { + "required": ["max"] + } + ], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_email": { + "type": "object", + "description": "Checks that the value is a valid email address.", + "properties": { + "call": { + "const": "email" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_formatString": { + "type": "object", + "description": "Performs string interpolation of data model values and other functions in the catalog functions list and returns the resulting string. The value string can contain interpolated expressions in the `${expression}` format. Supported expression types include: JSON Pointer paths to the data model (e.g., `${/absolute/path}` or `${relative/path}`), and client-side function calls (e.g., `${now()}`). Function arguments must be named (e.g., `${formatDate(value:${/currentDate}, format:'MM-dd')}`). To include a literal `${` sequence, escape it as `\\${`.", + "properties": { + "call": { + "const": "formatString" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicString" + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_formatNumber": { + "type": "object", + "description": "Formats a number with the specified grouping and decimal precision.", + "properties": { + "call": { + "const": "formatNumber" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The number to format." + }, + "decimals": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." + }, + "grouping": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_formatCurrency": { + "type": "object", + "description": "Formats a number as a currency string.", + "properties": { + "call": { + "const": "formatCurrency" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The monetary amount." + }, + "currency": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The ISO 4217 currency code (e.g., 'USD', 'EUR')." + }, + "decimals": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." + }, + "grouping": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." + } + }, + "required": ["currency", "value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_formatDate": { + "type": "object", + "description": "Formats a timestamp into a string using a pattern.", + "properties": { + "call": { + "const": "formatDate" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicValue", + "description": "The date to format." + }, + "format": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "A Unicode TR35 date pattern string.\n\nToken Reference:\n- Year: 'yy' (26), 'yyyy' (2026)\n- Month: 'M' (1), 'MM' (01), 'MMM' (Jan), 'MMMM' (January)\n- Day: 'd' (1), 'dd' (01), 'E' (Tue), 'EEEE' (Tuesday)\n- Hour (12h): 'h' (1-12), 'hh' (01-12) - requires 'a' for AM/PM\n- Hour (24h): 'H' (0-23), 'HH' (00-23) - Military Time\n- Minute: 'mm' (00-59)\n- Second: 'ss' (00-59)\n- Period: 'a' (AM/PM)\n\nExamples:\n- 'MMM dd, yyyy' -> 'Jan 16, 2026'\n- 'HH:mm' -> '14:30' (Military)\n- 'h:mm a' -> '2:30 PM'\n- 'EEEE, d MMMM' -> 'Friday, 16 January'" + } + }, + "required": ["format", "value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_pluralize": { + "type": "object", + "description": "Returns a localized string based on the Common Locale Data Repository (CLDR) plural category of the count (zero, one, two, few, many, other). Requires an 'other' fallback. For English, just use 'one' and 'other'.", + "properties": { + "call": { + "const": "pluralize" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicNumber", + "description": "The numeric value used to determine the plural category." + }, + "zero": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'zero' category (e.g., 0 items)." + }, + "one": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'one' category (e.g., 1 item)." + }, + "two": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'two' category (used in Arabic, Welsh, etc.)." + }, + "few": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'few' category (e.g., small groups in Slavic languages)." + }, + "many": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "String for the 'many' category (e.g., large groups in various languages)." + }, + "other": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "The default/fallback string (used for general plural cases)." + } + }, + "required": ["value", "other"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "string" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_openUrl": { + "type": "object", + "description": "Opens the specified URL in a browser or handler. This function has no return value.", + "properties": { + "call": { + "const": "openUrl" + }, + "args": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The URL to open." + } + }, + "required": ["url"], + "additionalProperties": false + }, + "returnType": { + "const": "void" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_and": { + "type": "object", + "description": "Performs a logical AND operation on a list of boolean values.", + "properties": { + "call": { + "const": "and" + }, + "args": { + "type": "object", + "properties": { + "values": { + "type": "array", + "description": "The list of boolean values to evaluate.", + "items": { + "$ref": "#/$defs/common_types_DynamicBoolean" + }, + "minItems": 2 + } + }, + "required": ["values"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_or": { + "type": "object", + "description": "Performs a logical OR operation on a list of boolean values.", + "properties": { + "call": { + "const": "or" + }, + "args": { + "type": "object", + "properties": { + "values": { + "type": "array", + "description": "The list of boolean values to evaluate.", + "items": { + "$ref": "#/$defs/common_types_DynamicBoolean" + }, + "minItems": 2 + } + }, + "required": ["values"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "catalog_not": { + "type": "object", + "description": "Performs a logical NOT operation on a boolean value.", + "properties": { + "call": { + "const": "not" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "#/$defs/common_types_DynamicBoolean", + "description": "The boolean value to negate." + } + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "returnType": { + "const": "boolean" + } + }, + "required": ["call", "args"], + "unevaluatedProperties": false + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/AudioPlayer" + }, + { + "$ref": "#/components/Button" + }, + { + "$ref": "#/components/Card" + }, + { + "$ref": "#/components/CheckBox" + }, + { + "$ref": "#/components/ChoicePicker" + }, + { + "$ref": "#/components/Column" + }, + { + "$ref": "#/components/DateTimeInput" + }, + { + "$ref": "#/components/Divider" + }, + { + "$ref": "#/components/Icon" + }, + { + "$ref": "#/components/Image" + }, + { + "$ref": "#/components/List" + }, + { + "$ref": "#/components/Modal" + }, + { + "$ref": "#/components/Row" + }, + { + "$ref": "#/components/Slider" + }, + { + "$ref": "#/components/Tabs" + }, + { + "$ref": "#/components/Text" + }, + { + "$ref": "#/components/TextField" + }, + { + "$ref": "#/components/Video" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/and" + }, + { + "$ref": "#/functions/email" + }, + { + "$ref": "#/functions/formatCurrency" + }, + { + "$ref": "#/functions/formatDate" + }, + { + "$ref": "#/functions/formatNumber" + }, + { + "$ref": "#/functions/formatString" + }, + { + "$ref": "#/functions/length" + }, + { + "$ref": "#/functions/not" + }, + { + "$ref": "#/functions/numeric" + }, + { + "$ref": "#/functions/openUrl" + }, + { + "$ref": "#/functions/or" + }, + { + "$ref": "#/functions/pluralize" + }, + { + "$ref": "#/functions/regex" + }, + { + "$ref": "#/functions/required" + }, + { + "$ref": "#/functions/callMcpTool" + }, + { + "$ref": "#/functions/jmespath" + }, + { + "$ref": "#/functions/split" + }, + { + "$ref": "#/functions/regexCapture" + }, + { + "$ref": "#/functions/regexReplace" + }, + { + "$ref": "#/functions/updateDataModel" + } + ] + } + } +} diff --git a/samples/ng-basic-with-mcp-catalog/src/assets/catalog.json.d.ts b/samples/ng-basic-with-mcp-catalog/src/assets/catalog.json.d.ts new file mode 100644 index 000000000..0514e296e --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/assets/catalog.json.d.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +declare const catalogJson: unknown; +export default catalogJson; diff --git a/samples/ng-basic-with-mcp-catalog/src/assets/usages.ts b/samples/ng-basic-with-mcp-catalog/src/assets/usages.ts new file mode 100644 index 000000000..5e77ba0ea --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/assets/usages.ts @@ -0,0 +1,313 @@ +/** + * Copyright 2026 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 + * + * http://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 {type ComponentUsages} from 'a2ui-bridge'; + +/** Component usages dictionary matching the A2UiComponentV09 usage spec. */ +export const COMPONENT_USAGES: ComponentUsages = { + AudioPlayer: { + usage: [ + { + id: 'root', + component: 'AudioPlayer', + url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', + description: 'Deep dive into A2UI', + }, + ], + }, + Button: { + usage: [ + { + id: 'root', + component: 'Button', + child: 'demo-button-child', + action: { + event: { + name: 'submit', + context: [ + { + key: 'formId', + value: 'contact-form', + }, + ], + }, + }, + primary: true, + }, + { + id: 'demo-button-child', + component: 'Text', + text: 'Submit', + }, + ], + }, + Card: { + usage: [ + { + id: 'root', + component: 'Card', + child: 'demo-card-contents', + }, + { + id: 'demo-card-contents', + component: 'Column', + children: ['demo-card-title', 'demo-card-content'], + }, + { + id: 'demo-card-title', + component: 'Text', + text: 'Card Title', + usageHint: 'h3', + }, + { + id: 'demo-card-content', + component: 'Text', + text: 'This is the card with some text', + }, + ], + }, + CheckBox: { + usage: [ + { + id: 'root', + component: 'CheckBox', + label: 'Label', + value: true, + }, + ], + }, + ChoicePicker: { + usage: [ + { + id: 'root', + component: 'ChoicePicker', + selections: ['US'], + options: [ + {label: 'United States', value: 'US'}, + {label: 'Canada', value: 'CA'}, + {label: 'Mexico', value: 'MX'}, + ], + variant: 'multipleSelection', + maxAllowedSelections: 1, + }, + ], + }, + Column: { + usage: [ + { + id: 'root', + component: 'Column', + children: ['demo-column-header', 'demo-column-content', 'demo-column-footer'], + justify: 'start', + align: 'stretch', + }, + { + id: 'demo-column-header', + component: 'Text', + text: 'Header', + usageHint: 'h2', + }, + { + id: 'demo-column-content', + component: 'Text', + text: 'Content goes here', + usageHint: 'body', + }, + { + id: 'demo-column-footer', + component: 'Text', + text: 'Footer', + usageHint: 'caption', + }, + ], + }, + DateTimeInput: { + usage: [ + { + id: 'root', + component: 'DateTimeInput', + value: '2023-01-01T12:00:00Z', + enableDate: true, + enableTime: true, + }, + ], + }, + Divider: { + usage: [ + { + id: 'root', + component: 'Divider', + axis: 'horizontal', + }, + ], + }, + Icon: { + usage: [ + { + id: 'root', + component: 'Icon', + name: 'check_circle', + }, + ], + }, + Image: { + usage: [ + { + id: 'root', + component: 'Image', + url: 'https://www.gstatic.com/marketing-cms/assets/images/c5/3a/200414104c669203c62270f7884f/google-wordmarks-2x.webp=n-w100-h32-fcrop64=1,00000000ffffffff-rw', + fit: 'scaleDown', + usageHint: 'mediumFeature', + }, + ], + }, + List: { + usage: [ + { + id: 'root', + component: 'List', + children: ['demo-list-item-1', 'demo-list-item-2', 'demo-list-item-3'], + direction: 'vertical', + }, + { + id: 'demo-list-item-1', + component: 'Text', + text: 'List Item 1', + }, + { + id: 'demo-list-item-2', + component: 'Text', + text: 'List Item 2', + }, + { + id: 'demo-list-item-3', + component: 'Text', + text: 'List Item 3', + }, + ], + }, + Modal: { + usage: [ + { + id: 'root', + component: 'Column', + align: 'center', + justify: 'center', + children: ['demo-modal'], + }, + { + id: 'demo-modal', + component: 'Modal', + trigger: 'demo-modal-button', + content: 'demo-modal-content', + }, + { + id: 'demo-modal-button', + component: 'Button', + child: 'demo-modal-button-label', + action: { + event: { + name: 'open', + }, + }, + }, + { + id: 'demo-modal-button-label', + component: 'Text', + text: 'Open Modal', + }, + { + id: 'demo-modal-content', + component: 'Text', + text: 'Modal Content', + }, + ], + }, + Row: { + usage: [ + { + id: 'root', + component: 'Row', + children: ['demo-row-left', 'demo-row-center', 'demo-row-right'], + justify: 'spaceBetween', + align: 'center', + }, + { + id: 'demo-row-left', + component: 'Text', + text: 'Left', + }, + { + id: 'demo-row-center', + component: 'Text', + text: 'Center', + }, + { + id: 'demo-row-right', + component: 'Text', + text: 'Right', + }, + ], + }, + Slider: { + usage: [ + { + id: 'root', + component: 'Slider', + value: 50, + minValue: 0, + maxValue: 100, + }, + ], + }, + Text: { + usage: [ + { + id: 'root', + component: 'Text', + text: 'Example text', + }, + ], + }, + TextField: { + usage: [ + { + id: 'root', + component: 'TextField', + label: 'Your name', + textFieldType: 'shortText', + validationRegexp: '^[a-zA-Z]+$', + value: { + path: '/user/name', + }, + }, + ], + data: { + user: { + name: 'Hello', + }, + }, + }, + Video: { + usage: [ + { + id: 'root', + component: 'Video', + url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', + }, + ], + }, +}; diff --git a/samples/ng-basic-with-mcp-catalog/src/index.html b/samples/ng-basic-with-mcp-catalog/src/index.html new file mode 100644 index 000000000..2fa8eba4e --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/index.html @@ -0,0 +1,31 @@ + + + + + + + A2UI Basic with MCP Catalog Angular Sandbox + + + + + + + + + + diff --git a/samples/ng-basic-with-mcp-catalog/src/main.spec.ts b/samples/ng-basic-with-mcp-catalog/src/main.spec.ts new file mode 100644 index 000000000..69e42729a --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/main.spec.ts @@ -0,0 +1,148 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +// @vitest-environment jsdom +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {AppComponent} from './app/app.component'; +import {BasicWithMcpCatalog} from './app/basic-with-mcp.catalog'; +import {A2uiSandboxConnection, provideA2uiSandbox} from 'a2ui-bridge/angular'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {A2uiRendererService, A2UI_RENDERER_CONFIG, BasicCatalog} from '@a2ui/angular/v0_9'; +import {A2uiClientAction} from '@a2ui/web_core/v0_9'; +import { + provideZonelessChangeDetection, + createEnvironmentInjector, + EnvironmentInjector, +} from '@angular/core'; +import {a2uiBridge} from 'a2ui-bridge'; + +// Mocks core bridge singleton variables for clean telemetry spy verification +vi.mock('a2ui-bridge', () => ({ + a2uiBridge: { + attachRenderer: vi.fn().mockReturnValue({unsubscribe: vi.fn()}), + sendMessage: vi.fn(), + sendAction: vi.fn(), + getMcpClient: vi.fn(), + }, + createMcpCatalogFunctions: vi.fn().mockReturnValue([]), +})); + +describe('A2uiSandbox', () => { + let fixture: ComponentFixture; + let component: AppComponent; + let sandbox: A2uiSandboxConnection; + + beforeEach(async () => { + vi.restoreAllMocks(); + + // Injects dynamic host element to JSDOM body to prevent bootloader selector crashes + const host = document.createElement('app-root'); + document.body.appendChild(host); + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [ + provideZonelessChangeDetection(), + provideA2uiSandbox([BasicWithMcpCatalog, BasicCatalog], {catalogJson: {items: ['Grid']}}), // Injects dynamically linked dynamic manager + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + sandbox = fixture.debugElement.injector.get(A2uiSandboxConnection); + fixture.detectChanges(); + }); + + afterEach(() => { + vi.clearAllMocks(); + document.querySelector('app-root')?.remove(); + }); + + it('creates the sandbox component successfully', () => { + expect(component).toBeTruthy(); + expect(sandbox).toBeTruthy(); + }); + + it('attaches renderer upon service/manager construction', () => { + expect(a2uiBridge.attachRenderer).toHaveBeenCalledWith( + expect.any(Object), // resolves to dynamic rendererService + expect.objectContaining({ + surfaceGroup: expect.any(Object), + onSurfaceReady: expect.any(Function), + onSurfaceCleared: expect.any(Function), + catalogJson: {items: ['Grid']}, + }), + ); + }); + + it('unsubscribes and detaches renderer upon ngOnDestroy', () => { + const mockUnsubscribe = vi.mocked(a2uiBridge.attachRenderer).mock.results[0].value.unsubscribe; + fixture.destroy(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('sets surfaceId signal when onSurfaceReady is called', () => { + const attachCall = vi.mocked(a2uiBridge.attachRenderer).mock.calls[0]; + const {onSurfaceReady} = attachCall[1]; + + // Check initial signal states + expect(sandbox.surfaceId()).toBe(''); + + onSurfaceReady('test-surface-angular-id'); + + expect(sandbox.surfaceId()).toBe('test-surface-angular-id'); + }); + + it('resets surfaceId when onSurfaceCleared is called', () => { + const attachCall = vi.mocked(a2uiBridge.attachRenderer).mock.calls[0]; + const {onSurfaceReady, onSurfaceCleared} = attachCall[1]; + + onSurfaceReady('test-surface-angular-id'); + expect(sandbox.surfaceId()).toBe('test-surface-angular-id'); + + onSurfaceCleared?.(); + + expect(sandbox.surfaceId()).toBe(''); + }); + + it('dispatches actions wrapped in version v0.9 envelope to SEND_TO_SERVER', () => { + const config = TestBed.inject(A2UI_RENDERER_CONFIG); + const dummyAction: A2uiClientAction = { + name: 'CLICK', + surfaceId: 'surface-1', + sourceComponentId: 'button-1', + timestamp: '2026-05-15T00:00:00Z', + context: {}, + }; + + config.actionHandler?.(dummyAction); + expect(a2uiBridge.sendAction).toHaveBeenCalledWith(dummyAction); + }); + + it('provides and registers correct DI providers list', () => { + const parentInjector = TestBed.inject(EnvironmentInjector); + const environmentInjector = createEnvironmentInjector( + [provideA2uiSandbox([BasicCatalog], {catalogJson: {items: ['Grid']}})], + parentInjector, + ); + + expect(environmentInjector.get(A2uiRendererService)).toBeDefined(); + expect(environmentInjector.get(A2uiSandboxConnection)).toBeDefined(); + expect(environmentInjector.get(BasicCatalog)).toBeDefined(); + expect(environmentInjector.get(A2UI_RENDERER_CONFIG)).toBeDefined(); + }); +}); diff --git a/samples/ng-basic-with-mcp-catalog/src/main.ts b/samples/ng-basic-with-mcp-catalog/src/main.ts new file mode 100644 index 000000000..bc4cd618c --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/main.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2026 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 + * + * http://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 {bootstrapApplication} from '@angular/platform-browser'; +import {provideZonelessChangeDetection} from '@angular/core'; +import {AppComponent} from './app/app.component'; +import {BasicWithMcpCatalog} from './app/basic-with-mcp.catalog'; +import {provideA2uiSandbox} from 'a2ui-bridge/angular'; +import {BasicCatalog} from '@a2ui/angular/v0_9'; +import catalogJson from './assets/catalog.json'; + +import {COMPONENT_USAGES} from './assets/usages.js'; + +bootstrapApplication(AppComponent, { + providers: [ + provideZonelessChangeDetection(), + provideA2uiSandbox([BasicWithMcpCatalog, BasicCatalog], { + catalogJson, + getComponentUsages: async () => COMPONENT_USAGES, + }), + ], +}).catch(err => console.error('A2UI Sandbox Bootstrap Failed:', err)); diff --git a/samples/ng-basic-with-mcp-catalog/src/styles.scss b/samples/ng-basic-with-mcp-catalog/src/styles.scss new file mode 100644 index 000000000..312ab5db0 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/styles.scss @@ -0,0 +1,52 @@ +/** + * Copyright 2026 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 + * + * http://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. + */ + +:root { + color-scheme: light dark; + --a2ui-color-surface: #ffffff; + --a2ui-color-on-surface: #1c1b1c; + --a2ui-color-background: #ffffff; + --a2ui-color-on-background: #1c1b1c; + --a2ui-color-input: #ffffff; + --a2ui-color-on-input: #1c1b1c; + --a2ui-color-border: #cccccc; +} + +html.dark-theme, +[data-theme='dark'], +.dark-theme body { + --a2ui-color-surface: #1e1e1e; + --a2ui-color-on-surface: #e5e2e2; + --a2ui-color-background: #121212; + --a2ui-color-on-background: #e5e2e2; + --a2ui-color-input: #2a2a2a; + --a2ui-color-on-input: #e5e2e2; + --a2ui-color-border: #444444; +} + +body { + margin: 0; + padding: 16px; + font-family: sans-serif; + background-color: var(--a2ui-color-background, canvas); + color: var(--a2ui-color-on-background, canvastext); + box-sizing: border-box; +} +*, +*:before, +*:after { + box-sizing: inherit; +} diff --git a/samples/ng-basic-with-mcp-catalog/src/test-setup.ts b/samples/ng-basic-with-mcp-catalog/src/test-setup.ts new file mode 100644 index 000000000..3d5acd749 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/test-setup.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2026 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 + * + * http://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 {getTestBed} from '@angular/core/testing'; +import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing'; + +if (!getTestBed().platform) { + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +} diff --git a/samples/ng-basic-with-mcp-catalog/tsconfig.app.json b/samples/ng-basic-with-mcp-catalog/tsconfig.app.json new file mode 100644 index 000000000..338e98cda --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/out-tsc" + }, + "files": ["src/main.ts"], + "include": ["src/**/*.ts"] +} diff --git a/samples/ng-basic-with-mcp-catalog/tsconfig.json b/samples/ng-basic-with-mcp-catalog/tsconfig.json new file mode 100644 index 000000000..1c644422b --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"] + } +} diff --git a/samples/ng-basic-with-mcp-catalog/tsconfig.spec.json b/samples/ng-basic-with-mcp-catalog/tsconfig.spec.json new file mode 100644 index 000000000..3db1fdbf2 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/out-tsc", + "types": ["node", "jsdom"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.ts"] +} diff --git a/samples/ng-basic-with-mcp-catalog/vitest.config.ts b/samples/ng-basic-with-mcp-catalog/vitest.config.ts new file mode 100644 index 000000000..c498e9559 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/vitest.config.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2026 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 + * + * http://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 {defineConfig} from 'vitest/config'; +import angular from '@analogjs/vite-plugin-angular'; + +export default defineConfig({ + esbuild: false, + plugins: [angular({jit: false, tsconfig: './tsconfig.spec.json', oxc: false})], + resolve: { + dedupe: ['@angular/core', '@a2ui/angular', 'a2ui-bridge'], + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + deps: { + optimizer: { + web: { + include: ['@a2ui/angular'], + }, + }, + }, + server: { + deps: { + inline: true, + }, + }, + }, +}); diff --git a/shell/package.json b/shell/package.json index a9b8a2f29..bc91ff4a7 100644 --- a/shell/package.json +++ b/shell/package.json @@ -30,6 +30,7 @@ "@angular/platform-browser": "22.0.7", "@angular/router": "22.0.7", "@google/genai": "2.11.0", + "@modelcontextprotocol/sdk": "1.30.0", "@monaco-editor/loader": "1.7.0", "a2ui-bridge": "0.1.0", "dockview": "7.0.3", diff --git a/shell/src/app/chat/chat-panel/chat-panel.ng.html b/shell/src/app/chat/chat-panel/chat-panel.ng.html index acda6300c..d450631a3 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.ng.html +++ b/shell/src/app/chat/chat-panel/chat-panel.ng.html @@ -277,7 +277,13 @@ photo_camera @if (isLocked()) { diff --git a/shell/src/app/chat/chat-panel/chat-panel.spec.ts b/shell/src/app/chat/chat-panel/chat-panel.spec.ts index 606c2b995..d4d157570 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.spec.ts +++ b/shell/src/app/chat/chat-panel/chat-panel.spec.ts @@ -39,6 +39,7 @@ import { parseAndHealJsonLines, SuccessRenderParseResult, } from '../a2ui-payload-parser/a2ui-payload-parser'; +import {McpClientManagerService} from '../../mcp/mcp-client-manager.service'; class MockChatState { readonly chatHistory = signal([]); @@ -986,4 +987,59 @@ describe('ChatPanel Gemini Dialogue Panel Integration', () => { expect(parsed?.success).toBe(false); }); }); + + describe('MCP catalog status indicator', () => { + it('shows "Instructions" when callMcpTool is not in activeCatalog and "Instructions (includes X MCP Servers)" when it is', async () => { + expect(await harness.getSystemInstructionsLinkText()).toBe('Instructions'); + + catalogManagementServiceMock.activeCatalog.set({ + title: 'Basic with MCP', + components: {}, + functions: {callMcpTool: {type: 'object'}}, + }); + fixture.detectChanges(); + + expect(await harness.getSystemInstructionsLinkText()).toBe( + 'Instructions (includes 0 MCP Servers)', + ); + + const mcpManager = TestBed.inject(McpClientManagerService); + mcpManager.servers.set([ + { + id: 'server-1', + url: 'http://localhost:3001/mcp', + enabled: true, + status: 'connected', + tools: [{name: 'sample_tool', inputSchema: {}}], + }, + ]); + fixture.detectChanges(); + + expect(await harness.getSystemInstructionsLinkText()).toBe( + 'Instructions (includes 1 MCP Server)', + ); + + mcpManager.servers.set([ + { + id: 'server-1', + url: 'http://localhost:3001/mcp', + enabled: true, + status: 'connected', + tools: [{name: 'sample_tool_1', inputSchema: {}}], + }, + { + id: 'server-2', + url: 'http://localhost:3002/mcp', + enabled: true, + status: 'connected', + tools: [{name: 'sample_tool_2', inputSchema: {}}], + }, + ]); + fixture.detectChanges(); + + expect(await harness.getSystemInstructionsLinkText()).toBe( + 'Instructions (includes 2 MCP Servers)', + ); + }); + }); }); diff --git a/shell/src/app/chat/chat-panel/chat-panel.ts b/shell/src/app/chat/chat-panel/chat-panel.ts index 89450c016..9593b962b 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.ts +++ b/shell/src/app/chat/chat-panel/chat-panel.ts @@ -38,6 +38,7 @@ import {HostCommunication} from '../../shell/host-communication/host-communicati import {ScreenshotCaptureService} from '../../shell/screenshot/screenshot-capture.service'; import {StartupResolution} from '../../shell/startup-resolution/startup-resolution'; import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; +import {doesCatalogSupportMcp} from '../../storage/models/catalog-storage.model'; import {ChatCleaner} from '../chat-cleaner/chat-cleaner'; import {parseAndHealJsonLines} from '../a2ui-payload-parser/a2ui-payload-parser'; import {ComposerPanelId, OpenPanelEvent} from '../../shell/composer-workspace/composer-panel-id'; @@ -46,6 +47,7 @@ import {ChatState} from '../chat-state/chat-state'; import {LlmMessage, MessageRole} from '../llm-client/llm-client'; import {PipelineStatus} from '../pipeline-status/pipeline-status'; import {SystemInstructionsDialog} from '../system-instructions-dialog/system-instructions-dialog'; +import {McpClientManagerService} from '../../mcp/mcp-client-manager.service'; /** * Directive responsible for automatically scrolling a container to the bottom whenever its inputs change. @@ -102,8 +104,15 @@ export class ChatPanel { private readonly hostCommunication = inject(HostCommunication); private readonly fileIngestionService = inject(FileIngestionService); private readonly screenshotCaptureService = inject(ScreenshotCaptureService); + protected readonly mcpManager = inject(McpClientManagerService); protected readonly includeScreenshot = signal(false); + protected readonly isMcpSupported = computed(() => + doesCatalogSupportMcp(this.catalogManagement.activeCatalog()), + ); + protected readonly activeMcpServerCount = computed( + () => this.mcpManager.getActiveServersWithTools().length, + ); protected onIncludeScreenshotChange(checked: boolean): void { this.includeScreenshot.set(checked); diff --git a/shell/src/app/chat/chat-panel/test/chat-panel.harness.ts b/shell/src/app/chat/chat-panel/test/chat-panel.harness.ts index 958253e30..348c97831 100644 --- a/shell/src/app/chat/chat-panel/test/chat-panel.harness.ts +++ b/shell/src/app/chat/chat-panel/test/chat-panel.harness.ts @@ -169,6 +169,12 @@ export class ChatPanelHarness extends ComponentHarness { return link !== null; } + async getSystemInstructionsLinkText(): Promise { + const link = await this.locatorForOptional('.system-instructions-link')(); + if (!link) return null; + return (await link.text()).trim(); + } + async getRetryButtonsCount(): Promise { const buttons = await this.locatorForAll('.retry-button')(); return buttons.length; diff --git a/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.spec.ts b/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.spec.ts index ed16ad693..d588427d3 100644 --- a/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.spec.ts +++ b/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.spec.ts @@ -18,15 +18,25 @@ import {describe, it, expect, vi, beforeEach} from 'vitest'; import {TestBed} from '@angular/core/testing'; import {ChatPromptFactoryService} from './chat-prompt-factory.service'; import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; +import {McpClientManagerService} from '../../mcp/mcp-client-manager.service'; describe('ChatPromptFactoryService', () => { let service: ChatPromptFactoryService; - let catalogSpy: unknown; + let catalogSpy: { + activeCatalog: ReturnType; + activeCatalogSignal: ReturnType; + }; + let mcpSpy: {getActiveServersWithTools: ReturnType}; beforeEach(() => { catalogSpy = {activeCatalog: vi.fn(), activeCatalogSignal: vi.fn(() => null)}; + mcpSpy = {getActiveServersWithTools: vi.fn(() => [])}; TestBed.configureTestingModule({ - providers: [ChatPromptFactoryService, {provide: CatalogManagement, useValue: catalogSpy}], + providers: [ + ChatPromptFactoryService, + {provide: CatalogManagement, useValue: catalogSpy}, + {provide: McpClientManagerService, useValue: mcpSpy}, + ], }); service = TestBed.inject(ChatPromptFactoryService); }); @@ -37,9 +47,52 @@ describe('ChatPromptFactoryService', () => { expect(service.systemPrompt()).not.toContain('Active Catalog Schema'); }); - it('generate catalog specific prompt', () => { + it('generate catalog specific prompt without MCP instructions when callMcpTool is absent', () => { + mcpSpy.getActiveServersWithTools.mockReturnValue([ + { + id: 'srv-1', + name: 'fs-server', + url: 'http://localhost:3001/mcp', + enabled: true, + status: 'connected', + tools: [{name: 'read_file', description: 'Reads a file'}], + }, + ]); catalogSpy.activeCatalog.mockReturnValue({components: {}}); expect(service.systemPrompt()).toContain('Active Catalog Schema'); expect(service.systemPrompt()).toContain('A2UI Generation Expert'); + expect(service.systemPrompt()).not.toContain('Available MCP Tools & Catalog Instructions'); + }); + + it('appends active MCP tools with input and output schema without server name or URL when callMcpTool is present in catalog functions', () => { + mcpSpy.getActiveServersWithTools.mockReturnValue([ + { + id: 'srv-1', + name: 'fs-server', + url: 'http://localhost:3001/mcp', + enabled: true, + status: 'connected', + tools: [ + { + name: 'read_file', + description: 'Reads a file', + inputSchema: {type: 'object', properties: {path: {type: 'string'}}}, + outputSchema: {type: 'object', properties: {content: {type: 'string'}}}, + }, + ], + }, + ]); + catalogSpy.activeCatalog.mockReturnValue({ + components: {}, + functions: {callMcpTool: {type: 'object'}}, + }); + expect(service.systemPrompt()).toContain('Available MCP Tools & Catalog Instructions'); + expect(service.systemPrompt()).toContain('read_file'); + expect(service.systemPrompt()).toContain('Input Schema'); + expect(service.systemPrompt()).toContain('Output Schema'); + expect(service.systemPrompt()).toContain('"path":{"type":"string"}'); + expect(service.systemPrompt()).toContain('"content":{"type":"string"}'); + expect(service.systemPrompt()).not.toContain('fs-server'); + expect(service.systemPrompt()).not.toContain('http://localhost:3001/mcp'); }); }); diff --git a/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.ts b/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.ts index 73f512b2e..2f868cdbe 100644 --- a/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.ts +++ b/shell/src/app/chat/chat-prompt-factory/chat-prompt-factory.service.ts @@ -16,8 +16,14 @@ import {Injectable, computed, inject} from '@angular/core'; import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; +import {doesCatalogSupportMcp} from '../../storage/models/catalog-storage.model'; import {formatJson} from '../../utils/json'; import {COMMON_TYPES_SCHEMA} from '../../gallery/schema/common-types-schema'; +import { + McpClientManagerService, + McpServerConfig, + McpToolInfo, +} from '../../mcp/mcp-client-manager.service'; /** * Constructs dynamic system prompts based on the provided LLM intent and active catalog states. @@ -27,9 +33,14 @@ import {COMMON_TYPES_SCHEMA} from '../../gallery/schema/common-types-schema'; }) export class ChatPromptFactoryService { private readonly catalogManagement = inject(CatalogManagement); + private readonly mcpManager = inject(McpClientManagerService); readonly systemPrompt = computed(() => { const catalog = this.catalogManagement.activeCatalog(); + const mcpSupported = doesCatalogSupportMcp(catalog); + const activeServers = mcpSupported ? this.mcpManager.getActiveServersWithTools() : []; + const mcpInstructions = this.buildMcpInstructions(activeServers); + if (!catalog) { return ` # A2UI Generation Expert @@ -38,12 +49,89 @@ export class ChatPromptFactoryService { You are an expert A2UI generation assistant. Your role is to translate user requests—whether provided as text instructions, UI wireframes, screenshots, or mockup images—into valid A2UI v0.9 interactive user interfaces. + ${mcpInstructions} `; } - return this.generateSystemPrompt(formatJson(catalog)); + return this.generateSystemPrompt(formatJson(catalog)) + mcpInstructions; }); + private buildMcpInstructions(activeServers: McpServerConfig[]): string { + const toolsMap = new Map(); + for (const server of activeServers) { + for (const tool of server.tools || []) { + if (!toolsMap.has(tool.name)) { + toolsMap.set(tool.name, tool); + } + } + } + + if (toolsMap.size === 0) { + return ''; + } + + const defaultOutputSchema = { + type: 'object', + properties: { + content: { + type: 'array', + items: { + type: 'object', + properties: { + type: {type: 'string'}, + text: {type: 'string'}, + }, + }, + }, + }, + }; + + const toolsMarkdown = Array.from(toolsMap.values()) + .map(tool => { + const desc = tool.description ? ` - ${tool.description}` : ''; + const inputSchemaStr = JSON.stringify(tool.inputSchema || {type: 'object', properties: {}}); + const outputSchemaStr = JSON.stringify(tool.outputSchema || defaultOutputSchema); + return `- **\`${tool.name}\`**${desc}\n - **Input Schema**: \`${inputSchemaStr}\`\n - **Output Schema**: \`${outputSchemaStr}\``; + }) + .join('\n'); + + return ` + + ## Available MCP Tools & Catalog Instructions + + When building surfaces that interact with MCP tools: + + 1. Trigger MCP tools via button \`functionCall\` actions that chain \`updateDataModel\`, \`jmespath\`, and \`callMcpTool\`: + \`\`\`json + "action": { + "functionCall": { + "call": "updateDataModel", + "args": { + "updates": { + "call": "jmespath", + "args": { + "expression": "{\\"/result\\": content[0].text}", + "data": { + "call": "callMcpTool", + "args": { + "name": "", + "arguments": { + "path": "/mcp_arguments" + } + } + } + } + } + } + } + } + \`\`\` + + ### Available MCP Tools + ${toolsMarkdown} +`; + } + private generateSystemPrompt(catalog: string): string { return ` # A2UI Generation Expert diff --git a/shell/src/app/chat/llm-client/llm-client.spec.ts b/shell/src/app/chat/llm-client/llm-client.spec.ts index 1d0c768aa..6e73e96ba 100644 --- a/shell/src/app/chat/llm-client/llm-client.spec.ts +++ b/shell/src/app/chat/llm-client/llm-client.spec.ts @@ -93,6 +93,13 @@ vi.mock('@google/genai', () => { return { GoogleGenAI: MockGoogleGenAI, + ThinkingLevel: { + THINKING_LEVEL_UNSPECIFIED: 'THINKING_LEVEL_UNSPECIFIED', + MINIMAL: 'MINIMAL', + LOW: 'LOW', + MEDIUM: 'MEDIUM', + HIGH: 'HIGH', + }, }; }); @@ -260,7 +267,8 @@ describe('LlmClient Facade and Standalone Provider Integration', () => { expect(mockGenerateContentStream).toHaveBeenCalledTimes(1); const passedParams = mockGenerateContentStream.mock.calls[0][0]; - expect(passedParams.model).toBe('gemini-3.5-flash'); + expect(passedParams.model).toBe('gemini-3.7-flash'); + expect(passedParams.config?.thinkingConfig?.thinkingLevel).toBe('MEDIUM'); // Assert system directives aggregate consolidation expect(passedParams.config).toBeDefined(); diff --git a/shell/src/app/chat/llm-client/standalone-3p-llm-client.ts b/shell/src/app/chat/llm-client/standalone-3p-llm-client.ts index fa4d4adcb..82b4b7d06 100644 --- a/shell/src/app/chat/llm-client/standalone-3p-llm-client.ts +++ b/shell/src/app/chat/llm-client/standalone-3p-llm-client.ts @@ -23,7 +23,6 @@ import { MessageRole, CANCEL_ERROR_NAME, StreamProcessingState, - THINKING_BUDGET, } from './llm-client'; import {AppConfigProvider} from '../../settings/app-config-provider/app-config-provider'; import { @@ -33,6 +32,7 @@ import { Part, GenerateContentConfig, GenerateContentResponse, + ThinkingLevel, } from '@google/genai'; /** @@ -183,7 +183,7 @@ export class Standalone3pLlmClient extends LlmClient { } config.thinkingConfig = { includeThoughts: true, - thinkingBudget: THINKING_BUDGET, + thinkingLevel: ThinkingLevel.MEDIUM, }; return config; } @@ -195,7 +195,7 @@ export class Standalone3pLlmClient extends LlmClient { const {systemInstruction, contents} = this.parseMessages(messages); const config = this.buildGenerateContentConfig(systemInstruction, abortSignal); return { - model: 'gemini-3.5-flash', + model: 'gemini-3.7-flash', contents, config, }; diff --git a/shell/src/app/debug/raw-messages/raw-messages.spec.ts b/shell/src/app/debug/raw-messages/raw-messages.spec.ts index cecb7a3a8..13d3b1a81 100644 --- a/shell/src/app/debug/raw-messages/raw-messages.spec.ts +++ b/shell/src/app/debug/raw-messages/raw-messages.spec.ts @@ -413,4 +413,26 @@ describe('RawMessages', () => { messageType: LlmLogType.REQUEST, }); }); + + it('displays MCP_RESPONSE with result payload in collapsible panel', async () => { + emitMessage({ + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: { + requestId: 'req-1', + result: {content: [{type: 'text', text: 'Directory listing complete'}]}, + }, + origin: 'http://localhost:3000', + timestamp: 1000, + }); + fixture.detectChanges(); + + expect(await harness.getLoggedMessagesCount()).toBe(1); + expect(await harness.isMessageCollapsibleAt(0)).toBe(true); + expect(await harness.getMessageTextAt(0)).toContain(PreviewBridgeMessageType.MCP_RESPONSE); + + const panel = await harness.getLlmLogPanelAt(0); + await panel.expand(); + const content = await panel.getTextContent(); + expect(content).toContain('Directory listing complete'); + }); }); diff --git a/shell/src/app/mcp/mcp-client-manager.service.spec.ts b/shell/src/app/mcp/mcp-client-manager.service.spec.ts new file mode 100644 index 000000000..2cd32f6fa --- /dev/null +++ b/shell/src/app/mcp/mcp-client-manager.service.spec.ts @@ -0,0 +1,181 @@ +/** + * Copyright 2026 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 + * + * http://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 {TestBed} from '@angular/core/testing'; +import {describe, it, expect, beforeEach, vi} from 'vitest'; +import {McpClientManagerService} from './mcp-client-manager.service'; +import {LocalStorageInteractions} from '../storage/local-storage-interactions/local-storage-interactions'; +import {LocalStorageKey} from '../storage/models/local-storage-keys'; + +const connectMock = vi.fn(); +const getServerVersionMock = vi.fn(); +const listToolsMock = vi.fn(); +const callToolMock = vi.fn(); +const closeMock = vi.fn(); + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: class { + connect = connectMock; + getServerVersion = getServerVersionMock; + listTools = listToolsMock; + callTool = callToolMock; + close = closeMock; + }, +})); + +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: class { + constructor(readonly url: URL) {} + }, +})); + +describe('McpClientManagerService', () => { + let service: McpClientManagerService; + let storageMap: Map; + let mockStorage: { + getItem: ReturnType; + setItem: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + storageMap = new Map(); + mockStorage = { + getItem: vi.fn((key: string) => storageMap.get(key) ?? null), + setItem: vi.fn((key: string, val: string) => { + storageMap.set(key, val); + }), + }; + + connectMock.mockResolvedValue(undefined); + getServerVersionMock.mockReturnValue({name: 'filesystem-mcp-server', version: '1.0.0'}); + listToolsMock.mockResolvedValue({ + tools: [ + { + name: 'list_directory', + description: 'Lists directory entries', + inputSchema: {type: 'object'}, + }, + ], + }); + callToolMock.mockResolvedValue({ + content: [{type: 'text', text: 'ok'}], + }); + closeMock.mockResolvedValue(undefined); + + TestBed.configureTestingModule({ + providers: [ + McpClientManagerService, + {provide: LocalStorageInteractions, useValue: mockStorage}, + ], + }); + service = TestBed.inject(McpClientManagerService); + }); + + it('adds by URL, populates name from serverInfo, toggles, calls tool, and removes an MCP server', async () => { + await service.addServer('http://localhost:3001/mcp'); + const servers = service.servers(); + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe('filesystem-mcp-server'); + expect(servers[0].status).toBe('connected'); + expect(servers[0].tools).toHaveLength(1); + expect(service.getActiveServersWithTools()).toHaveLength(1); + + const res = await service.callTool('list_directory', {path: '/tmp'}); + expect(callToolMock).toHaveBeenCalledWith({ + name: 'list_directory', + arguments: {path: '/tmp'}, + }); + expect(res).toEqual({content: [{type: 'text', text: 'ok'}]}); + + await service.toggleServer(servers[0].id, false); + expect(service.servers()[0].enabled).toBe(false); + expect(service.servers()[0].status).toBe('disconnected'); + + await service.toggleServer(servers[0].id, true); + expect(service.servers()[0].enabled).toBe(true); + expect(service.servers()[0].status).toBe('connected'); + + await service.connectServer(servers[0].id); + expect(service.servers()[0].status).toBe('connected'); + + await service.removeServer(servers[0].id); + expect(service.servers()).toHaveLength(0); + }); + + it('handles connection failure and missing server callTool', async () => { + connectMock.mockRejectedValueOnce(new Error('Connection refused')); + await service.addServer('http://localhost:9999/mcp'); + expect(service.servers()[0].status).toBe('error'); + expect(service.servers()[0].errorMessage).toContain('Connection refused'); + + await expect(service.callTool('unknown-tool', {})).rejects.toThrow( + /No connected MCP server found/, + ); + }); + + it('hydrates saved servers from localStorage on initialization', () => { + storageMap.set( + LocalStorageKey.MCP_SERVERS, + JSON.stringify([ + { + id: 'saved-1', + name: 'saved-fs', + url: 'http://localhost:3001/mcp', + enabled: false, + }, + ]), + ); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + McpClientManagerService, + {provide: LocalStorageInteractions, useValue: mockStorage}, + ], + }); + const hydratedService = TestBed.inject(McpClientManagerService); + expect(hydratedService.servers()).toHaveLength(1); + expect(hydratedService.servers()[0].name).toBe('saved-fs'); + }); + + it('falls back to server name or url if getServerVersion returns a non-string name', async () => { + getServerVersionMock.mockReturnValueOnce({name: {unexpected: 'object'}}); + await service.addServer('http://localhost:3002/mcp'); + expect(service.servers()[0].name).toBe('http://localhost:3002/mcp'); + }); + + it('closes client and aborts connectServer if server is disabled while connecting', async () => { + let resolveListTools!: (val: {tools: []}) => void; + listToolsMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveListTools = resolve; + }), + ); + + const addPromise = service.addServer('http://localhost:3003/mcp'); + await Promise.resolve(); + await Promise.resolve(); + const id = service.servers()[0].id; + await service.toggleServer(id, false); + resolveListTools({tools: []}); + await addPromise; + + expect(closeMock).toHaveBeenCalled(); + expect(service.servers()[0].status).toBe('disconnected'); + }); +}); diff --git a/shell/src/app/mcp/mcp-client-manager.service.ts b/shell/src/app/mcp/mcp-client-manager.service.ts new file mode 100644 index 000000000..b5c27f788 --- /dev/null +++ b/shell/src/app/mcp/mcp-client-manager.service.ts @@ -0,0 +1,246 @@ +/** + * Copyright 2026 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 + * + * http://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 {Injectable, inject, signal} from '@angular/core'; +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import {LocalStorageInteractions} from '../storage/local-storage-interactions/local-storage-interactions'; +import {LocalStorageKey} from '../storage/models/local-storage-keys'; + +export interface McpToolInfo { + name: string; + description?: string; + inputSchema?: Record; + outputSchema?: Record; +} + +export interface McpServerConfig { + id: string; + name?: string; + url: string; + enabled: boolean; + status?: 'disconnected' | 'connecting' | 'connected' | 'error'; + errorMessage?: string; + tools?: McpToolInfo[]; +} + +interface PersistedMcpServer { + id: string; + name?: string; + url: string; + enabled: boolean; +} + +@Injectable({ + providedIn: 'root', +}) +export class McpClientManagerService { + private readonly storage = inject(LocalStorageInteractions); + private readonly clients = new Map(); + + readonly servers = signal([]); + + constructor() { + this.loadFromStorage(); + for (const server of this.servers()) { + if (server.enabled) { + void this.connectServer(server.id); + } + } + } + + private loadFromStorage(): void { + const rawServers = this.storage.getItem(LocalStorageKey.MCP_SERVERS); + if (rawServers) { + try { + const parsed = JSON.parse(rawServers) as PersistedMcpServer[]; + if (Array.isArray(parsed)) { + this.servers.set( + parsed.map(s => ({ + id: s.id, + name: s.name || s.url, + url: s.url, + enabled: Boolean(s.enabled), + status: 'disconnected', + tools: [], + })), + ); + } + } catch { + this.servers.set([]); + } + } + } + + private persistServers(): void { + const toSave: PersistedMcpServer[] = this.servers().map(s => ({ + id: s.id, + name: s.name, + url: s.url, + enabled: s.enabled, + })); + this.storage.setItem(LocalStorageKey.MCP_SERVERS, JSON.stringify(toSave)); + } + + async addServer(url: string): Promise { + const trimmedUrl = url.trim(); + if (!trimmedUrl) return; + + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `mcp-srv-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + const newServer: McpServerConfig = { + id, + name: trimmedUrl, + url: trimmedUrl, + enabled: true, + status: 'disconnected', + tools: [], + }; + + this.servers.update(list => [...list, newServer]); + this.persistServers(); + await this.connectServer(id); + } + + async removeServer(id: string): Promise { + await this.disconnectServer(id); + this.servers.update(list => list.filter(s => s.id !== id)); + this.persistServers(); + } + + async toggleServer(id: string, enabled: boolean): Promise { + this.servers.update(list => list.map(s => (s.id === id ? {...s, enabled} : s))); + this.persistServers(); + if (enabled) { + await this.connectServer(id); + } else { + await this.disconnectServer(id); + } + } + + async connectServer(id: string): Promise { + const server = this.servers().find(s => s.id === id); + if (!server || !server.enabled) return; + + await this.disconnectServer(id); + + const activeBeforeConnect = this.servers().find(s => s.id === id); + if (!activeBeforeConnect || !activeBeforeConnect.enabled) return; + + this.servers.update(list => + list.map(s => (s.id === id ? {...s, status: 'connecting', errorMessage: undefined} : s)), + ); + + try { + const transport = new StreamableHTTPClientTransport(new URL(server.url)); + const client = new Client({name: 'a2ui-composer', version: '1.0.0'}); + await client.connect(transport); + const serverInfo = client.getServerVersion?.(); + const rawName = serverInfo?.name; + const resolvedName = + (typeof rawName === 'string' ? rawName.trim() : '') || server.name || server.url; + const toolsRes = await client.listTools(); + const discoveredTools: McpToolInfo[] = (toolsRes.tools ?? []).map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema as Record | undefined, + outputSchema: (t as unknown as {outputSchema?: Record}).outputSchema, + })); + + const currentServer = this.servers().find(s => s.id === id); + if (!currentServer || !currentServer.enabled) { + try { + await client.close(); + } catch { + // Ignore close errors during abort + } + return; + } + + this.clients.set(id, client); + this.servers.update(list => + list.map(s => + s.id === id + ? { + ...s, + name: resolvedName, + status: 'connected', + errorMessage: undefined, + tools: discoveredTools, + } + : s, + ), + ); + this.persistServers(); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.servers.update(list => + list.map(s => + s.id === id + ? { + ...s, + status: 'error', + errorMessage, + tools: [], + } + : s, + ), + ); + } + } + + async disconnectServer(id: string): Promise { + const existing = this.clients.get(id); + if (existing) { + this.clients.delete(id); + try { + await existing.close(); + } catch { + // Ignore errors when closing disconnected transport + } + } + this.servers.update(list => + list.map(s => (s.id === id ? {...s, status: 'disconnected', errorMessage: undefined} : s)), + ); + } + + async callTool(toolName: string, args: Record): Promise { + const activeServers = this.getActiveServersWithTools(); + const targetServer = activeServers.find(s => s.tools?.some(t => t.name === toolName)); + if (!targetServer) { + throw new Error(`No connected MCP server found for tool "${toolName}".`); + } + + const client = this.clients.get(targetServer.id); + if (!client) { + throw new Error( + `MCP client for server "${targetServer.name || targetServer.url}" is not initialized.`, + ); + } + + return await client.callTool({ + name: toolName, + arguments: args, + }); + } + + getActiveServersWithTools(): McpServerConfig[] { + return this.servers().filter(s => s.enabled && s.status === 'connected'); + } +} diff --git a/shell/src/app/settings/settings-view/settings.ng.html b/shell/src/app/settings/settings-view/settings.ng.html index 520e92740..1a2fa5296 100644 --- a/shell/src/app/settings/settings-view/settings.ng.html +++ b/shell/src/app/settings/settings-view/settings.ng.html @@ -92,7 +92,7 @@

Developer Authentication Overrides

Connection Status & Diagnostics

-

Real-time monitoring bridge

+

Real-time monitoring bridge

@@ -143,4 +143,101 @@

Overlay Logs Preview

+ + + +

MCP Servers

+

+ Configure HTTP Model Context Protocol (MCP) servers for use in renderers that support the + A2UI MCP Catalog. +

+
+ +
+ + Server URL + + + +
+ +
+ @for (server of mcpManager.servers(); track server.id) { +
+
+
+ +
+
+ {{ server.name || server.url }} + + @if (server.status === 'connected') { + Connected ({{ server.tools?.length || 0 }} tools) + } @else if (server.status === 'connecting') { + Connecting... + } @else if (server.status === 'error') { + Error: {{ server.errorMessage || 'Failed to connect' }} + } @else { + Disconnected + } + +
+ {{ server.url }} +
+
+
+ + +
+
+ @if (server.tools && server.tools.length > 0) { +
+ @for (tool of server.tools; track tool.name) { + {{ tool.name }} + } +
+ } +
+ } +
+
+
diff --git a/shell/src/app/settings/settings-view/settings.scss b/shell/src/app/settings/settings-view/settings.scss index 8d7b30437..2ea7451d5 100644 --- a/shell/src/app/settings/settings-view/settings.scss +++ b/shell/src/app/settings/settings-view/settings.scss @@ -104,17 +104,48 @@ mat-form-field { color: var(--mat-sys-tertiary); } +mat-card-header { + h2[mat-card-title] { + margin: 0; + font-size: 20px; + font-weight: 500; + line-height: 28px; + } + + [mat-card-subtitle] { + margin: 4px 0 0; + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: var(--mat-sys-on-surface-variant); + } +} + ::ng-deep body .mat-mdc-slide-toggle { + --mat-slide-toggle-track-width: 52px; + --mat-slide-toggle-track-height: 32px; + --mat-slide-toggle-track-shape: 9999px; + --mat-slide-toggle-handle-width: 100%; + --mat-slide-toggle-handle-height: 24px; + --mat-slide-toggle-handle-shape: 9999px; + --mat-slide-toggle-with-icon-handle-size: 24px; + --mat-slide-toggle-selected-handle-size: 24px; + --mat-slide-toggle-unselected-handle-size: 16px; + --mat-slide-toggle-selected-handle-horizontal-margin: 0 24px; + --mat-slide-toggle-selected-with-icon-handle-horizontal-margin: 0 24px; + --mat-slide-toggle-unselected-handle-horizontal-margin: 0 8px; + --mat-slide-toggle-unselected-with-icon-handle-horizontal-margin: 0 4px; + .mat-internal-form-field, .mdc-form-field { display: inline-flex !important; align-items: center !important; - gap: 16px !important; + gap: 12px !important; } - label, - .mdc-label { - padding-left: 16px !important; + label:not(:empty), + .mdc-label:not(:empty) { + padding-left: 4px !important; white-space: normal !important; line-height: 1.4 !important; color: var(--mat-sys-on-surface) !important; @@ -132,3 +163,126 @@ mat-form-field { padding-left: 24px; padding-bottom: 12px; } + +.mcp-add-row { + display: flex; + gap: 12px; + align-items: center; + margin-top: 16px; + + .mcp-url-field { + flex: 1; + } + + .mcp-add-btn { + height: 56px; + padding: 0 24px; + border-radius: 8px; + white-space: nowrap; + flex-shrink: 0; + } +} + +.mcp-server-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.mcp-server-item { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px 16px; + border-radius: 10px; + border: 1px solid var(--mat-sys-outline-variant); + background-color: var(--mat-sys-surface-container-low); +} + +.mcp-server-top-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.mcp-server-main { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; + flex: 1; +} + +.mcp-server-toggle { + flex-shrink: 0; +} + +.mcp-server-info { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; +} + +.mcp-server-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.mcp-server-name { + font-weight: 600; + font-size: 14px; + color: var(--mat-sys-on-surface); +} + +.mcp-server-status { + font-size: 12px; + font-weight: 500; + padding: 2px 10px; + border-radius: 9999px; + background-color: var(--mat-sys-surface-container-high); + + &[data-status='connected'] { + color: var(--mat-sys-tertiary); + } + + &[data-status='error'] { + color: var(--mat-sys-error); + } +} + +.mcp-server-url { + font-size: 12px; + color: var(--mat-sys-on-surface-variant); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-server-tools { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--mat-sys-outline-variant); +} + +.mcp-tool-name { + font-family: monospace; + font-size: 11px; + padding: 3px 8px; + border-radius: 6px; + background-color: var(--mat-sys-surface-container-high); + color: var(--mat-sys-on-surface); +} + +.mcp-server-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} diff --git a/shell/src/app/settings/settings-view/settings.spec.ts b/shell/src/app/settings/settings-view/settings.spec.ts index 7bb76c649..50c8f7ca0 100644 --- a/shell/src/app/settings/settings-view/settings.spec.ts +++ b/shell/src/app/settings/settings-view/settings.spec.ts @@ -580,4 +580,44 @@ describe('Settings', () => { ); }); }); + + describe('MCP Servers section', () => { + it('renders MCP Servers in a separate card and displays server name and tool names', async () => { + const {fixture, component, harness} = await setupComponent(); + expect(await harness.hasMcpCard()).toBe(true); + + const addSpy = vi.spyOn(component['mcpManager'], 'addServer').mockResolvedValue(); + component.newMcpServerUrl.set('http://localhost:3001/mcp'); + await component.addMcpServer(); + expect(addSpy).toHaveBeenCalledWith('http://localhost:3001/mcp'); + expect(component.newMcpServerUrl()).toBe(''); + + component['mcpManager'].servers.set([ + { + id: 'srv-1', + name: 'filesystem-server', + url: 'http://localhost:3001/mcp', + enabled: true, + status: 'connected', + tools: [{name: 'read_file'}, {name: 'list_directory'}], + }, + ]); + fixture.detectChanges(); + + expect(await harness.getMcpServerNames()).toEqual(['filesystem-server']); + expect(await harness.getMcpServerToolNames()).toEqual(['read_file', 'list_directory']); + + const toggleSpy = vi.spyOn(component['mcpManager'], 'toggleServer').mockResolvedValue(); + await component.toggleMcpServer('srv-1', false); + expect(toggleSpy).toHaveBeenCalledWith('srv-1', false); + + const reconnectSpy = vi.spyOn(component['mcpManager'], 'connectServer').mockResolvedValue(); + await component.reconnectMcpServer('srv-1'); + expect(reconnectSpy).toHaveBeenCalledWith('srv-1'); + + const removeSpy = vi.spyOn(component['mcpManager'], 'removeServer').mockResolvedValue(); + await component.removeMcpServer('srv-1'); + expect(removeSpy).toHaveBeenCalledWith('srv-1'); + }); + }); }); diff --git a/shell/src/app/settings/settings-view/settings.ts b/shell/src/app/settings/settings-view/settings.ts index 342f54bb0..8b0cfd21f 100644 --- a/shell/src/app/settings/settings-view/settings.ts +++ b/shell/src/app/settings/settings-view/settings.ts @@ -25,7 +25,7 @@ import { untracked, WritableSignal, } from '@angular/core'; -import {NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {FormsModule, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatInputModule} from '@angular/material/input'; import {MatButtonModule} from '@angular/material/button'; @@ -33,6 +33,7 @@ import {MatIconModule} from '@angular/material/icon'; import {MatCardModule} from '@angular/material/card'; import {MatChipsModule} from '@angular/material/chips'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; +import {MatTooltipModule} from '@angular/material/tooltip'; import {StartupConfigStateService} from '../../shell/startup-resolution/state/startup-config-state.service'; import {StartupResolution} from '../../shell/startup-resolution/startup-resolution'; import {HostCommunication} from '../../shell/host-communication/host-communication'; @@ -42,6 +43,7 @@ import {IS_1P_AUTH_ENABLED} from '../../shell/environment-tokens/environment-tok import {SettingsService, RendererOption} from '../settings-service/settings.service'; import {RendererSelectorComponent} from '../renderer-selector/renderer-selector'; import {ApiKeySelectorComponent} from '../api-key-selector/api-key-selector'; +import {McpClientManagerService} from '../../mcp/mcp-client-manager.service'; /** * Renders the user settings view, allowing configuration of target URL endpoints, @@ -51,6 +53,7 @@ import {ApiKeySelectorComponent} from '../api-key-selector/api-key-selector'; selector: 'a2ui-composer-settings', standalone: true, imports: [ + FormsModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, @@ -59,6 +62,7 @@ import {ApiKeySelectorComponent} from '../api-key-selector/api-key-selector'; MatCardModule, MatChipsModule, MatSlideToggleModule, + MatTooltipModule, RendererSelectorComponent, ApiKeySelectorComponent, ], @@ -73,6 +77,9 @@ export class Settings implements OnInit { private readonly catalogManagement = inject(CatalogManagement); private readonly configProvider = inject(AppConfigProvider); protected readonly settingsService = inject(SettingsService); + protected readonly mcpManager = inject(McpClientManagerService); + + readonly newMcpServerUrl = signal(''); protected readonly is1PAuthEnabled = inject(IS_1P_AUTH_ENABLED); @@ -165,4 +172,23 @@ export class Settings implements OnInit { this.configProvider.setForcedAuthMode(newState ? AuthType.THIRD_PARTY : AuthType.FIRST_PARTY); this.isThirdParty.set(this.startupResolution.isThirdPartyEnvironment()); } + + async addMcpServer(): Promise { + const url = this.newMcpServerUrl().trim(); + if (!url) return; + this.newMcpServerUrl.set(''); + await this.mcpManager.addServer(url); + } + + async removeMcpServer(id: string): Promise { + await this.mcpManager.removeServer(id); + } + + async toggleMcpServer(id: string, enabled: boolean): Promise { + await this.mcpManager.toggleServer(id, enabled); + } + + async reconnectMcpServer(id: string): Promise { + await this.mcpManager.connectServer(id); + } } diff --git a/shell/src/app/settings/settings-view/test/settings.harness.ts b/shell/src/app/settings/settings-view/test/settings.harness.ts index 036ff45ec..c49ba80ef 100644 --- a/shell/src/app/settings/settings-view/test/settings.harness.ts +++ b/shell/src/app/settings/settings-view/test/settings.harness.ts @@ -160,4 +160,18 @@ export class SettingsHarness extends ComponentHarness { async hasProfileSelector(): Promise { return !!(await this.getProfileSelector()); } + + async hasMcpCard(): Promise { + return !!(await this.locatorForOptional('.mcp-card')()); + } + + async getMcpServerNames(): Promise { + const els = await this.locatorForAll('.mcp-server-name')(); + return Promise.all(els.map(e => e.text())); + } + + async getMcpServerToolNames(): Promise { + const els = await this.locatorForAll('.mcp-tool-name')(); + return Promise.all(els.map(e => e.text())); + } } diff --git a/shell/src/app/shell/cross-frame-validator/cross-frame-validator.spec.ts b/shell/src/app/shell/cross-frame-validator/cross-frame-validator.spec.ts index 66f70297e..6b1009aad 100644 --- a/shell/src/app/shell/cross-frame-validator/cross-frame-validator.spec.ts +++ b/shell/src/app/shell/cross-frame-validator/cross-frame-validator.spec.ts @@ -876,5 +876,42 @@ describe('CrossFrameValidator', () => { expect(errors).toEqual([]); expect(errorSpy).not.toHaveBeenCalled(); }); + + it('validates MCP_REQUEST payloads and rejects malformed payloads', () => { + expect( + CrossFrameValidator.validateOutgoingMessage({ + type: PreviewBridgeMessageType.MCP_REQUEST, + payload: { + requestId: 'req-1', + toolName: 'read_file', + }, + }), + ).toBe(true); + + expect( + CrossFrameValidator.validateOutgoingMessage({ + type: PreviewBridgeMessageType.MCP_REQUEST, + payload: null, + }), + ).toBe(false); + expect( + CrossFrameValidator.validateOutgoingMessage({ + type: PreviewBridgeMessageType.MCP_REQUEST, + payload: { + requestId: '', + toolName: 'read_file', + }, + }), + ).toBe(false); + expect( + CrossFrameValidator.validateOutgoingMessage({ + type: PreviewBridgeMessageType.MCP_REQUEST, + payload: { + requestId: 'req-1', + toolName: ' ', + }, + }), + ).toBe(false); + }); }); }); diff --git a/shell/src/app/shell/cross-frame-validator/cross-frame-validator.ts b/shell/src/app/shell/cross-frame-validator/cross-frame-validator.ts index c40b38c9a..2a033f117 100644 --- a/shell/src/app/shell/cross-frame-validator/cross-frame-validator.ts +++ b/shell/src/app/shell/cross-frame-validator/cross-frame-validator.ts @@ -188,6 +188,51 @@ export class CrossFrameValidator { return true; } + case PreviewBridgeMessageType.MCP_REQUEST: { + if (!msgPayload || typeof msgPayload !== 'object' || Array.isArray(msgPayload)) { + CrossFrameValidator.recordError( + 'Malformed payload for MCP_REQUEST: must be an object.', + errors, + ); + return false; + } + const mcpReq = msgPayload as {requestId?: unknown; toolName?: unknown}; + if (typeof mcpReq.requestId !== 'string' || !mcpReq.requestId.trim()) { + CrossFrameValidator.recordError( + 'Malformed payload for MCP_REQUEST: must contain a non-empty requestId string.', + errors, + ); + return false; + } + if (typeof mcpReq.toolName !== 'string' || !mcpReq.toolName.trim()) { + CrossFrameValidator.recordError( + 'Malformed payload for MCP_REQUEST: must contain a non-empty toolName string.', + errors, + ); + return false; + } + return true; + } + + case PreviewBridgeMessageType.MCP_RESPONSE: { + if (!msgPayload || typeof msgPayload !== 'object' || Array.isArray(msgPayload)) { + CrossFrameValidator.recordError( + 'Malformed payload for MCP_RESPONSE: must be an object.', + errors, + ); + return false; + } + const mcpRes = msgPayload as {requestId?: unknown}; + if (typeof mcpRes.requestId !== 'string' || !mcpRes.requestId.trim()) { + CrossFrameValidator.recordError( + 'Malformed payload for MCP_RESPONSE: must contain a non-empty requestId string.', + errors, + ); + return false; + } + return true; + } + default: { console.warn(`Unrecognized message type: ${msgType}`); return true; diff --git a/shell/src/app/shell/host-communication/host-communication.spec.ts b/shell/src/app/shell/host-communication/host-communication.spec.ts index 923cc0659..08147ddd1 100644 --- a/shell/src/app/shell/host-communication/host-communication.spec.ts +++ b/shell/src/app/shell/host-communication/host-communication.spec.ts @@ -1212,5 +1212,117 @@ describe('HostCommunication', () => { }); expect(Object.keys(renderCall?.[0] as object)).not.toContain('target'); }); + + it('handles MCP_REQUEST by calling mcpManager.callTool with toolName and sending MCP_RESPONSE to a cross-origin WindowProxy', async () => { + const postMessageSpy = vi.fn(); + const crossOriginWindowProxy = new Proxy( + {postMessage: postMessageSpy}, + { + has(target, prop) { + if (prop === 'contentWindow') { + throw new DOMException( + 'Blocked a frame from accessing a cross-origin frame.', + 'SecurityError', + ); + } + return prop in target; + }, + }, + ) as unknown as Window; + + service.registerIframe(crossOriginWindowProxy); + + const mcpManager = ( + service as unknown as { + mcpManager: {callTool: ReturnType}; + } + ).mcpManager; + const callToolSpy = vi + .spyOn(mcpManager, 'callTool') + .mockResolvedValueOnce({content: [{type: 'text', text: 'dir-list'}]}); + + window.dispatchEvent( + new MessageEvent('message', { + source: crossOriginWindowProxy, + origin: 'http://localhost:3000', + data: { + type: PreviewBridgeMessageType.MCP_REQUEST, + payload: { + requestId: 'req-123', + toolName: 'list_directory', + }, + }, + }), + ); + + await vi.waitFor(() => { + expect(callToolSpy).toHaveBeenCalledWith('list_directory', {}); + expect(postMessageSpy).toHaveBeenCalledWith( + { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: { + requestId: 'req-123', + result: {content: [{type: 'text', text: 'dir-list'}]}, + }, + }, + 'http://localhost:3000', + ); + const successEnvelope = service + .getHistoryBuffer() + .find( + env => + env.type === PreviewBridgeMessageType.MCP_RESPONSE && + (env.payload as {requestId?: string})?.requestId === 'req-123', + ); + expect(successEnvelope).toBeDefined(); + expect(successEnvelope?.payload).toEqual({ + requestId: 'req-123', + result: {content: [{type: 'text', text: 'dir-list'}]}, + }); + expect(successEnvelope?.origin).toBe('http://localhost:3000'); + }); + + callToolSpy.mockRejectedValueOnce(new Error('Tool failure')); + window.dispatchEvent( + new MessageEvent('message', { + source: crossOriginWindowProxy, + origin: 'http://localhost:3000', + data: { + type: PreviewBridgeMessageType.MCP_REQUEST, + payload: { + requestId: 'req-456', + toolName: 'list_directory', + args: {path: '/bad'}, + }, + }, + }), + ); + + await vi.waitFor(() => { + expect(postMessageSpy).toHaveBeenCalledWith( + { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: { + requestId: 'req-456', + error: 'Tool failure', + }, + }, + 'http://localhost:3000', + ); + const errorEnvelope = service + .getHistoryBuffer() + .find( + env => + env.type === PreviewBridgeMessageType.MCP_RESPONSE && + (env.payload as {requestId?: string})?.requestId === 'req-456', + ); + expect(errorEnvelope).toBeDefined(); + expect(errorEnvelope?.payload).toEqual({ + requestId: 'req-456', + error: 'Tool failure', + }); + expect(errorEnvelope?.origin).toBe('http://localhost:3000'); + }); + }); }); }); diff --git a/shell/src/app/shell/host-communication/host-communication.ts b/shell/src/app/shell/host-communication/host-communication.ts index ea1a3c93f..1bcc33b1e 100644 --- a/shell/src/app/shell/host-communication/host-communication.ts +++ b/shell/src/app/shell/host-communication/host-communication.ts @@ -23,8 +23,9 @@ import { ThemePreference, } from '../../settings/app-config-provider/app-config-provider'; import {CrossFrameValidator} from '../cross-frame-validator/cross-frame-validator'; -import {PreviewBridgeMessageType} from 'a2ui-bridge'; +import {PreviewBridgeMessageType, McpRequestPayload} from 'a2ui-bridge'; import {ErrorLogger, ErrorLogLevel} from '../../debug/error-logger.service'; +import {McpClientManagerService} from '../../mcp/mcp-client-manager.service'; /** * Schema representing a structured postMessage payload used to communicate @@ -60,6 +61,7 @@ export class HostCommunication implements OnDestroy { private readonly startupResolution = inject(StartupResolution); private readonly configProvider = inject(AppConfigProvider); private readonly errorLogger = inject(ErrorLogger); + private readonly mcpManager = inject(McpClientManagerService); private iframeWindow: Window | null = null; private iframeElement: HTMLIFrameElement | null = null; private readonly registeredIframes = new Set(); @@ -232,6 +234,38 @@ export class HostCommunication implements OnDestroy { return; } + if (type === PreviewBridgeMessageType.MCP_REQUEST) { + this.isRendererReadySignal.set(true); + const req = data.payload as McpRequestPayload; + const sourceTarget = + Array.from(this.registeredIframes).find(f => f.contentWindow === event.source) ?? + (event.source as Window) ?? + null; + void this.mcpManager + .callTool(req.toolName, req.args ?? {}) + .then(result => { + this.sendMessage( + { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: {requestId: req.requestId, result}, + }, + sourceTarget, + ); + }) + .catch((err: unknown) => { + this.sendMessage( + { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: { + requestId: req.requestId, + error: err instanceof Error ? err.message : String(err), + }, + }, + sourceTarget, + ); + }); + } + if (type === PreviewBridgeMessageType.DATA_MODEL_CHANGE) { const payload = data.payload as {validationErrors?: unknown} | undefined; if (payload?.validationErrors) { @@ -263,16 +297,20 @@ export class HostCommunication implements OnDestroy { } } - this.messageHistoryBuffer.push(envelope); - if (this.messageHistoryBuffer.length > 100) { - this.messageHistoryBuffer.shift(); - } - - this.latestEnvelopeSignal.set(envelope); - this.messageStreamSubject.next(envelope); + this.recordEnvelope(envelope); } }; + private recordEnvelope(envelope: MessageEnvelope): void { + this.messageHistoryBuffer.push(envelope); + if (this.messageHistoryBuffer.length > 100) { + this.messageHistoryBuffer.shift(); + } + + this.latestEnvelopeSignal.set(envelope); + this.messageStreamSubject.next(envelope); + } + constructor() { if (typeof window !== 'undefined') { window.addEventListener('message', this.messageListener); @@ -290,6 +328,19 @@ export class HostCommunication implements OnDestroy { } } + private isIframeElement(target: HTMLIFrameElement | Window): target is HTMLIFrameElement { + if (typeof HTMLIFrameElement !== 'undefined' && target instanceof HTMLIFrameElement) { + return true; + } + try { + return ( + 'contentWindow' in target && typeof (target as unknown as Window).postMessage !== 'function' + ); + } catch { + return false; + } + } + /** * Registers an active iframe DOM element or content window target and flushes * any buffered early messages. Supports multiple concurrent iframes. @@ -308,14 +359,14 @@ export class HostCommunication implements OnDestroy { } let windowTarget: Window | null = null; - if ('contentWindow' in target) { - this.iframeElement = target as HTMLIFrameElement; - this.registeredIframes.add(target as HTMLIFrameElement); + if (this.isIframeElement(target)) { + this.iframeElement = target; + this.registeredIframes.add(target); windowTarget = target.contentWindow; } else { this.iframeElement = null; - this.registeredWindows.add(target as Window); - windowTarget = target as Window; + this.registeredWindows.add(target); + windowTarget = target; } this.iframeWindow = windowTarget; @@ -335,8 +386,8 @@ export class HostCommunication implements OnDestroy { */ unregisterIframe(target: HTMLIFrameElement | Window): void { if (!target) return; - if ('contentWindow' in target) { - this.registeredIframes.delete(target as HTMLIFrameElement); + if (this.isIframeElement(target)) { + this.registeredIframes.delete(target); if (this.iframeElement === target) { this.iframeElement = this.registeredIframes.values().next().value ?? null; this.iframeWindow = this.iframeElement @@ -345,7 +396,7 @@ export class HostCommunication implements OnDestroy { } } else { // Target is a direct Window reference (e.g. external popout or window-only test target). - this.registeredWindows.delete(target as Window); + this.registeredWindows.delete(target); if (this.iframeWindow === target) { const nextWindow = this.registeredWindows.values().next().value ?? null; if (nextWindow) { @@ -382,7 +433,7 @@ export class HostCommunication implements OnDestroy { let targetWindow: Window | null = null; if (target) { - targetWindow = 'contentWindow' in target ? target.contentWindow : (target as Window); + targetWindow = this.isIframeElement(target) ? target.contentWindow : target; } else { targetWindow = this.iframeElement ? this.iframeElement.contentWindow : this.iframeWindow; } @@ -394,6 +445,15 @@ export class HostCommunication implements OnDestroy { try { const targetOrigin = new URL(expectedUrl, globalThis.location?.href).origin; targetWindow.postMessage(message, targetOrigin); + if (message.type === PreviewBridgeMessageType.MCP_RESPONSE) { + this.recordEnvelope({ + type: message.type, + payload: message.payload, + origin: targetOrigin, + timestamp: Date.now(), + sourceWindow: targetWindow, + }); + } } catch (err) { // Ignore malformed URL } diff --git a/shell/src/app/storage/models/catalog-storage.model.ts b/shell/src/app/storage/models/catalog-storage.model.ts index f93f59095..7692c5827 100644 --- a/shell/src/app/storage/models/catalog-storage.model.ts +++ b/shell/src/app/storage/models/catalog-storage.model.ts @@ -44,4 +44,22 @@ export declare interface Catalog { description?: string; catalogId?: string; components?: {[key: string]: CatalogComponentSchema}; + functions?: {[key: string]: Record}; + $defs?: {[key: string]: Record}; +} + +/** + * Checks whether the active catalog includes `callMcpTool` to determine if the renderer supports MCP mode. + */ +export function doesCatalogSupportMcp(catalog: Catalog | null | undefined): boolean { + if (!catalog) { + return false; + } + if (catalog.functions && 'callMcpTool' in catalog.functions) { + return true; + } + if (catalog.$defs && ('callMcpTool' in catalog.$defs || 'catalog_callMcpTool' in catalog.$defs)) { + return true; + } + return false; } diff --git a/shell/src/app/storage/models/local-storage-keys.ts b/shell/src/app/storage/models/local-storage-keys.ts index 4016f51bb..0ff8d8cc6 100644 --- a/shell/src/app/storage/models/local-storage-keys.ts +++ b/shell/src/app/storage/models/local-storage-keys.ts @@ -48,6 +48,8 @@ export enum LocalStorageKey { A2A_TENANT_ID = 'a2ui_composer_a2a_tenant_id', /** Key for storing active A2A transport protocol backend mode. */ A2A_BACKEND_MODE = 'a2ui_composer_a2a_backend_mode', + /** Key for storing configured MCP servers. */ + MCP_SERVERS = 'a2ui_composer_mcp_servers', /** @deprecated Key for retrieving the active workspace prompt in-progress draft content. */ ACTIVE_DRAFT = 'a2ui_composer_active_draft', diff --git a/shell/src/config.json b/shell/src/config.json index 3e283d205..9f2398659 100644 --- a/shell/src/config.json +++ b/shell/src/config.json @@ -8,6 +8,10 @@ "rendererUrl": "http://localhost:4200/samples/ng-basic-catalog/", "displayName": "Angular Basic (local)" }, + "angular-mcp-dev": { + "rendererUrl": "http://localhost:3459/", + "displayName": "Angular Basic with MCP (local)" + }, "lit": { "rendererUrl": "https://a2ui-project.github.io/composer/samples/lit-basic-catalog/", "displayName": "Lit Basic (github)" diff --git a/shell/src/global_styles.scss b/shell/src/global_styles.scss index 2bfdd9012..9ec135aa8 100644 --- a/shell/src/global_styles.scss +++ b/shell/src/global_styles.scss @@ -249,18 +249,34 @@ body { font-family: Roboto, 'Helvetica Neue', sans-serif; } -/* High-specificity global overrides resolving slide toggle label checks collisions */ +/* High-specificity global overrides resolving slide toggle M2/M3 token and label collisions */ +body .mat-mdc-slide-toggle { + --mat-slide-toggle-track-width: 52px; + --mat-slide-toggle-track-height: 32px; + --mat-slide-toggle-track-shape: 9999px; + --mat-slide-toggle-handle-width: 100%; + --mat-slide-toggle-handle-height: 24px; + --mat-slide-toggle-handle-shape: 9999px; + --mat-slide-toggle-with-icon-handle-size: 24px; + --mat-slide-toggle-selected-handle-size: 24px; + --mat-slide-toggle-unselected-handle-size: 16px; + --mat-slide-toggle-selected-handle-horizontal-margin: 0 24px; + --mat-slide-toggle-selected-with-icon-handle-horizontal-margin: 0 24px; + --mat-slide-toggle-unselected-handle-horizontal-margin: 0 8px; + --mat-slide-toggle-unselected-with-icon-handle-horizontal-margin: 0 4px; +} + body .mat-mdc-slide-toggle .mdc-form-field, body .mat-mdc-slide-toggle .mat-internal-form-field { display: inline-flex !important; align-items: center !important; - gap: 16px !important; // Clean spacer gap between switch and text label + gap: 12px !important; } -body .mat-mdc-slide-toggle label, -body .mat-mdc-slide-toggle .mdc-label { - padding-left: 16px !important; // Safe spacing cushion blocking handle overlaps - white-space: normal !important; // Forces clean wrapping on narrow viewports +body .mat-mdc-slide-toggle label:not(:empty), +body .mat-mdc-slide-toggle .mdc-label:not(:empty) { + padding-left: 4px !important; + white-space: normal !important; line-height: 1.4 !important; color: var(--mat-sys-on-surface) !important; } diff --git a/yarn.lock b/yarn.lock index 98b1eeaf5..0677fda12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2647,6 +2647,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9 || ^2.0.5": + version: 2.1.1 + resolution: "@hono/node-server@npm:2.1.1" + peerDependencies: + hono: ^4 + checksum: 10c0/7ef810ca647c56d8d9386fd81609e18443b3a5634aa5ecabdf357e7ca724613c535e863c15ded9b7ae89b935a073040fb11e6d59041f3b1c3e153d8ecb699880 + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.2": version: 0.19.2 resolution: "@humanfs/core@npm:0.19.2" @@ -3390,6 +3399,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:1.30.0": + version: 1.30.0 + resolution: "@modelcontextprotocol/sdk@npm:1.30.0" + dependencies: + "@hono/node-server": "npm:^1.19.9 || ^2.0.5" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10c0/6d12d5c88048b42b0d64906222f804eecbfc0aae02fc42a200d2438f037276cc7863510a8ce649e82c96d5972f62a58bfbdedf79624b0558a607738f5e58b97e + languageName: node + linkType: hard + "@monaco-editor/loader@npm:1.7.0": version: 1.7.0 resolution: "@monaco-editor/loader@npm:1.7.0" @@ -5201,6 +5243,13 @@ __metadata: languageName: node linkType: hard +"@types/jmespath@npm:0.15.2": + version: 0.15.2 + resolution: "@types/jmespath@npm:0.15.2" + checksum: 10c0/61e45c2785f845f758df3cdb093ad30d32d3ca9c6e20c5ddf6eeb74125d7b6c0426ac175ca994fec47517b7fb0a4b89a91d47155709fec15ab03969ac3bcadc5 + languageName: node + linkType: hard + "@types/jsdom@npm:28.0.3": version: 28.0.3 resolution: "@types/jsdom@npm:28.0.3" @@ -5910,21 +5959,27 @@ __metadata: version: 0.0.0-use.local resolution: "a2ui-bridge@workspace:bridge" dependencies: + "@a2ui/web_core": "npm:0.10.5" "@angular/core": "npm:22.0.7" "@lit/context": "npm:1.1.6" + "@modelcontextprotocol/sdk": "npm:1.30.0" + "@types/jmespath": "npm:0.15.2" "@types/react": "npm:19.2.17" "@types/react-dom": "npm:19.2.3" "@vitest/coverage-v8": "npm:4.1.10" esbuild: "npm:0.28.1" eslint: "npm:10.7.0" + jmespath: "npm:0.16.0" jsdom: "npm:29.1.1" lit: "npm:3.3.3" prettier: "npm:3.9.6" + re2js: "npm:2.8.6" react: "npm:19.2.8" react-dom: "npm:19.2.8" typescript: "npm:6.0.3" typescript-eslint: "npm:8.65.0" vitest: "npm:4.1.11" + zod: "npm:^4.1.12" peerDependencies: "@angular/core": 22.0.7 lit: 3.3.3 @@ -5972,6 +6027,7 @@ __metadata: "@angular/platform-browser-dynamic": "npm:22.0.7" "@angular/router": "npm:22.0.7" "@google/genai": "npm:2.11.0" + "@modelcontextprotocol/sdk": "npm:1.30.0" "@monaco-editor/loader": "npm:1.7.0" "@playwright/test": "npm:1.61.1" "@types/dompurify": "npm:3.2.0" @@ -8979,6 +9035,13 @@ __metadata: languageName: node linkType: hard +"jmespath@npm:0.16.0": + version: 0.16.0 + resolution: "jmespath@npm:0.16.0" + checksum: 10c0/84cdca62c4a3d339701f01cc53decf16581c76ce49e6455119be1c5f6ab09a19e6788372536bd261d348d21cd817981605f8debae67affadba966219a2bac1c5 + languageName: node + linkType: hard + "jose@npm:^6.1.3": version: 6.2.3 resolution: "jose@npm:6.2.3" @@ -10143,6 +10206,40 @@ __metadata: languageName: unknown linkType: soft +"ng-basic-with-mcp-catalog@workspace:samples/ng-basic-with-mcp-catalog": + version: 0.0.0-use.local + resolution: "ng-basic-with-mcp-catalog@workspace:samples/ng-basic-with-mcp-catalog" + dependencies: + "@a2ui/angular": "npm:0.10.4" + "@a2ui/markdown-it": "npm:0.1.0" + "@a2ui/web_core": "npm:0.10.5" + "@analogjs/vite-plugin-angular": "npm:2.6.3" + "@angular-devkit/build-angular": "npm:22.0.7" + "@angular/animations": "npm:22.0.7" + "@angular/build": "npm:22.0.7" + "@angular/cli": "npm:22.0.7" + "@angular/common": "npm:22.0.7" + "@angular/compiler": "npm:22.0.7" + "@angular/compiler-cli": "npm:22.0.7" + "@angular/core": "npm:22.0.7" + "@angular/forms": "npm:22.0.7" + "@angular/platform-browser": "npm:22.0.7" + "@angular/platform-browser-dynamic": "npm:22.0.7" + "@angular/router": "npm:22.0.7" + "@types/jsdom": "npm:28.0.3" + a2ui-bridge: "npm:0.1.0" + angular-eslint: "npm:22.1.0" + eslint: "npm:10.7.0" + jsdom: "npm:29.1.1" + prettier: "npm:3.9.6" + rxjs: "npm:7.8.2" + tslib: "npm:2.8.1" + typescript: "npm:6.0.3" + typescript-eslint: "npm:8.65.0" + vitest: "npm:4.1.11" + languageName: unknown + linkType: soft + "node-addon-api@npm:^6.1.0": version: 6.1.0 resolution: "node-addon-api@npm:6.1.0" @@ -11122,6 +11219,13 @@ __metadata: languageName: node linkType: hard +"re2js@npm:2.8.6": + version: 2.8.6 + resolution: "re2js@npm:2.8.6" + checksum: 10c0/70a7ae4778429ddd8dfef6af3ee65e152999e31a894f19f3eb4820f5267d2ac0c5797b60f988139a5d72a9f06bb5c0b7fd05c4aaa6272970b006772aa956fb51 + languageName: node + linkType: hard + "react-basic-catalog@npm:0.1.0, react-basic-catalog@workspace:samples/react-basic-catalog": version: 0.0.0-use.local resolution: "react-basic-catalog@workspace:samples/react-basic-catalog" @@ -13542,3 +13646,10 @@ __metadata: checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c languageName: node linkType: hard + +"zod@npm:^4.1.12": + version: 4.6.5 + resolution: "zod@npm:4.6.5" + checksum: 10c0/32eb6c5e98becbe045675b2141c3611451c16ba567f9779231d79af41c4d651ca1c22f154aef7e6c6e77b929f23e43f1154ca05b82e119e922353725d6776af5 + languageName: node + linkType: hard