From 2c498d94e4f833de3c2e91be5f5f971632619791 Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 12:40:27 -0400 Subject: [PATCH 01/16] feat: implement A2UI MCP catalog and live MCP server support Vendor v0.9 MCP catalog functions (callMcpTool, jmespath, split, regexCapture, regexReplace, updateDataModel) into a2ui-bridge and update the Lit, Angular, and React basic catalog sample definitions to use basic_with_mcp. Add MCP_REQUEST and MCP_RESPONSE cross-frame proxy messaging between PreviewBridge (IframeMcpClient) and HostCommunication, backed by McpClientManagerService in the Composer shell. Add MCP server management in Settings and an MCP Servers toggle in ChatPanel that injects active server tool schemas into the LLM system prompt. --- bridge/package.json | 8 + bridge/src/angular/angular-bridge.spec.ts | 6 +- bridge/src/angular/angular-bridge.ts | 34 +- bridge/src/bridge-message.ts | 17 + bridge/src/index.ts | 2 + bridge/src/lit/lit-bridge.ts | 46 ++- bridge/src/mcp/dynamic-values.ts | 141 +++++++ bridge/src/mcp/functions/callMcpTool.ts | 312 ++++++++++++++ bridge/src/mcp/functions/common.ts | 147 +++++++ bridge/src/mcp/functions/jmespath.ts | 76 ++++ bridge/src/mcp/functions/regexCapture.ts | 63 +++ bridge/src/mcp/functions/regexReplace.ts | 62 +++ bridge/src/mcp/functions/split.ts | 45 ++ bridge/src/mcp/functions/updateDataModel.ts | 70 ++++ bridge/src/mcp/iframe-mcp-client.ts | 79 ++++ bridge/src/mcp/index.ts | 99 +++++ bridge/src/mcp/mcp.spec.ts | 383 ++++++++++++++++++ bridge/src/preview-bridge.ts | 49 ++- bridge/src/react/react-bridge.ts | 41 +- samples/lit-basic-catalog/public/catalog | 201 ++++++++- .../ng-basic-catalog/src/assets/catalog.json | 207 +++++++++- samples/ng-basic-catalog/src/main.spec.ts | 2 + samples/react-basic-catalog/public/catalog | 201 ++++++++- shell/e2e/gallery.e2e.ts | 2 +- shell/package.json | 1 + .../app/chat/chat-panel/chat-panel.ng.html | 16 + shell/src/app/chat/chat-panel/chat-panel.scss | 20 +- shell/src/app/chat/chat-panel/chat-panel.ts | 10 + .../chat-prompt-factory.service.ts | 67 ++- .../mcp/mcp-client-manager.service.spec.ts | 157 +++++++ .../src/app/mcp/mcp-client-manager.service.ts | 246 +++++++++++ .../settings/settings-view/settings.ng.html | 95 +++++ .../app/settings/settings-view/settings.scss | 93 +++++ .../settings/settings-view/settings.spec.ts | 4 +- .../app/settings/settings-view/settings.ts | 31 +- .../cross-frame-validator.ts | 19 + .../host-communication/host-communication.ts | 32 +- .../app/storage/models/local-storage-keys.ts | 4 + yarn.lock | 105 +++++ 39 files changed, 3127 insertions(+), 66 deletions(-) create mode 100644 bridge/src/mcp/dynamic-values.ts create mode 100644 bridge/src/mcp/functions/callMcpTool.ts create mode 100644 bridge/src/mcp/functions/common.ts create mode 100644 bridge/src/mcp/functions/jmespath.ts create mode 100644 bridge/src/mcp/functions/regexCapture.ts create mode 100644 bridge/src/mcp/functions/regexReplace.ts create mode 100644 bridge/src/mcp/functions/split.ts create mode 100644 bridge/src/mcp/functions/updateDataModel.ts create mode 100644 bridge/src/mcp/iframe-mcp-client.ts create mode 100644 bridge/src/mcp/index.ts create mode 100644 bridge/src/mcp/mcp.spec.ts create mode 100644 shell/src/app/mcp/mcp-client-manager.service.spec.ts create mode 100644 shell/src/app/mcp/mcp-client-manager.service.ts diff --git a/bridge/package.json b/bridge/package.json index edc5589b3..fd85e1b77 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": "portal:../../a2ui/node_modules/@modelcontextprotocol/sdk", + "jmespath": "portal:../../a2ui/node_modules/jmespath", + "re2js": "portal:../../a2ui/node_modules/re2js", + "zod": "^4.1.12" + }, "devDependencies": { "@angular/core": "22.0.7", "@lit/context": "1.1.6", + "@types/jmespath": "portal:../../a2ui/node_modules/@types/jmespath", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitest/coverage-v8": "4.1.10", diff --git a/bridge/src/angular/angular-bridge.spec.ts b/bridge/src/angular/angular-bridge.spec.ts index f4e71831d..ba7199a53 100644 --- a/bridge/src/angular/angular-bridge.spec.ts +++ b/bridge/src/angular/angular-bridge.spec.ts @@ -168,7 +168,11 @@ describe('Angular Sandbox Connection Spec', () => { // 2. Verify A2UI_RENDERER_CONFIG factory and actionHandler const config = TestBed.inject(A2UI_RENDERER_CONFIG); - expect(config.catalogs).toEqual([mockCatalogInstance]); + expect(config.catalogs).toHaveLength(2); + expect((config.catalogs[0] as {id: string}).id).toBe( + 'https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json', + ); + expect(config.catalogs[1]).toBe(mockCatalogInstance); const sendActionSpy = vi.spyOn(a2uiBridge, 'sendAction'); const dummyAction: A2uiClientAction = { diff --git a/bridge/src/angular/angular-bridge.ts b/bridge/src/angular/angular-bridge.ts index 402e2873f..7fac9d94a 100644 --- a/bridge/src/angular/angular-bridge.ts +++ b/bridge/src/angular/angular-bridge.ts @@ -29,16 +29,28 @@ import { A2UI_RENDERER_CONFIG, provideMarkdownRenderer, } from '@a2ui/angular/v0_9'; -import {Catalog, ComponentApi} from '@a2ui/web_core/v0_9'; +import {Catalog, ComponentApi, FunctionImplementation} from '@a2ui/web_core/v0_9'; import { a2uiBridge, ThemePreference, RendererProcessor, SurfaceStateSubscription, CatalogDetails, + createMcpCatalogFunctions, type ComponentUsages, } from '../index.js'; +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); +} + export interface AngularSandboxOptions { /** Optional custom markdown rendering delegate callback hook */ markdownRendererFn?: (markdown: string) => Promise; @@ -154,8 +166,26 @@ export function provideA2uiSandbox( provide: A2UI_RENDERER_CONFIG, useFactory: () => { const resolvedCatalogs = catalogsClasses.map(cls => inject(cls)); + const baseCatalog = resolvedCatalogs[0]; + const baseComponents = extractCatalogEntries(baseCatalog?.components); + const baseFunctions = extractCatalogEntries(baseCatalog?.functions); + const mcpFunctions = createMcpCatalogFunctions( + { + processMessages: msgs => { + const activeProcessor = ( + a2uiBridge as unknown as {activeRenderer?: {processor?: RendererProcessor}} + ).activeRenderer?.processor; + activeProcessor?.processMessages(msgs); + }, + }, + async server => a2uiBridge.getMcpClient(server), + ); + const mcpCatalog = new Catalog(BASIC_WITH_MCP_CATALOG_ID, baseComponents, [ + ...baseFunctions, + ...mcpFunctions, + ]); return { - catalogs: resolvedCatalogs, + catalogs: [mcpCatalog, ...resolvedCatalogs], actionHandler: (action: unknown) => { a2uiBridge.sendAction(action); }, diff --git a/bridge/src/bridge-message.ts b/bridge/src/bridge-message.ts index 7ed544bdc..390121a59 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,18 @@ export declare interface A2uiComponentInstance { id?: string; [key: string]: unknown; } + +/** Payload for MCP_REQUEST message type. */ +export declare interface McpRequestPayload { + requestId: string; + server: 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/index.ts b/bridge/src/index.ts index afa864c72..cbc1659bb 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -17,6 +17,8 @@ export * from './bridge-message.js'; export * from './domain-origin-verification.js'; export * from './instrumentation-overrides.js'; +export * from './mcp/index.js'; +export * from './mcp/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/lit/lit-bridge.ts b/bridge/src/lit/lit-bridge.ts index 6ea7986e4..9d1ad19db 100644 --- a/bridge/src/lit/lit-bridge.ts +++ b/bridge/src/lit/lit-bridge.ts @@ -23,6 +23,7 @@ import { SurfaceModel, Catalog, ComponentApi, + FunctionImplementation, A2uiClientAction, } from '@a2ui/web_core/v0_9'; import type {MarkdownRenderer} from '@a2ui/web_core/types/types'; @@ -31,9 +32,21 @@ import { ThemePreference, SurfaceStateSubscription, CatalogDetails, + createMcpCatalogFunctions, type ComponentUsages, } from '../index.js'; +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); +} + /** * Options block configuring custom element generation and static payload injection * for the Lit component rendering sandbox. @@ -79,13 +92,31 @@ export class A2uiSandboxRoot extends LitElement { /** Optional callback when theme changes shared statically */ static onThemeChange?: (theme: ThemePreference) => void = undefined; + private runtimeCatalogs: Catalog[] = []; + // Core dynamic processing engine mapping actions outbox proxies - private processor = new MessageProcessor( - (this.constructor as typeof A2uiSandboxRoot).catalogs, - (action: A2uiClientAction) => { + private processor: MessageProcessor = (() => { + const baseCatalog = (this.constructor as typeof A2uiSandboxRoot).catalogs[0]; + const baseComponents = extractCatalogEntries(baseCatalog?.components); + const baseFunctions = extractCatalogEntries(baseCatalog?.functions); + const procRef: {current?: MessageProcessor} = {}; + const mcpFunctions = createMcpCatalogFunctions( + { + processMessages: msgs => procRef.current?.processMessages(msgs), + }, + async server => a2uiBridge.getMcpClient(server), + ); + const mcpCatalog = new Catalog(BASIC_WITH_MCP_CATALOG_ID, baseComponents, [ + ...baseFunctions, + ...mcpFunctions, + ]); + this.runtimeCatalogs = [mcpCatalog, ...(this.constructor as typeof A2uiSandboxRoot).catalogs]; + const proc = new MessageProcessor(this.runtimeCatalogs, (action: A2uiClientAction) => { a2uiBridge.sendAction(action); - }, - ); + }); + procRef.current = proc; + return proc; + })(); private markdownProvider = new ContextProvider(this, { context: Context.markdown, @@ -132,7 +163,10 @@ export class A2uiSandboxRoot extends LitElement { getComponentUsages: (this.constructor as typeof A2uiSandboxRoot).getComponentUsages, onThemeChange: (this.constructor as typeof A2uiSandboxRoot).onThemeChange, onCatalogResolved: catalogId => { - for (const catalog of (this.constructor as typeof A2uiSandboxRoot).catalogs) { + for (const catalog of [ + ...this.runtimeCatalogs, + ...(this.constructor as typeof A2uiSandboxRoot).catalogs, + ]) { if (catalog) { (catalog as unknown as CatalogDetails).id = catalogId; } diff --git a/bridge/src/mcp/dynamic-values.ts b/bridge/src/mcp/dynamic-values.ts new file mode 100644 index 000000000..d243a1f03 --- /dev/null +++ b/bridge/src/mcp/dynamic-values.ts @@ -0,0 +1,141 @@ +/* + * 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. + */ + +/** + * 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..a0d4b01b2 --- /dev/null +++ b/bridge/src/mcp/functions/callMcpTool.ts @@ -0,0 +1,312 @@ +/* + * 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. + */ + +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 or server 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 = ( + toolOrServer: 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.', + ), + server: (DynamicStringSchema as unknown as z.ZodTypeAny) + .optional() + .describe('Optional MCP server name.'), + 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 {tools} = await client.listTools(); + 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']); + const serverName = args['server'] + ? context.resolveDynamicValue(args['server']) + : undefined; + + try { + const resolvedArguments = resolveDynamicRecord( + (args['arguments'] as Record | undefined) ?? {}, + context, + ); + + const client = await getMcpClientForTool(serverName ?? 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 || !('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..29380fa91 --- /dev/null +++ b/bridge/src/mcp/functions/common.ts @@ -0,0 +1,147 @@ +/* + * 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. + */ + +/** + * 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..5a621ed3b --- /dev/null +++ b/bridge/src/mcp/functions/jmespath.ts @@ -0,0 +1,76 @@ +/* + * 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. + */ + +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..2b0133594 --- /dev/null +++ b/bridge/src/mcp/functions/regexCapture.ts @@ -0,0 +1,63 @@ +/* + * 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. + */ + +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..faa425ede --- /dev/null +++ b/bridge/src/mcp/functions/regexReplace.ts @@ -0,0 +1,62 @@ +/* + * 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. + */ + +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..9cf0d0dc0 --- /dev/null +++ b/bridge/src/mcp/functions/split.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +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..fdf093ea5 --- /dev/null +++ b/bridge/src/mcp/functions/updateDataModel.ts @@ -0,0 +1,70 @@ +/* + * 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. + */ + +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/iframe-mcp-client.ts b/bridge/src/mcp/iframe-mcp-client.ts new file mode 100644 index 000000000..3875d7bab --- /dev/null +++ b/bridge/src/mcp/iframe-mcp-client.ts @@ -0,0 +1,79 @@ +/** + * 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 server: string, + 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}" on server "${this.server}" timed out after ${this.timeoutMs}ms`, + ), + ); + }, this.timeoutMs); + + this.pendingRequests.set(requestId, {resolve, reject, timer}); + + this.sendMcpRequest({ + requestId, + server: this.server, + 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/mcp/index.ts b/bridge/src/mcp/index.ts new file mode 100644 index 000000000..75fdb2254 --- /dev/null +++ b/bridge/src/mcp/index.ts @@ -0,0 +1,99 @@ +/* + * 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. + */ + +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..ea72ceefe --- /dev/null +++ b/bridge/src/mcp/mcp.spec.ts @@ -0,0 +1,383 @@ +/** + * 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 {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {Catalog, MessageProcessor, type ComponentApi} from '@a2ui/web_core/v0_9'; +import { + IframeMcpClient, + 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('IframeMcpClient', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves callTool when matching MCP_RESPONSE is received', async () => { + const sentPayloads: Array<{ + requestId: string; + server: string; + toolName: string; + args: Record; + }> = []; + const client = new IframeMcpClient('fs', payload => { + sentPayloads.push(payload); + }); + + const promise = client.callTool({name: 'list_dir', arguments: {path: '/root'}}); + expect(sentPayloads).toHaveLength(1); + expect(sentPayloads[0].server).toBe('fs'); + 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('fs', 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('fs', () => {}, 1000); + const promise = client.callTool({name: 'slow_tool'}); + vi.advanceTimersByTime(1001); + await expect(promise).rejects.toThrow(/timed out/); + }); + }); + + 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', server: 'fs', 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 server => { + if (server === 'broken') { + 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', server: 'ok', 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: 'rich_tool', server: 'broken', arguments: {}}, mockCtx), + ).rejects.toThrow(/Server offline/); + }); + + it('parses A2UI resources and UI resource URIs', () => { + 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'}}]); + }); + }); + + 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('fs'); + expect(bridge.getMcpClient('fs')).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..b10b3ee35 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 './mcp/iframe-mcp-client'; + import {SurfaceResizeObserver} from './surface-resize-observer'; export * from './surface-resize-observer'; @@ -220,6 +223,26 @@ export class PreviewBridge { private readonly cachedParentOrigin: string | null = null; + /** Registry of active iframe MCP proxy clients keyed by server name. */ + private readonly mcpClients = new Map(); + + /** + * Returns an IframeMcpClient proxy for the given server name, creating and caching it if needed. + */ + getMcpClient(server: string): IframeMcpClient { + let client = this.mcpClients.get(server); + if (!client) { + client = new IframeMcpClient(server, payload => { + this.sendMessage({ + type: PreviewBridgeMessageType.MCP_REQUEST, + payload, + }); + }); + this.mcpClients.set(server, client); + } + return client; + } + /** * 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 +409,7 @@ export class PreviewBridge { } } this.activeConnections.clear(); + this.mcpClients.clear(); } private resolveExpectedParentOrigin(): string { @@ -475,6 +499,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 +512,17 @@ export class PreviewBridge { } }; + /** + * Routes incoming MCP_RESPONSE messages to the matching IframeMcpClient pending request. + */ + private handleMcpResponse(payload: unknown): void { + const payloadObj = payload as McpResponsePayload | undefined; + if (!payloadObj || typeof payloadObj.requestId !== 'string') return; + for (const client of this.mcpClients.values()) { + client.handleResponse(payloadObj); + } + } + /** * Handles incoming theme change requests. */ @@ -633,7 +672,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/bridge/src/react/react-bridge.ts b/bridge/src/react/react-bridge.ts index c043f7f2c..a2653407e 100644 --- a/bridge/src/react/react-bridge.ts +++ b/bridge/src/react/react-bridge.ts @@ -20,9 +20,27 @@ import { SurfaceModel, Catalog, ComponentApi, + FunctionImplementation, A2uiClientAction, } from '@a2ui/web_core/v0_9'; -import {a2uiBridge, ThemePreference, CatalogDetails, ComponentUsages} from '../index.js'; +import { + a2uiBridge, + ThemePreference, + CatalogDetails, + ComponentUsages, + createMcpCatalogFunctions, +} from '../index.js'; + +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); +} export interface UseA2uiSandboxResult { /** The reactive dynamic surface drawing model representing the active canvas. */ @@ -54,10 +72,27 @@ export function useA2uiSandbox( const [surface, setSurface] = useState | undefined>(undefined); useEffect(() => { + const procRef: {current?: MessageProcessor} = {}; + const baseCatalog = catalogs[0]; + const baseComponents = extractCatalogEntries(baseCatalog?.components); + const baseFunctions = extractCatalogEntries(baseCatalog?.functions); + const mcpFunctions = createMcpCatalogFunctions( + { + processMessages: msgs => procRef.current?.processMessages(msgs), + }, + async server => a2uiBridge.getMcpClient(server), + ); + const mcpCatalog = new Catalog(BASIC_WITH_MCP_CATALOG_ID, baseComponents, [ + ...baseFunctions, + ...mcpFunctions, + ]); + const runtimeCatalogs = [mcpCatalog, ...catalogs]; + // Instantiates a new dynamic MessageProcessor mapping outbound event actions - const processor = new MessageProcessor(catalogs, (action: A2uiClientAction) => { + const processor = new MessageProcessor(runtimeCatalogs, (action: A2uiClientAction) => { a2uiBridge.sendAction(action); }); + procRef.current = processor; // Connects the renderer stack and establishes inter-frame callbacks const connection = a2uiBridge.attachRenderer(processor, { @@ -66,7 +101,7 @@ export function useA2uiSandbox( getComponentUsages: options?.getComponentUsages, onThemeChange: options?.onThemeChange, onCatalogResolved: catalogId => { - for (const catalog of catalogs) { + for (const catalog of runtimeCatalogs) { if (catalog) { (catalog as unknown as CatalogDetails).id = catalogId; } diff --git a/samples/lit-basic-catalog/public/catalog b/samples/lit-basic-catalog/public/catalog index 23df99d47..99005e871 100644 --- a/samples/lit-basic-catalog/public/catalog +++ b/samples/lit-basic-catalog/public/catalog @@ -1,26 +1,185 @@ { "$schema": "https://a2ui.org/specification/v0_9/catalog.json", - "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", - "title": "Basic Catalog", - "description": "The standard A2UI components list for the basic catalog.", + "catalogId": "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.", "components": { - "Text": { "name": "Text" }, - "Image": { "name": "Image" }, - "Icon": { "name": "Icon" }, - "Video": { "name": "Video" }, - "AudioPlayer": { "name": "AudioPlayer" }, - "Row": { "name": "Row" }, - "Column": { "name": "Column" }, - "List": { "name": "List" }, - "Card": { "name": "Card" }, - "Tabs": { "name": "Tabs" }, - "Modal": { "name": "Modal" }, - "Divider": { "name": "Divider" }, - "Button": { "name": "Button" }, - "TextField": { "name": "TextField" }, - "CheckBox": { "name": "CheckBox" }, - "ChoicePicker": { "name": "ChoicePicker" }, - "Slider": { "name": "Slider" }, - "DateTimeInput": { "name": "DateTimeInput" } + "Text": {"name": "Text"}, + "Image": {"name": "Image"}, + "Icon": {"name": "Icon"}, + "Video": {"name": "Video"}, + "AudioPlayer": {"name": "AudioPlayer"}, + "Row": {"name": "Row"}, + "Column": {"name": "Column"}, + "List": {"name": "List"}, + "Card": {"name": "Card"}, + "Tabs": {"name": "Tabs"}, + "Modal": {"name": "Modal"}, + "Divider": {"name": "Divider"}, + "Button": {"name": "Button"}, + "TextField": {"name": "TextField"}, + "CheckBox": {"name": "CheckBox"}, + "ChoicePicker": {"name": "ChoicePicker"}, + "Slider": {"name": "Slider"}, + "DateTimeInput": {"name": "DateTimeInput"} + }, + "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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "The name of the MCP tool to execute." + }, + "server": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "Optional MCP server name." + }, + "arguments": { + "type": "object", + "description": "The arguments to pass to the MCP tool.", + "additionalProperties": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "A standard JMESPath expression evaluated against `data`." + }, + "data": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to split." + }, + "separator": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to match against." + }, + "pattern": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to perform replacements on." + }, + "pattern": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "An RE2 regular expression matching substrings to replace." + }, + "replacement": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "An object mapping data model paths to the values to write (e.g., {\"/entries\": [...], \"/title\": \"Home\"}). Paths starting with \"/\" are absolute; relative paths resolve against the current data context." + } + }, + "required": ["updates"], + "additionalProperties": false + }, + "returnType": {"const": "any"} + }, + "required": ["call", "args"], + "unevaluatedProperties": false + } } } diff --git a/samples/ng-basic-catalog/src/assets/catalog.json b/samples/ng-basic-catalog/src/assets/catalog.json index 2e59b9e3c..d0758fa74 100644 --- a/samples/ng-basic-catalog/src/assets/catalog.json +++ b/samples/ng-basic-catalog/src/assets/catalog.json @@ -1,9 +1,9 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "my_basic_catalog.json", - "title": "my_basic_catalog A2UI Catalog", - "description": "my_basic_catalog A2UI catalog, including catalog.", - "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "$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", @@ -802,6 +802,187 @@ } }, "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." + }, + "server": { + "$ref": "#/$defs/common_types_DynamicString", + "description": "Optional MCP server name." + }, + "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.", @@ -2911,6 +3092,24 @@ }, { "$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-catalog/src/main.spec.ts b/samples/ng-basic-catalog/src/main.spec.ts index e79ac3f50..42bc9c30f 100644 --- a/samples/ng-basic-catalog/src/main.spec.ts +++ b/samples/ng-basic-catalog/src/main.spec.ts @@ -34,7 +34,9 @@ vi.mock('a2ui-bridge', () => ({ attachRenderer: vi.fn().mockReturnValue({unsubscribe: vi.fn()}), sendMessage: vi.fn(), sendAction: vi.fn(), + getMcpClient: vi.fn(), }, + createMcpCatalogFunctions: vi.fn().mockReturnValue([]), })); describe('A2uiSandbox', () => { diff --git a/samples/react-basic-catalog/public/catalog b/samples/react-basic-catalog/public/catalog index 23df99d47..99005e871 100644 --- a/samples/react-basic-catalog/public/catalog +++ b/samples/react-basic-catalog/public/catalog @@ -1,26 +1,185 @@ { "$schema": "https://a2ui.org/specification/v0_9/catalog.json", - "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", - "title": "Basic Catalog", - "description": "The standard A2UI components list for the basic catalog.", + "catalogId": "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.", "components": { - "Text": { "name": "Text" }, - "Image": { "name": "Image" }, - "Icon": { "name": "Icon" }, - "Video": { "name": "Video" }, - "AudioPlayer": { "name": "AudioPlayer" }, - "Row": { "name": "Row" }, - "Column": { "name": "Column" }, - "List": { "name": "List" }, - "Card": { "name": "Card" }, - "Tabs": { "name": "Tabs" }, - "Modal": { "name": "Modal" }, - "Divider": { "name": "Divider" }, - "Button": { "name": "Button" }, - "TextField": { "name": "TextField" }, - "CheckBox": { "name": "CheckBox" }, - "ChoicePicker": { "name": "ChoicePicker" }, - "Slider": { "name": "Slider" }, - "DateTimeInput": { "name": "DateTimeInput" } + "Text": {"name": "Text"}, + "Image": {"name": "Image"}, + "Icon": {"name": "Icon"}, + "Video": {"name": "Video"}, + "AudioPlayer": {"name": "AudioPlayer"}, + "Row": {"name": "Row"}, + "Column": {"name": "Column"}, + "List": {"name": "List"}, + "Card": {"name": "Card"}, + "Tabs": {"name": "Tabs"}, + "Modal": {"name": "Modal"}, + "Divider": {"name": "Divider"}, + "Button": {"name": "Button"}, + "TextField": {"name": "TextField"}, + "CheckBox": {"name": "CheckBox"}, + "ChoicePicker": {"name": "ChoicePicker"}, + "Slider": {"name": "Slider"}, + "DateTimeInput": {"name": "DateTimeInput"} + }, + "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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "The name of the MCP tool to execute." + }, + "server": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "Optional MCP server name." + }, + "arguments": { + "type": "object", + "description": "The arguments to pass to the MCP tool.", + "additionalProperties": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "A standard JMESPath expression evaluated against `data`." + }, + "data": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to split." + }, + "separator": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to match against." + }, + "pattern": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "The string or array of strings to perform replacements on." + }, + "pattern": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", + "description": "An RE2 regular expression matching substrings to replace." + }, + "replacement": { + "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", + "description": "An object mapping data model paths to the values to write (e.g., {\"/entries\": [...], \"/title\": \"Home\"}). Paths starting with \"/\" are absolute; relative paths resolve against the current data context." + } + }, + "required": ["updates"], + "additionalProperties": false + }, + "returnType": {"const": "any"} + }, + "required": ["call", "args"], + "unevaluatedProperties": false + } } } diff --git a/shell/e2e/gallery.e2e.ts b/shell/e2e/gallery.e2e.ts index c99b80e18..3018fe897 100644 --- a/shell/e2e/gallery.e2e.ts +++ b/shell/e2e/gallery.e2e.ts @@ -47,7 +47,7 @@ test.describe('Components Gallery User Journey', () => { await expect(workspaceIframe).toBeVisible(); // Wait for the catalog handshake to complete (indicated by header title updating) - await expect(page.locator('.header-title')).toContainText('my_basic_catalog'); + await expect(page.locator('.header-title')).toContainText('Basic with MCP Catalog'); // 2. Click on the gallery link in the sidebar const galleryLink = page.getByRole('link', {name: 'Components Gallery'}); diff --git a/shell/package.json b/shell/package.json index a9b8a2f29..e0cdb1a69 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": "portal:../../a2ui/node_modules/@modelcontextprotocol/sdk", "@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..f3fd9841f 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.ng.html +++ b/shell/src/app/chat/chat-panel/chat-panel.ng.html @@ -276,6 +276,22 @@ > photo_camera + diff --git a/shell/src/app/chat/chat-panel/chat-panel.scss b/shell/src/app/chat/chat-panel/chat-panel.scss index 1335714e8..4d2080725 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.scss +++ b/shell/src/app/chat/chat-panel/chat-panel.scss @@ -419,7 +419,9 @@ } } - .screenshot-toggle-button { + .screenshot-toggle-button, + .mcp-toggle-button { + position: relative; color: var(--mat-sys-on-surface-variant); transition: color 0.15s ease, @@ -431,6 +433,22 @@ } } + .mcp-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 14px; + height: 14px; + border-radius: 7px; + background-color: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); + font-size: 9px; + line-height: 14px; + text-align: center; + padding: 0 3px; + font-weight: 700; + } + .submit-button { font-size: 12px; font-weight: 500; diff --git a/shell/src/app/chat/chat-panel/chat-panel.ts b/shell/src/app/chat/chat-panel/chat-panel.ts index 89450c016..f7a03ea91 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.ts +++ b/shell/src/app/chat/chat-panel/chat-panel.ts @@ -46,6 +46,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,13 +103,22 @@ 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 mcpEnabledInChat = this.mcpManager.mcpEnabledInChat; + protected readonly activeMcpServerCount = computed( + () => this.mcpManager.getActiveServersWithTools().length, + ); protected onIncludeScreenshotChange(checked: boolean): void { this.includeScreenshot.set(checked); } + protected toggleMcpInChat(): void { + this.mcpManager.setMcpEnabledInChat(!this.mcpEnabledInChat()); + } + /** * Reactively computed dynamic system prompt instructions spec viewport * text. 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..a6c46c61b 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 @@ -18,6 +18,7 @@ import {Injectable, computed, inject} from '@angular/core'; import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; import {formatJson} from '../../utils/json'; import {COMMON_TYPES_SCHEMA} from '../../gallery/schema/common-types-schema'; +import {McpClientManagerService, McpServerConfig} from '../../mcp/mcp-client-manager.service'; /** * Constructs dynamic system prompts based on the provided LLM intent and active catalog states. @@ -27,9 +28,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 mcpEnabled = this.mcpManager.mcpEnabledInChat(); + const activeServers = mcpEnabled ? this.mcpManager.getActiveServersWithTools() : []; + const mcpInstructions = this.buildMcpInstructions(activeServers); + if (!catalog) { return ` # A2UI Generation Expert @@ -38,12 +44,71 @@ 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 { + if (activeServers.length === 0) { + return ''; + } + + const serversMarkdown = activeServers + .map(server => { + const toolsList = + server.tools && server.tools.length > 0 + ? server.tools + .map( + tool => + ` - \`${tool.name}\`: ${tool.description || 'No description'} — Input Schema: \`${JSON.stringify(tool.inputSchema || {})}\``, + ) + .join('\n') + : ' - (No tools discovered)'; + return `- **Server \`${server.name}\`** (\`${server.url}\`):\n${toolsList}`; + }) + .join('\n\n'); + + return ` + + ## Available MCP Servers & Catalog Instructions + + When building surfaces that interact with MCP tools: + 1. Use \`"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json"\` in \`createSurface\`. + 2. Trigger MCP tools via button \`functionCall\` actions that chain \`updateDataModel\`, \`jmespath\`, and \`callMcpTool\`: + \`\`\`json + "action": { + "functionCall": { + "call": "updateDataModel", + "args": { + "updates": { + "/result": { + "call": "jmespath", + "args": { + "expression": "content[0].text", + "data": { + "call": "callMcpTool", + "args": { + "server": "${activeServers[0].name}", + "name": "", + "arguments": {} + } + } + } + } + } + } + } + } + \`\`\` + + ### Connected MCP Servers + ${serversMarkdown} +`; + } + private generateSystemPrompt(catalog: string): string { return ` # A2UI Generation Expert 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..096d4a149 --- /dev/null +++ b/shell/src/app/mcp/mcp-client-manager.service.spec.ts @@ -0,0 +1,157 @@ +/** + * 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 listToolsMock = vi.fn(); +const callToolMock = vi.fn(); +const closeMock = vi.fn(); + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: class { + connect = connectMock; + 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); + 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, connects, toggles, calls tool, and removes an MCP server', async () => { + await service.addServer('fs', 'http://localhost:3001/mcp'); + const servers = service.servers(); + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe('fs'); + expect(servers[0].status).toBe('connected'); + expect(servers[0].tools).toHaveLength(1); + expect(service.getActiveServersWithTools()).toHaveLength(1); + + const res = await service.callTool('fs', '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, missing server callTool, and persists mcpEnabledInChat toggle', async () => { + connectMock.mockRejectedValueOnce(new Error('Connection refused')); + await service.addServer('broken', 'http://localhost:9999/mcp'); + expect(service.servers()[0].status).toBe('error'); + expect(service.servers()[0].errorMessage).toContain('Connection refused'); + + await expect(service.callTool('unknown-server', 'tool', {})).rejects.toThrow( + /No connected MCP server found/, + ); + + service.setMcpEnabledInChat(false); + expect(service.mcpEnabledInChat()).toBe(false); + expect(mockStorage.setItem).toHaveBeenCalledWith(LocalStorageKey.MCP_ENABLED_IN_CHAT, 'false'); + }); + + 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, + }, + ]), + ); + storageMap.set(LocalStorageKey.MCP_ENABLED_IN_CHAT, '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'); + expect(hydratedService.mcpEnabledInChat()).toBe(false); + }); +}); 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..5518c0bec --- /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; +} + +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([]); + readonly mcpEnabledInChat = signal(true); + + 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, + url: s.url, + enabled: Boolean(s.enabled), + status: 'disconnected', + tools: [], + })), + ); + } + } catch { + this.servers.set([]); + } + } + + const rawEnabled = this.storage.getItem(LocalStorageKey.MCP_ENABLED_IN_CHAT); + if (rawEnabled !== null) { + this.mcpEnabledInChat.set(rawEnabled !== 'false'); + } + } + + 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)); + } + + setMcpEnabledInChat(enabled: boolean): void { + this.mcpEnabledInChat.set(enabled); + this.storage.setItem(LocalStorageKey.MCP_ENABLED_IN_CHAT, String(enabled)); + } + + async addServer(name: string, url: string): Promise { + const trimmedName = name.trim(); + const trimmedUrl = url.trim(); + if (!trimmedName || !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: trimmedName, + 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) return; + + await this.disconnectServer(id); + + 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 toolsRes = await client.listTools(); + const discoveredTools: McpToolInfo[] = (toolsRes.tools ?? []).map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema as Record | undefined, + })); + + this.clients.set(id, client); + this.servers.update(list => + list.map(s => + s.id === id + ? { + ...s, + status: 'connected', + errorMessage: undefined, + tools: discoveredTools, + } + : s, + ), + ); + } 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( + serverNameOrToolName: string, + toolName: string, + args: Record, + ): Promise { + const activeServers = this.getActiveServersWithTools(); + let targetServer = activeServers.find( + s => s.name === serverNameOrToolName || s.id === serverNameOrToolName, + ); + if (!targetServer) { + targetServer = activeServers.find(s => s.tools?.some(t => t.name === toolName)); + } + if (!targetServer) { + throw new Error( + `No connected MCP server found for "${serverNameOrToolName}" or tool "${toolName}".`, + ); + } + + const client = this.clients.get(targetServer.id); + if (!client) { + throw new Error(`MCP client for server "${targetServer.name}" 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..9a3dccc9f 100644 --- a/shell/src/app/settings/settings-view/settings.ng.html +++ b/shell/src/app/settings/settings-view/settings.ng.html @@ -55,6 +55,101 @@

Developer Authentication Overrides

+ +
+

MCP Servers

+

+ Configure HTTP Model Context Protocol (MCP) servers for live tool execution in A2UI + surfaces. +

+ +
+ + Server Name + + + + Server URL + + + +
+ +
+ @for (server of mcpManager.servers(); track server.id) { +
+
+ +
+
+ {{ server.name }} + + @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 (isThirdParty()) { diff --git a/shell/src/app/settings/settings-view/settings.scss b/shell/src/app/settings/settings-view/settings.scss index 8d7b30437..21dab52bb 100644 --- a/shell/src/app/settings/settings-view/settings.scss +++ b/shell/src/app/settings/settings-view/settings.scss @@ -132,3 +132,96 @@ mat-form-field { padding-left: 24px; padding-bottom: 12px; } + +.mcp-add-row { + display: flex; + gap: 8px; + align-items: flex-start; + margin-top: 12px; + + .mcp-name-field { + flex: 1; + } + + .mcp-url-field { + flex: 2; + } + + .mcp-add-btn { + margin-top: 4px; + height: 48px; + white-space: nowrap; + } +} + +.mcp-server-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.mcp-server-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 8px; + background-color: var(--mat-sys-surface-container-low); + gap: 12px; +} + +.mcp-server-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + flex: 1; +} + +.mcp-server-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.mcp-server-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.mcp-server-name { + font-weight: 600; + font-size: 14px; +} + +.mcp-server-status { + font-size: 12px; + padding: 2px 8px; + border-radius: 12px; + 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-actions { + display: flex; + align-items: center; + gap: 4px; +} diff --git a/shell/src/app/settings/settings-view/settings.spec.ts b/shell/src/app/settings/settings-view/settings.spec.ts index 7bb76c649..c3d9ae5ee 100644 --- a/shell/src/app/settings/settings-view/settings.spec.ts +++ b/shell/src/app/settings/settings-view/settings.spec.ts @@ -379,10 +379,10 @@ describe('Settings', () => { const harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, SettingsHarness); expect(component.isThirdParty()).toBe(true); - expect(await harness.getFormSectionsCount()).toBe(3); + expect(await harness.getFormSectionsCount()).toBe(4); const sections = await harness.getFormSectionsCount(); - expect(sections).toBe(3); + expect(sections).toBe(4); } finally { localStorage.removeItem('a2ui_composer_force_3p'); } diff --git a/shell/src/app/settings/settings-view/settings.ts b/shell/src/app/settings/settings-view/settings.ts index 342f54bb0..b48c36397 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,10 @@ 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 newMcpServerName = signal(''); + readonly newMcpServerUrl = signal(''); protected readonly is1PAuthEnabled = inject(IS_1P_AUTH_ENABLED); @@ -165,4 +173,25 @@ 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 name = this.newMcpServerName().trim(); + const url = this.newMcpServerUrl().trim(); + if (!name || !url) return; + this.newMcpServerName.set(''); + this.newMcpServerUrl.set(''); + await this.mcpManager.addServer(name, 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/shell/cross-frame-validator/cross-frame-validator.ts b/shell/src/app/shell/cross-frame-validator/cross-frame-validator.ts index c40b38c9a..ca9f506d2 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,25 @@ export class CrossFrameValidator { 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.ts b/shell/src/app/shell/host-communication/host-communication.ts index ea1a3c93f..d8d9d6304 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,34 @@ export class HostCommunication implements OnDestroy { return; } + if (type === PreviewBridgeMessageType.MCP_REQUEST) { + const req = data.payload as McpRequestPayload; + const sourceWin = (event.source as Window) ?? null; + void this.mcpManager + .callTool(req.server, req.toolName, req.args) + .then(result => { + this.sendMessage( + { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: {requestId: req.requestId, result}, + }, + sourceWin, + ); + }) + .catch((err: unknown) => { + this.sendMessage( + { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: { + requestId: req.requestId, + error: err instanceof Error ? err.message : String(err), + }, + }, + sourceWin, + ); + }); + } + if (type === PreviewBridgeMessageType.DATA_MODEL_CHANGE) { const payload = data.payload as {validationErrors?: unknown} | undefined; if (payload?.validationErrors) { diff --git a/shell/src/app/storage/models/local-storage-keys.ts b/shell/src/app/storage/models/local-storage-keys.ts index 4016f51bb..f89ec3a32 100644 --- a/shell/src/app/storage/models/local-storage-keys.ts +++ b/shell/src/app/storage/models/local-storage-keys.ts @@ -48,6 +48,10 @@ 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', + /** Key for storing whether MCP servers are enabled in chat. */ + MCP_ENABLED_IN_CHAT = 'a2ui_composer_mcp_enabled_in_chat', /** @deprecated Key for retrieving the active workspace prompt in-progress draft content. */ ACTIVE_DRAFT = 'a2ui_composer_active_draft', diff --git a/yarn.lock b/yarn.lock index 98b1eeaf5..e9c6a289f 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,70 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@portal:../../a2ui/node_modules/@modelcontextprotocol/sdk::locator=a2ui-bridge%40workspace%3Abridge": + version: 0.0.0-use.local + resolution: "@modelcontextprotocol/sdk@portal:../../a2ui/node_modules/@modelcontextprotocol/sdk::locator=a2ui-bridge%40workspace%3Abridge" + 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 + languageName: node + linkType: soft + +"@modelcontextprotocol/sdk@portal:../../a2ui/node_modules/@modelcontextprotocol/sdk::locator=a2ui-composer-shell%40workspace%3Ashell": + version: 0.0.0-use.local + resolution: "@modelcontextprotocol/sdk@portal:../../a2ui/node_modules/@modelcontextprotocol/sdk::locator=a2ui-composer-shell%40workspace%3Ashell" + 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 + languageName: node + linkType: soft + "@monaco-editor/loader@npm:1.7.0": version: 1.7.0 resolution: "@monaco-editor/loader@npm:1.7.0" @@ -5201,6 +5274,12 @@ __metadata: languageName: node linkType: hard +"@types/jmespath@portal:../../a2ui/node_modules/@types/jmespath::locator=a2ui-bridge%40workspace%3Abridge": + version: 0.0.0-use.local + resolution: "@types/jmespath@portal:../../a2ui/node_modules/@types/jmespath::locator=a2ui-bridge%40workspace%3Abridge" + languageName: node + linkType: soft + "@types/jsdom@npm:28.0.3": version: 28.0.3 resolution: "@types/jsdom@npm:28.0.3" @@ -5910,21 +5989,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": "portal:../../a2ui/node_modules/@modelcontextprotocol/sdk" + "@types/jmespath": "portal:../../a2ui/node_modules/@types/jmespath" "@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: "portal:../../a2ui/node_modules/jmespath" jsdom: "npm:29.1.1" lit: "npm:3.3.3" prettier: "npm:3.9.6" + re2js: "portal:../../a2ui/node_modules/re2js" 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 +6057,7 @@ __metadata: "@angular/platform-browser-dynamic": "npm:22.0.7" "@angular/router": "npm:22.0.7" "@google/genai": "npm:2.11.0" + "@modelcontextprotocol/sdk": "portal:../../a2ui/node_modules/@modelcontextprotocol/sdk" "@monaco-editor/loader": "npm:1.7.0" "@playwright/test": "npm:1.61.1" "@types/dompurify": "npm:3.2.0" @@ -8979,6 +9065,12 @@ __metadata: languageName: node linkType: hard +"jmespath@portal:../../a2ui/node_modules/jmespath::locator=a2ui-bridge%40workspace%3Abridge": + version: 0.0.0-use.local + resolution: "jmespath@portal:../../a2ui/node_modules/jmespath::locator=a2ui-bridge%40workspace%3Abridge" + languageName: node + linkType: soft + "jose@npm:^6.1.3": version: 6.2.3 resolution: "jose@npm:6.2.3" @@ -11122,6 +11214,12 @@ __metadata: languageName: node linkType: hard +"re2js@portal:../../a2ui/node_modules/re2js::locator=a2ui-bridge%40workspace%3Abridge": + version: 0.0.0-use.local + resolution: "re2js@portal:../../a2ui/node_modules/re2js::locator=a2ui-bridge%40workspace%3Abridge" + languageName: node + linkType: soft + "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 +13640,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 From 7e29a54b7b0134530990f534bcc77032d8973149 Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 13:18:25 -0400 Subject: [PATCH 02/16] feat: switch chat model to gemini-3.7-flash with medium thinking Update Standalone3pLlmClient to use gemini-3.7-flash with thinkingLevel set to ThinkingLevel.MEDIUM. --- shell/src/app/chat/llm-client/llm-client.spec.ts | 10 +++++++++- .../app/chat/llm-client/standalone-3p-llm-client.ts | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) 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, }; From 42455afecafc8668e20346f18fd64feeb4db4808 Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 14:21:30 -0400 Subject: [PATCH 03/16] e2e flow works --- bridge/src/angular/angular-bridge.ts | 2 +- bridge/src/bridge-message.ts | 1 - bridge/src/lit/lit-bridge.ts | 2 +- bridge/src/mcp/functions/callMcpTool.ts | 12 +-- bridge/src/mcp/iframe-mcp-client.ts | 8 +- bridge/src/mcp/mcp.spec.ts | 28 +++--- bridge/src/preview-bridge.ts | 24 +++-- bridge/src/react/react-bridge.ts | 2 +- samples/lit-basic-catalog/public/catalog | 4 - .../ng-basic-catalog/src/assets/catalog.json | 4 - samples/react-basic-catalog/public/catalog | 4 - .../chat-prompt-factory.service.ts | 21 +++-- .../mcp/mcp-client-manager.service.spec.ts | 4 +- .../src/app/mcp/mcp-client-manager.service.ts | 17 +--- .../host-communication.spec.ts | 87 +++++++++++++++++++ .../host-communication/host-communication.ts | 43 ++++++--- 16 files changed, 159 insertions(+), 104 deletions(-) diff --git a/bridge/src/angular/angular-bridge.ts b/bridge/src/angular/angular-bridge.ts index 7fac9d94a..d05f870c6 100644 --- a/bridge/src/angular/angular-bridge.ts +++ b/bridge/src/angular/angular-bridge.ts @@ -178,7 +178,7 @@ export function provideA2uiSandbox( activeProcessor?.processMessages(msgs); }, }, - async server => a2uiBridge.getMcpClient(server), + async () => a2uiBridge.getMcpClient(), ); const mcpCatalog = new Catalog(BASIC_WITH_MCP_CATALOG_ID, baseComponents, [ ...baseFunctions, diff --git a/bridge/src/bridge-message.ts b/bridge/src/bridge-message.ts index 390121a59..b91364a36 100644 --- a/bridge/src/bridge-message.ts +++ b/bridge/src/bridge-message.ts @@ -207,7 +207,6 @@ export declare interface A2uiComponentInstance { /** Payload for MCP_REQUEST message type. */ export declare interface McpRequestPayload { requestId: string; - server: string; toolName: string; args: Record; } diff --git a/bridge/src/lit/lit-bridge.ts b/bridge/src/lit/lit-bridge.ts index 9d1ad19db..c34a2d77a 100644 --- a/bridge/src/lit/lit-bridge.ts +++ b/bridge/src/lit/lit-bridge.ts @@ -104,7 +104,7 @@ export class A2uiSandboxRoot extends LitElement { { processMessages: msgs => procRef.current?.processMessages(msgs), }, - async server => a2uiBridge.getMcpClient(server), + async () => a2uiBridge.getMcpClient(), ); const mcpCatalog = new Catalog(BASIC_WITH_MCP_CATALOG_ID, baseComponents, [ ...baseFunctions, diff --git a/bridge/src/mcp/functions/callMcpTool.ts b/bridge/src/mcp/functions/callMcpTool.ts index a0d4b01b2..478336990 100644 --- a/bridge/src/mcp/functions/callMcpTool.ts +++ b/bridge/src/mcp/functions/callMcpTool.ts @@ -55,13 +55,13 @@ export interface McpMessageProcessor { } /** - * Resolves the connected MCP client for a given tool or server name. + * 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 = ( - toolOrServer: string, + toolName: string, ) => McpToolClient | undefined | null | Promise; /** @@ -77,9 +77,6 @@ export const CallMcpToolApi: FunctionApiDefinition = { name: (DynamicStringSchema as unknown as z.ZodTypeAny).describe( 'The name of the MCP tool to execute.', ), - server: (DynamicStringSchema as unknown as z.ZodTypeAny) - .optional() - .describe('Optional MCP server name.'), arguments: z .record(DynamicValueSchema as unknown as z.ZodTypeAny) .optional() @@ -158,9 +155,6 @@ export function createCallMcpToolImplementation( CallMcpToolApi as Parameters[0], async (args, context) => { const toolName = context.resolveDynamicValue(args['name']); - const serverName = args['server'] - ? context.resolveDynamicValue(args['server']) - : undefined; try { const resolvedArguments = resolveDynamicRecord( @@ -168,7 +162,7 @@ export function createCallMcpToolImplementation( context, ); - const client = await getMcpClientForTool(serverName ?? toolName); + const client = await getMcpClientForTool(toolName); if (!client) { throw new Error(`MCP client for tool '${toolName}' could not be resolved.`); } diff --git a/bridge/src/mcp/iframe-mcp-client.ts b/bridge/src/mcp/iframe-mcp-client.ts index 3875d7bab..13579b430 100644 --- a/bridge/src/mcp/iframe-mcp-client.ts +++ b/bridge/src/mcp/iframe-mcp-client.ts @@ -30,7 +30,6 @@ export class IframeMcpClient { private pendingRequests = new Map(); constructor( - private readonly server: string, private readonly sendMcpRequest: (payload: McpRequestPayload) => void, private readonly timeoutMs = 30000, ) {} @@ -44,18 +43,13 @@ export class IframeMcpClient { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRequests.delete(requestId); - reject( - new Error( - `MCP tool call "${params.name}" on server "${this.server}" timed out after ${this.timeoutMs}ms`, - ), - ); + 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, - server: this.server, toolName: params.name, args: params.arguments ?? {}, }); diff --git a/bridge/src/mcp/mcp.spec.ts b/bridge/src/mcp/mcp.spec.ts index ea72ceefe..c7ce41fed 100644 --- a/bridge/src/mcp/mcp.spec.ts +++ b/bridge/src/mcp/mcp.spec.ts @@ -99,17 +99,15 @@ describe('MCP Bridge & Catalog Functions', () => { it('resolves callTool when matching MCP_RESPONSE is received', async () => { const sentPayloads: Array<{ requestId: string; - server: string; toolName: string; args: Record; }> = []; - const client = new IframeMcpClient('fs', payload => { + 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].server).toBe('fs'); expect(sentPayloads[0].toolName).toBe('list_dir'); expect(sentPayloads[0].args).toEqual({path: '/root'}); @@ -124,7 +122,7 @@ describe('MCP Bridge & Catalog Functions', () => { it('rejects callTool when MCP_RESPONSE contains error', async () => { let reqId = ''; - const client = new IframeMcpClient('fs', payload => { + const client = new IframeMcpClient(payload => { reqId = payload.requestId; }); @@ -138,7 +136,7 @@ describe('MCP Bridge & Catalog Functions', () => { }); it('times out if no response is received within timeoutMs', async () => { - const client = new IframeMcpClient('fs', () => {}, 1000); + const client = new IframeMcpClient(() => {}, 1000); const promise = client.callTool({name: 'slow_tool'}); vi.advanceTimersByTime(1001); await expect(promise).rejects.toThrow(/timed out/); @@ -224,10 +222,7 @@ describe('MCP Bridge & Catalog Functions', () => { ).toThrow(/context\.set is not a function/); const callMcpFn = mcpFunctions.find(f => f.name === 'callMcpTool')!; - const toolRes = await callMcpFn.execute( - {name: 'list_dir', server: 'fs', arguments: {path: '/'}}, - mockCtx, - ); + const toolRes = await callMcpFn.execute({name: 'list_dir', arguments: {path: '/'}}, mockCtx); expect(mockClient.callTool).toHaveBeenCalledWith({ name: 'list_dir', arguments: {path: '/'}, @@ -281,8 +276,8 @@ describe('MCP Bridge & Catalog Functions', () => { { processMessages: msgs => processedMsgs.push(...msgs), }, - async server => { - if (server === 'broken') { + async toolName => { + if (toolName === 'broken_tool') { throw new Error('Server offline'); } return fullClient; @@ -294,10 +289,7 @@ describe('MCP Bridge & Catalog Functions', () => { resolveDynamicValue: (v: unknown) => v, } as never; - const res = await callMcpFn.execute( - {name: 'rich_tool', server: 'ok', arguments: {x: 1}}, - mockCtx, - ); + const res = await callMcpFn.execute({name: 'rich_tool', arguments: {x: 1}}, mockCtx); expect(res).toBeDefined(); expect(fullClient.request).toHaveBeenCalled(); expect(fullClient.listTools).toHaveBeenCalled(); @@ -305,7 +297,7 @@ describe('MCP Bridge & Catalog Functions', () => { expect(processedMsgs.length).toBeGreaterThanOrEqual(2); await expect( - callMcpFn.execute({name: 'rich_tool', server: 'broken', arguments: {}}, mockCtx), + callMcpFn.execute({name: 'broken_tool', arguments: {}}, mockCtx), ).rejects.toThrow(/Server offline/); }); @@ -351,8 +343,8 @@ describe('MCP Bridge & Catalog Functions', () => { const bridge = new PreviewBridge(); const sendSpy = vi.spyOn(bridge, 'sendMessage'); - const client = bridge.getMcpClient('fs'); - expect(bridge.getMcpClient('fs')).toBe(client); + const client = bridge.getMcpClient(); + expect(bridge.getMcpClient()).toBe(client); const callPromise = client.callTool({name: 'read_file', arguments: {path: '/a'}}); expect(sendSpy).toHaveBeenCalledWith( diff --git a/bridge/src/preview-bridge.ts b/bridge/src/preview-bridge.ts index b10b3ee35..1db9e4039 100644 --- a/bridge/src/preview-bridge.ts +++ b/bridge/src/preview-bridge.ts @@ -223,24 +223,22 @@ export class PreviewBridge { private readonly cachedParentOrigin: string | null = null; - /** Registry of active iframe MCP proxy clients keyed by server name. */ - private readonly mcpClients = new Map(); + /** Cached iframe MCP proxy client. */ + private mcpClient: IframeMcpClient | null = null; /** - * Returns an IframeMcpClient proxy for the given server name, creating and caching it if needed. + * Returns the IframeMcpClient proxy, creating and caching it if needed. */ - getMcpClient(server: string): IframeMcpClient { - let client = this.mcpClients.get(server); - if (!client) { - client = new IframeMcpClient(server, payload => { + getMcpClient(): IframeMcpClient { + if (!this.mcpClient) { + this.mcpClient = new IframeMcpClient(payload => { this.sendMessage({ type: PreviewBridgeMessageType.MCP_REQUEST, payload, }); }); - this.mcpClients.set(server, client); } - return client; + return this.mcpClient; } /** @@ -409,7 +407,7 @@ export class PreviewBridge { } } this.activeConnections.clear(); - this.mcpClients.clear(); + this.mcpClient = null; } private resolveExpectedParentOrigin(): string { @@ -513,14 +511,12 @@ export class PreviewBridge { }; /** - * Routes incoming MCP_RESPONSE messages to the matching IframeMcpClient pending request. + * 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; - for (const client of this.mcpClients.values()) { - client.handleResponse(payloadObj); - } + this.mcpClient?.handleResponse(payloadObj); } /** diff --git a/bridge/src/react/react-bridge.ts b/bridge/src/react/react-bridge.ts index a2653407e..660424538 100644 --- a/bridge/src/react/react-bridge.ts +++ b/bridge/src/react/react-bridge.ts @@ -80,7 +80,7 @@ export function useA2uiSandbox( { processMessages: msgs => procRef.current?.processMessages(msgs), }, - async server => a2uiBridge.getMcpClient(server), + async () => a2uiBridge.getMcpClient(), ); const mcpCatalog = new Catalog(BASIC_WITH_MCP_CATALOG_ID, baseComponents, [ ...baseFunctions, diff --git a/samples/lit-basic-catalog/public/catalog b/samples/lit-basic-catalog/public/catalog index 99005e871..0d209c011 100644 --- a/samples/lit-basic-catalog/public/catalog +++ b/samples/lit-basic-catalog/public/catalog @@ -36,10 +36,6 @@ "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", "description": "The name of the MCP tool to execute." }, - "server": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "Optional MCP server name." - }, "arguments": { "type": "object", "description": "The arguments to pass to the MCP tool.", diff --git a/samples/ng-basic-catalog/src/assets/catalog.json b/samples/ng-basic-catalog/src/assets/catalog.json index d0758fa74..5bd48db1e 100644 --- a/samples/ng-basic-catalog/src/assets/catalog.json +++ b/samples/ng-basic-catalog/src/assets/catalog.json @@ -816,10 +816,6 @@ "$ref": "#/$defs/common_types_DynamicString", "description": "The name of the MCP tool to execute." }, - "server": { - "$ref": "#/$defs/common_types_DynamicString", - "description": "Optional MCP server name." - }, "arguments": { "type": "object", "description": "The arguments to pass to the MCP tool.", diff --git a/samples/react-basic-catalog/public/catalog b/samples/react-basic-catalog/public/catalog index 99005e871..0d209c011 100644 --- a/samples/react-basic-catalog/public/catalog +++ b/samples/react-basic-catalog/public/catalog @@ -36,10 +36,6 @@ "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", "description": "The name of the MCP tool to execute." }, - "server": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "Optional MCP server name." - }, "arguments": { "type": "object", "description": "The arguments to pass to the MCP tool.", 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 a6c46c61b..f9c6c9151 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 @@ -77,23 +77,22 @@ export class ChatPromptFactoryService { When building surfaces that interact with MCP tools: 1. Use \`"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json"\` in \`createSurface\`. - 2. Trigger MCP tools via button \`functionCall\` actions that chain \`updateDataModel\`, \`jmespath\`, and \`callMcpTool\`: + 2. Trigger MCP tools via button \`functionCall\` actions that chain \`updateDataModel\`, \`jmespath\`, and \`callMcpTool\`. Never specify a \`server\` property in \`callMcpTool\` (the host resolves the MCP server from the tool \`name\`): \`\`\`json "action": { "functionCall": { "call": "updateDataModel", "args": { "updates": { - "/result": { - "call": "jmespath", - "args": { - "expression": "content[0].text", - "data": { - "call": "callMcpTool", - "args": { - "server": "${activeServers[0].name}", - "name": "", - "arguments": {} + "call": "jmespath", + "args": { + "expression": "{\\"/result\\": content[0].text}", + "data": { + "call": "callMcpTool", + "args": { + "name": "", + "arguments": { + "path": "/mcp_arguments" } } } diff --git a/shell/src/app/mcp/mcp-client-manager.service.spec.ts b/shell/src/app/mcp/mcp-client-manager.service.spec.ts index 096d4a149..1bb554290 100644 --- a/shell/src/app/mcp/mcp-client-manager.service.spec.ts +++ b/shell/src/app/mcp/mcp-client-manager.service.spec.ts @@ -91,7 +91,7 @@ describe('McpClientManagerService', () => { expect(servers[0].tools).toHaveLength(1); expect(service.getActiveServersWithTools()).toHaveLength(1); - const res = await service.callTool('fs', 'list_directory', {path: '/tmp'}); + const res = await service.callTool('list_directory', {path: '/tmp'}); expect(callToolMock).toHaveBeenCalledWith({ name: 'list_directory', arguments: {path: '/tmp'}, @@ -119,7 +119,7 @@ describe('McpClientManagerService', () => { expect(service.servers()[0].status).toBe('error'); expect(service.servers()[0].errorMessage).toContain('Connection refused'); - await expect(service.callTool('unknown-server', 'tool', {})).rejects.toThrow( + await expect(service.callTool('unknown-tool', {})).rejects.toThrow( /No connected MCP server found/, ); diff --git a/shell/src/app/mcp/mcp-client-manager.service.ts b/shell/src/app/mcp/mcp-client-manager.service.ts index 5518c0bec..55b09dd71 100644 --- a/shell/src/app/mcp/mcp-client-manager.service.ts +++ b/shell/src/app/mcp/mcp-client-manager.service.ts @@ -211,22 +211,11 @@ export class McpClientManagerService { ); } - async callTool( - serverNameOrToolName: string, - toolName: string, - args: Record, - ): Promise { + async callTool(toolName: string, args: Record): Promise { const activeServers = this.getActiveServersWithTools(); - let targetServer = activeServers.find( - s => s.name === serverNameOrToolName || s.id === serverNameOrToolName, - ); - if (!targetServer) { - targetServer = activeServers.find(s => s.tools?.some(t => t.name === toolName)); - } + const targetServer = activeServers.find(s => s.tools?.some(t => t.name === toolName)); if (!targetServer) { - throw new Error( - `No connected MCP server found for "${serverNameOrToolName}" or tool "${toolName}".`, - ); + throw new Error(`No connected MCP server found for tool "${toolName}".`); } const client = this.clients.get(targetServer.id); 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..6d1d50b2c 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,92 @@ 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', + args: {path: '.'}, + }, + }, + }), + ); + + await vi.waitFor(() => { + expect(callToolSpy).toHaveBeenCalledWith('list_directory', {path: '.'}); + expect(postMessageSpy).toHaveBeenCalledWith( + { + type: PreviewBridgeMessageType.MCP_RESPONSE, + payload: { + requestId: 'req-123', + result: {content: [{type: 'text', text: 'dir-list'}]}, + }, + }, + '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', + ); + }); + }); }); }); diff --git a/shell/src/app/shell/host-communication/host-communication.ts b/shell/src/app/shell/host-communication/host-communication.ts index d8d9d6304..828ba518a 100644 --- a/shell/src/app/shell/host-communication/host-communication.ts +++ b/shell/src/app/shell/host-communication/host-communication.ts @@ -235,17 +235,21 @@ export class HostCommunication implements OnDestroy { } if (type === PreviewBridgeMessageType.MCP_REQUEST) { + this.isRendererReadySignal.set(true); const req = data.payload as McpRequestPayload; - const sourceWin = (event.source as Window) ?? null; + const sourceTarget = + Array.from(this.registeredIframes).find(f => f.contentWindow === event.source) ?? + (event.source as Window) ?? + null; void this.mcpManager - .callTool(req.server, req.toolName, req.args) + .callTool(req.toolName, req.args) .then(result => { this.sendMessage( { type: PreviewBridgeMessageType.MCP_RESPONSE, payload: {requestId: req.requestId, result}, }, - sourceWin, + sourceTarget, ); }) .catch((err: unknown) => { @@ -257,7 +261,7 @@ export class HostCommunication implements OnDestroy { error: err instanceof Error ? err.message : String(err), }, }, - sourceWin, + sourceTarget, ); }); } @@ -320,6 +324,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. @@ -338,14 +355,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; @@ -365,8 +382,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 @@ -375,7 +392,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) { @@ -412,7 +429,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; } From f1bdd733c508592ad8bdf0f97ee11d3f68892741 Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 14:34:14 -0400 Subject: [PATCH 04/16] show mcp_response --- .../debug/raw-messages/raw-messages.spec.ts | 22 +++++++++++++++ .../host-communication.spec.ts | 26 ++++++++++++++++++ .../host-communication/host-communication.ts | 27 ++++++++++++++----- 3 files changed, 68 insertions(+), 7 deletions(-) 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/shell/host-communication/host-communication.spec.ts b/shell/src/app/shell/host-communication/host-communication.spec.ts index 6d1d50b2c..afd7f52b2 100644 --- a/shell/src/app/shell/host-communication/host-communication.spec.ts +++ b/shell/src/app/shell/host-communication/host-communication.spec.ts @@ -1268,6 +1268,19 @@ describe('HostCommunication', () => { }, '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')); @@ -1297,6 +1310,19 @@ describe('HostCommunication', () => { }, '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 828ba518a..11ad815cc 100644 --- a/shell/src/app/shell/host-communication/host-communication.ts +++ b/shell/src/app/shell/host-communication/host-communication.ts @@ -297,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); @@ -441,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 } From 73309eff336cceff9f775afb0a9b97367f072a1c Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 15:27:11 -0400 Subject: [PATCH 05/16] a --- samples/lit-basic-catalog/public/catalog | 197 ++--------------- .../ng-basic-catalog/src/assets/catalog.json | 203 +----------------- samples/react-basic-catalog/public/catalog | 197 ++--------------- shell/e2e/gallery.e2e.ts | 2 +- 4 files changed, 47 insertions(+), 552 deletions(-) diff --git a/samples/lit-basic-catalog/public/catalog b/samples/lit-basic-catalog/public/catalog index 0d209c011..23df99d47 100644 --- a/samples/lit-basic-catalog/public/catalog +++ b/samples/lit-basic-catalog/public/catalog @@ -1,181 +1,26 @@ { "$schema": "https://a2ui.org/specification/v0_9/catalog.json", - "catalogId": "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/basic_catalog.json", + "title": "Basic Catalog", + "description": "The standard A2UI components list for the basic catalog.", "components": { - "Text": {"name": "Text"}, - "Image": {"name": "Image"}, - "Icon": {"name": "Icon"}, - "Video": {"name": "Video"}, - "AudioPlayer": {"name": "AudioPlayer"}, - "Row": {"name": "Row"}, - "Column": {"name": "Column"}, - "List": {"name": "List"}, - "Card": {"name": "Card"}, - "Tabs": {"name": "Tabs"}, - "Modal": {"name": "Modal"}, - "Divider": {"name": "Divider"}, - "Button": {"name": "Button"}, - "TextField": {"name": "TextField"}, - "CheckBox": {"name": "CheckBox"}, - "ChoicePicker": {"name": "ChoicePicker"}, - "Slider": {"name": "Slider"}, - "DateTimeInput": {"name": "DateTimeInput"} - }, - "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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "The name of the MCP tool to execute." - }, - "arguments": { - "type": "object", - "description": "The arguments to pass to the MCP tool.", - "additionalProperties": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "A standard JMESPath expression evaluated against `data`." - }, - "data": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "The string or array of strings to split." - }, - "separator": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "The string or array of strings to match against." - }, - "pattern": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "The string or array of strings to perform replacements on." - }, - "pattern": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "An RE2 regular expression matching substrings to replace." - }, - "replacement": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "An object mapping data model paths to the values to write (e.g., {\"/entries\": [...], \"/title\": \"Home\"}). Paths starting with \"/\" are absolute; relative paths resolve against the current data context." - } - }, - "required": ["updates"], - "additionalProperties": false - }, - "returnType": {"const": "any"} - }, - "required": ["call", "args"], - "unevaluatedProperties": false - } + "Text": { "name": "Text" }, + "Image": { "name": "Image" }, + "Icon": { "name": "Icon" }, + "Video": { "name": "Video" }, + "AudioPlayer": { "name": "AudioPlayer" }, + "Row": { "name": "Row" }, + "Column": { "name": "Column" }, + "List": { "name": "List" }, + "Card": { "name": "Card" }, + "Tabs": { "name": "Tabs" }, + "Modal": { "name": "Modal" }, + "Divider": { "name": "Divider" }, + "Button": { "name": "Button" }, + "TextField": { "name": "TextField" }, + "CheckBox": { "name": "CheckBox" }, + "ChoicePicker": { "name": "ChoicePicker" }, + "Slider": { "name": "Slider" }, + "DateTimeInput": { "name": "DateTimeInput" } } } diff --git a/samples/ng-basic-catalog/src/assets/catalog.json b/samples/ng-basic-catalog/src/assets/catalog.json index 5bd48db1e..2e59b9e3c 100644 --- a/samples/ng-basic-catalog/src/assets/catalog.json +++ b/samples/ng-basic-catalog/src/assets/catalog.json @@ -1,9 +1,9 @@ { "$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", + "$id": "my_basic_catalog.json", + "title": "my_basic_catalog A2UI Catalog", + "description": "my_basic_catalog A2UI catalog, including catalog.", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", "components": { "Text": { "type": "object", @@ -802,183 +802,6 @@ } }, "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.", @@ -3088,24 +2911,6 @@ }, { "$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/react-basic-catalog/public/catalog b/samples/react-basic-catalog/public/catalog index 0d209c011..23df99d47 100644 --- a/samples/react-basic-catalog/public/catalog +++ b/samples/react-basic-catalog/public/catalog @@ -1,181 +1,26 @@ { "$schema": "https://a2ui.org/specification/v0_9/catalog.json", - "catalogId": "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/basic_catalog.json", + "title": "Basic Catalog", + "description": "The standard A2UI components list for the basic catalog.", "components": { - "Text": {"name": "Text"}, - "Image": {"name": "Image"}, - "Icon": {"name": "Icon"}, - "Video": {"name": "Video"}, - "AudioPlayer": {"name": "AudioPlayer"}, - "Row": {"name": "Row"}, - "Column": {"name": "Column"}, - "List": {"name": "List"}, - "Card": {"name": "Card"}, - "Tabs": {"name": "Tabs"}, - "Modal": {"name": "Modal"}, - "Divider": {"name": "Divider"}, - "Button": {"name": "Button"}, - "TextField": {"name": "TextField"}, - "CheckBox": {"name": "CheckBox"}, - "ChoicePicker": {"name": "ChoicePicker"}, - "Slider": {"name": "Slider"}, - "DateTimeInput": {"name": "DateTimeInput"} - }, - "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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "The name of the MCP tool to execute." - }, - "arguments": { - "type": "object", - "description": "The arguments to pass to the MCP tool.", - "additionalProperties": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "A standard JMESPath expression evaluated against `data`." - }, - "data": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "The string or array of strings to split." - }, - "separator": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "The string or array of strings to match against." - }, - "pattern": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "The string or array of strings to perform replacements on." - }, - "pattern": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicString", - "description": "An RE2 regular expression matching substrings to replace." - }, - "replacement": { - "$ref": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/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": "https://a2ui.org/specification/v0_9/common_types.json#/$defs/DynamicValue", - "description": "An object mapping data model paths to the values to write (e.g., {\"/entries\": [...], \"/title\": \"Home\"}). Paths starting with \"/\" are absolute; relative paths resolve against the current data context." - } - }, - "required": ["updates"], - "additionalProperties": false - }, - "returnType": {"const": "any"} - }, - "required": ["call", "args"], - "unevaluatedProperties": false - } + "Text": { "name": "Text" }, + "Image": { "name": "Image" }, + "Icon": { "name": "Icon" }, + "Video": { "name": "Video" }, + "AudioPlayer": { "name": "AudioPlayer" }, + "Row": { "name": "Row" }, + "Column": { "name": "Column" }, + "List": { "name": "List" }, + "Card": { "name": "Card" }, + "Tabs": { "name": "Tabs" }, + "Modal": { "name": "Modal" }, + "Divider": { "name": "Divider" }, + "Button": { "name": "Button" }, + "TextField": { "name": "TextField" }, + "CheckBox": { "name": "CheckBox" }, + "ChoicePicker": { "name": "ChoicePicker" }, + "Slider": { "name": "Slider" }, + "DateTimeInput": { "name": "DateTimeInput" } } } diff --git a/shell/e2e/gallery.e2e.ts b/shell/e2e/gallery.e2e.ts index 3018fe897..c99b80e18 100644 --- a/shell/e2e/gallery.e2e.ts +++ b/shell/e2e/gallery.e2e.ts @@ -47,7 +47,7 @@ test.describe('Components Gallery User Journey', () => { await expect(workspaceIframe).toBeVisible(); // Wait for the catalog handshake to complete (indicated by header title updating) - await expect(page.locator('.header-title')).toContainText('Basic with MCP Catalog'); + await expect(page.locator('.header-title')).toContainText('my_basic_catalog'); // 2. Click on the gallery link in the sidebar const galleryLink = page.getByRole('link', {name: 'Components Gallery'}); From 0a2e128049cab9d917ee8d8ce7f3e5b24cb52dd0 Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 15:27:15 -0400 Subject: [PATCH 06/16] separate renderer for basic with mcp --- .github/workflows/deploy_pages.yml | 10 +- .github/workflows/pr_preview_build.yml | 8 + README.md | 16 +- eslint.config.js | 8 +- package.json | 1 + samples/ng-basic-with-mcp-catalog/README.md | 4 + .../ng-basic-with-mcp-catalog/angular.json | 48 + .../ng-basic-with-mcp-catalog/package.json | 45 + .../src/app/app.component.ng.html | 25 + .../src/app/app.component.scss | 17 + .../src/app/app.component.ts | 37 + .../src/assets/catalog.json | 3113 +++++++++++++++++ .../src/assets/catalog.json.d.ts | 18 + .../src/assets/usages.ts | 313 ++ .../ng-basic-with-mcp-catalog/src/index.html | 31 + .../src/main.spec.ts | 147 + samples/ng-basic-with-mcp-catalog/src/main.ts | 34 + .../ng-basic-with-mcp-catalog/src/styles.scss | 52 + .../src/test-setup.ts | 22 + .../tsconfig.app.json | 8 + .../ng-basic-with-mcp-catalog/tsconfig.json | 21 + .../tsconfig.spec.json | 8 + .../vitest.config.ts | 42 + shell/angular.json | 5 + shell/package.json | 5 +- .../app/chat/chat-panel/chat-panel.ng.html | 22 +- shell/src/app/chat/chat-panel/chat-panel.scss | 20 +- .../app/chat/chat-panel/chat-panel.spec.ts | 17 + shell/src/app/chat/chat-panel/chat-panel.ts | 9 +- .../chat-panel/test/chat-panel.harness.ts | 6 + .../chat-prompt-factory.service.spec.ts | 47 +- .../chat-prompt-factory.service.ts | 7 +- .../mcp/mcp-client-manager.service.spec.ts | 19 +- .../src/app/mcp/mcp-client-manager.service.ts | 32 +- .../settings/settings-view/settings.ng.html | 188 +- .../app/settings/settings-view/settings.scss | 23 +- .../settings/settings-view/settings.spec.ts | 44 +- .../app/settings/settings-view/settings.ts | 7 +- .../settings-view/test/settings.harness.ts | 14 + .../storage/models/catalog-storage.model.ts | 18 + .../app/storage/models/local-storage-keys.ts | 2 - shell/src/config.json | 8 + yarn.lock | 35 + 43 files changed, 4358 insertions(+), 198 deletions(-) create mode 100644 samples/ng-basic-with-mcp-catalog/README.md create mode 100644 samples/ng-basic-with-mcp-catalog/angular.json create mode 100644 samples/ng-basic-with-mcp-catalog/package.json create mode 100644 samples/ng-basic-with-mcp-catalog/src/app/app.component.ng.html create mode 100644 samples/ng-basic-with-mcp-catalog/src/app/app.component.scss create mode 100644 samples/ng-basic-with-mcp-catalog/src/app/app.component.ts create mode 100644 samples/ng-basic-with-mcp-catalog/src/assets/catalog.json create mode 100644 samples/ng-basic-with-mcp-catalog/src/assets/catalog.json.d.ts create mode 100644 samples/ng-basic-with-mcp-catalog/src/assets/usages.ts create mode 100644 samples/ng-basic-with-mcp-catalog/src/index.html create mode 100644 samples/ng-basic-with-mcp-catalog/src/main.spec.ts create mode 100644 samples/ng-basic-with-mcp-catalog/src/main.ts create mode 100644 samples/ng-basic-with-mcp-catalog/src/styles.scss create mode 100644 samples/ng-basic-with-mcp-catalog/src/test-setup.ts create mode 100644 samples/ng-basic-with-mcp-catalog/tsconfig.app.json create mode 100644 samples/ng-basic-with-mcp-catalog/tsconfig.json create mode 100644 samples/ng-basic-with-mcp-catalog/tsconfig.spec.json create mode 100644 samples/ng-basic-with-mcp-catalog/vitest.config.ts diff --git a/.github/workflows/deploy_pages.yml b/.github/workflows/deploy_pages.yml index 4f169a5c1..738b99742 100644 --- a/.github/workflows/deploy_pages.yml +++ b/.github/workflows/deploy_pages.yml @@ -66,6 +66,7 @@ jobs: - name: Build Production Shell & Catalogs with Correct Base URLs run: | yarn workspace ng-basic-catalog run build --base-href=/composer/samples/ng-basic-catalog/ + yarn workspace ng-basic-with-mcp-catalog run build --base-href=/composer/samples/ng-basic-with-mcp-catalog/ yarn workspace a2ui-composer-shell run build --base-href=/composer/ yarn workspace lit-basic-catalog run build --base=/composer/samples/lit-basic-catalog/ yarn workspace react-basic-catalog run build --base=/composer/samples/react-basic-catalog/ @@ -74,6 +75,7 @@ jobs: - name: Assemble Staging Directory (deploy_dist) run: | mkdir -p deploy_dist/samples/ng-basic-catalog + mkdir -p deploy_dist/samples/ng-basic-with-mcp-catalog mkdir -p deploy_dist/samples/lit-basic-catalog mkdir -p deploy_dist/samples/react-basic-catalog @@ -84,13 +86,19 @@ jobs: cp -r shell/dist/a2ui-composer/. deploy_dist/ fi - # Stage Angular Sample + # Stage Angular Samples if [ -d "samples/ng-basic-catalog/dist/ng-basic-catalog/browser" ]; then cp -r samples/ng-basic-catalog/dist/ng-basic-catalog/browser/. deploy_dist/samples/ng-basic-catalog/ else cp -r samples/ng-basic-catalog/dist/ng-basic-catalog/. deploy_dist/samples/ng-basic-catalog/ fi + if [ -d "samples/ng-basic-with-mcp-catalog/dist/ng-basic-with-mcp-catalog/browser" ]; then + cp -r samples/ng-basic-with-mcp-catalog/dist/ng-basic-with-mcp-catalog/browser/. deploy_dist/samples/ng-basic-with-mcp-catalog/ + else + cp -r samples/ng-basic-with-mcp-catalog/dist/ng-basic-with-mcp-catalog/. deploy_dist/samples/ng-basic-with-mcp-catalog/ + fi + # Stage Vite Samples cp -r samples/lit-basic-catalog/dist/. deploy_dist/samples/lit-basic-catalog/ cp -r samples/react-basic-catalog/dist/. deploy_dist/samples/react-basic-catalog/ diff --git a/.github/workflows/pr_preview_build.yml b/.github/workflows/pr_preview_build.yml index d721c7a9a..143be58a9 100644 --- a/.github/workflows/pr_preview_build.yml +++ b/.github/workflows/pr_preview_build.yml @@ -73,6 +73,7 @@ jobs: PR_NUM: ${{ github.event.pull_request.number }} run: | yarn workspace ng-basic-catalog run build --base-href=/composer/pr/${PR_NUM}/samples/ng-basic-catalog/ + yarn workspace ng-basic-with-mcp-catalog run build --base-href=/composer/pr/${PR_NUM}/samples/ng-basic-with-mcp-catalog/ yarn workspace a2ui-composer-shell run build --base-href=/composer/pr/${PR_NUM}/ yarn workspace lit-basic-catalog run build --base=/composer/pr/${PR_NUM}/samples/lit-basic-catalog/ yarn workspace react-basic-catalog run build --base=/composer/pr/${PR_NUM}/samples/react-basic-catalog/ @@ -80,6 +81,7 @@ jobs: - name: Assemble Staging Directory (deploy_dist) run: | mkdir -p deploy_dist/samples/ng-basic-catalog + mkdir -p deploy_dist/samples/ng-basic-with-mcp-catalog mkdir -p deploy_dist/samples/lit-basic-catalog mkdir -p deploy_dist/samples/react-basic-catalog @@ -95,6 +97,12 @@ jobs: cp -r samples/ng-basic-catalog/dist/ng-basic-catalog/. deploy_dist/samples/ng-basic-catalog/ fi + if [ -d "samples/ng-basic-with-mcp-catalog/dist/ng-basic-with-mcp-catalog/browser" ]; then + cp -r samples/ng-basic-with-mcp-catalog/dist/ng-basic-with-mcp-catalog/browser/. deploy_dist/samples/ng-basic-with-mcp-catalog/ + else + cp -r samples/ng-basic-with-mcp-catalog/dist/ng-basic-with-mcp-catalog/. deploy_dist/samples/ng-basic-with-mcp-catalog/ + fi + cp -r samples/lit-basic-catalog/dist/. deploy_dist/samples/lit-basic-catalog/ cp -r samples/react-basic-catalog/dist/. deploy_dist/samples/react-basic-catalog/ 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/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..7bf2b75ca --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/README.md @@ -0,0 +1,4 @@ +# Angular Basic with MCP Sample A2UI Composer Integration + +Sample application showing how to integrate Angular-based renderers for the +`basic_with_mcp` catalog into the A2UI Composer. 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/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..42bc9c30f --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/main.spec.ts @@ -0,0 +1,147 @@ +/** + * 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 {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([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..4357420a6 --- /dev/null +++ b/samples/ng-basic-with-mcp-catalog/src/main.ts @@ -0,0 +1,34 @@ +/** + * 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 {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([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/angular.json b/shell/angular.json index eece041a5..92c8fe8fb 100644 --- a/shell/angular.json +++ b/shell/angular.json @@ -31,6 +31,11 @@ "input": "node_modules/ng-basic-catalog/dist/ng-basic-catalog/browser", "output": "samples/ng-basic-catalog" }, + { + "glob": "**/*", + "input": "node_modules/ng-basic-with-mcp-catalog/dist/ng-basic-with-mcp-catalog/browser", + "output": "samples/ng-basic-with-mcp-catalog" + }, { "glob": "**/*", "input": "node_modules/lit-basic-catalog/dist", diff --git a/shell/package.json b/shell/package.json index e0cdb1a69..228684439 100644 --- a/shell/package.json +++ b/shell/package.json @@ -7,8 +7,8 @@ }, "type": "module", "scripts": { - "start": "yarn workspace ng-basic-catalog build && yarn workspace lit-basic-catalog build && yarn workspace react-basic-catalog build && ng serve", - "build": "yarn lint && yarn workspace ng-basic-catalog build && yarn workspace lit-basic-catalog build && yarn workspace react-basic-catalog build && ng build", + "start": "yarn workspace ng-basic-catalog build && yarn workspace ng-basic-with-mcp-catalog build && yarn workspace lit-basic-catalog build && yarn workspace react-basic-catalog build && ng serve", + "build": "yarn lint && yarn workspace ng-basic-catalog build && yarn workspace ng-basic-with-mcp-catalog build && yarn workspace lit-basic-catalog build && yarn workspace react-basic-catalog build && ng build", "test": "vitest run --coverage", "test:visual": "playwright test --project=visual", "test:visual:update": "playwright test --project=visual --update-snapshots=all", @@ -57,6 +57,7 @@ "jsdom": "29.1.1", "lit-basic-catalog": "0.1.0", "ng-basic-catalog": "0.1.0", + "ng-basic-with-mcp-catalog": "0.1.0", "prettier": "3.9.6", "react-basic-catalog": "0.1.0", "typescript": "6.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 f3fd9841f..15813a604 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.ng.html +++ b/shell/src/app/chat/chat-panel/chat-panel.ng.html @@ -276,24 +276,12 @@ > photo_camera - @if (isLocked()) { diff --git a/shell/src/app/chat/chat-panel/chat-panel.scss b/shell/src/app/chat/chat-panel/chat-panel.scss index 4d2080725..1335714e8 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.scss +++ b/shell/src/app/chat/chat-panel/chat-panel.scss @@ -419,9 +419,7 @@ } } - .screenshot-toggle-button, - .mcp-toggle-button { - position: relative; + .screenshot-toggle-button { color: var(--mat-sys-on-surface-variant); transition: color 0.15s ease, @@ -433,22 +431,6 @@ } } - .mcp-badge { - position: absolute; - top: 2px; - right: 2px; - min-width: 14px; - height: 14px; - border-radius: 7px; - background-color: var(--mat-sys-primary); - color: var(--mat-sys-on-primary); - font-size: 9px; - line-height: 14px; - text-align: center; - padding: 0 3px; - font-weight: 700; - } - .submit-button { font-size: 12px; font-weight: 500; 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..8741d65cd 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.spec.ts +++ b/shell/src/app/chat/chat-panel/chat-panel.spec.ts @@ -986,4 +986,21 @@ 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)', + ); + }); + }); }); diff --git a/shell/src/app/chat/chat-panel/chat-panel.ts b/shell/src/app/chat/chat-panel/chat-panel.ts index f7a03ea91..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'; @@ -106,7 +107,9 @@ export class ChatPanel { protected readonly mcpManager = inject(McpClientManagerService); protected readonly includeScreenshot = signal(false); - protected readonly mcpEnabledInChat = this.mcpManager.mcpEnabledInChat; + protected readonly isMcpSupported = computed(() => + doesCatalogSupportMcp(this.catalogManagement.activeCatalog()), + ); protected readonly activeMcpServerCount = computed( () => this.mcpManager.getActiveServersWithTools().length, ); @@ -115,10 +118,6 @@ export class ChatPanel { this.includeScreenshot.set(checked); } - protected toggleMcpInChat(): void { - this.mcpManager.setMcpEnabledInChat(!this.mcpEnabledInChat()); - } - /** * Reactively computed dynamic system prompt instructions spec viewport * text. 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..402ba43a0 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,40 @@ 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 Servers & Catalog Instructions'); + }); + + it('appends active MCP servers 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'}], + }, + ]); + catalogSpy.activeCatalog.mockReturnValue({ + components: {}, + functions: {callMcpTool: {type: 'object'}}, + }); + expect(service.systemPrompt()).toContain('Available MCP Servers & Catalog Instructions'); + expect(service.systemPrompt()).toContain('fs-server'); + expect(service.systemPrompt()).toContain('read_file'); }); }); 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 f9c6c9151..14a81432a 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,6 +16,7 @@ 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} from '../../mcp/mcp-client-manager.service'; @@ -32,8 +33,8 @@ export class ChatPromptFactoryService { readonly systemPrompt = computed(() => { const catalog = this.catalogManagement.activeCatalog(); - const mcpEnabled = this.mcpManager.mcpEnabledInChat(); - const activeServers = mcpEnabled ? this.mcpManager.getActiveServersWithTools() : []; + const mcpSupported = doesCatalogSupportMcp(catalog); + const activeServers = mcpSupported ? this.mcpManager.getActiveServersWithTools() : []; const mcpInstructions = this.buildMcpInstructions(activeServers); if (!catalog) { @@ -67,7 +68,7 @@ export class ChatPromptFactoryService { ) .join('\n') : ' - (No tools discovered)'; - return `- **Server \`${server.name}\`** (\`${server.url}\`):\n${toolsList}`; + return `- **Server \`${server.name || server.url}\`** (\`${server.url}\`):\n${toolsList}`; }) .join('\n\n'); diff --git a/shell/src/app/mcp/mcp-client-manager.service.spec.ts b/shell/src/app/mcp/mcp-client-manager.service.spec.ts index 1bb554290..619a2d7b5 100644 --- a/shell/src/app/mcp/mcp-client-manager.service.spec.ts +++ b/shell/src/app/mcp/mcp-client-manager.service.spec.ts @@ -21,6 +21,7 @@ import {LocalStorageInteractions} from '../storage/local-storage-interactions/lo 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(); @@ -28,6 +29,7 @@ const closeMock = vi.fn(); vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ Client: class { connect = connectMock; + getServerVersion = getServerVersionMock; listTools = listToolsMock; callTool = callToolMock; close = closeMock; @@ -59,6 +61,7 @@ describe('McpClientManagerService', () => { }; connectMock.mockResolvedValue(undefined); + getServerVersionMock.mockReturnValue({name: 'filesystem-mcp-server', version: '1.0.0'}); listToolsMock.mockResolvedValue({ tools: [ { @@ -82,11 +85,11 @@ describe('McpClientManagerService', () => { service = TestBed.inject(McpClientManagerService); }); - it('adds, connects, toggles, calls tool, and removes an MCP server', async () => { - await service.addServer('fs', 'http://localhost:3001/mcp'); + 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('fs'); + 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); @@ -113,19 +116,15 @@ describe('McpClientManagerService', () => { expect(service.servers()).toHaveLength(0); }); - it('handles connection failure, missing server callTool, and persists mcpEnabledInChat toggle', async () => { + it('handles connection failure and missing server callTool', async () => { connectMock.mockRejectedValueOnce(new Error('Connection refused')); - await service.addServer('broken', 'http://localhost:9999/mcp'); + 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/, ); - - service.setMcpEnabledInChat(false); - expect(service.mcpEnabledInChat()).toBe(false); - expect(mockStorage.setItem).toHaveBeenCalledWith(LocalStorageKey.MCP_ENABLED_IN_CHAT, 'false'); }); it('hydrates saved servers from localStorage on initialization', () => { @@ -140,7 +139,6 @@ describe('McpClientManagerService', () => { }, ]), ); - storageMap.set(LocalStorageKey.MCP_ENABLED_IN_CHAT, 'false'); TestBed.resetTestingModule(); TestBed.configureTestingModule({ @@ -152,6 +150,5 @@ describe('McpClientManagerService', () => { const hydratedService = TestBed.inject(McpClientManagerService); expect(hydratedService.servers()).toHaveLength(1); expect(hydratedService.servers()[0].name).toBe('saved-fs'); - expect(hydratedService.mcpEnabledInChat()).toBe(false); }); }); diff --git a/shell/src/app/mcp/mcp-client-manager.service.ts b/shell/src/app/mcp/mcp-client-manager.service.ts index 55b09dd71..344fe50b1 100644 --- a/shell/src/app/mcp/mcp-client-manager.service.ts +++ b/shell/src/app/mcp/mcp-client-manager.service.ts @@ -28,7 +28,7 @@ export interface McpToolInfo { export interface McpServerConfig { id: string; - name: string; + name?: string; url: string; enabled: boolean; status?: 'disconnected' | 'connecting' | 'connected' | 'error'; @@ -38,7 +38,7 @@ export interface McpServerConfig { interface PersistedMcpServer { id: string; - name: string; + name?: string; url: string; enabled: boolean; } @@ -51,7 +51,6 @@ export class McpClientManagerService { private readonly clients = new Map(); readonly servers = signal([]); - readonly mcpEnabledInChat = signal(true); constructor() { this.loadFromStorage(); @@ -71,7 +70,7 @@ export class McpClientManagerService { this.servers.set( parsed.map(s => ({ id: s.id, - name: s.name, + name: s.name || s.url, url: s.url, enabled: Boolean(s.enabled), status: 'disconnected', @@ -83,11 +82,6 @@ export class McpClientManagerService { this.servers.set([]); } } - - const rawEnabled = this.storage.getItem(LocalStorageKey.MCP_ENABLED_IN_CHAT); - if (rawEnabled !== null) { - this.mcpEnabledInChat.set(rawEnabled !== 'false'); - } } private persistServers(): void { @@ -100,15 +94,9 @@ export class McpClientManagerService { this.storage.setItem(LocalStorageKey.MCP_SERVERS, JSON.stringify(toSave)); } - setMcpEnabledInChat(enabled: boolean): void { - this.mcpEnabledInChat.set(enabled); - this.storage.setItem(LocalStorageKey.MCP_ENABLED_IN_CHAT, String(enabled)); - } - - async addServer(name: string, url: string): Promise { - const trimmedName = name.trim(); + async addServer(url: string): Promise { const trimmedUrl = url.trim(); - if (!trimmedName || !trimmedUrl) return; + if (!trimmedUrl) return; const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' @@ -117,7 +105,7 @@ export class McpClientManagerService { const newServer: McpServerConfig = { id, - name: trimmedName, + name: trimmedUrl, url: trimmedUrl, enabled: true, status: 'disconnected', @@ -159,6 +147,8 @@ export class McpClientManagerService { 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 resolvedName = serverInfo?.name?.trim() || server.name || server.url; const toolsRes = await client.listTools(); const discoveredTools: McpToolInfo[] = (toolsRes.tools ?? []).map(t => ({ name: t.name, @@ -172,6 +162,7 @@ export class McpClientManagerService { s.id === id ? { ...s, + name: resolvedName, status: 'connected', errorMessage: undefined, tools: discoveredTools, @@ -179,6 +170,7 @@ export class McpClientManagerService { : s, ), ); + this.persistServers(); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); this.servers.update(list => @@ -220,7 +212,9 @@ export class McpClientManagerService { const client = this.clients.get(targetServer.id); if (!client) { - throw new Error(`MCP client for server "${targetServer.name}" is not initialized.`); + throw new Error( + `MCP client for server "${targetServer.name || targetServer.url}" is not initialized.`, + ); } return await client.callTool({ diff --git a/shell/src/app/settings/settings-view/settings.ng.html b/shell/src/app/settings/settings-view/settings.ng.html index 9a3dccc9f..a4be6991f 100644 --- a/shell/src/app/settings/settings-view/settings.ng.html +++ b/shell/src/app/settings/settings-view/settings.ng.html @@ -55,101 +55,6 @@

Developer Authentication Overrides

- -
-

MCP Servers

-

- Configure HTTP Model Context Protocol (MCP) servers for live tool execution in A2UI - surfaces. -

- -
- - Server Name - - - - Server URL - - - -
- -
- @for (server of mcpManager.servers(); track server.id) { -
-
- -
-
- {{ server.name }} - - @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 (isThirdParty()) { @@ -238,4 +143,97 @@

Overlay Logs Preview

+ + + +

MCP Servers

+

+ Configure HTTP Model Context Protocol (MCP) servers for live tool execution in A2UI surfaces +

+
+ +
+ + 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 21dab52bb..657e3923f 100644 --- a/shell/src/app/settings/settings-view/settings.scss +++ b/shell/src/app/settings/settings-view/settings.scss @@ -139,12 +139,8 @@ mat-form-field { align-items: flex-start; margin-top: 12px; - .mcp-name-field { - flex: 1; - } - .mcp-url-field { - flex: 2; + flex: 1; } .mcp-add-btn { @@ -183,6 +179,7 @@ mat-form-field { display: flex; flex-direction: column; min-width: 0; + gap: 2px; } .mcp-server-header { @@ -220,6 +217,22 @@ mat-form-field { white-space: nowrap; } +.mcp-server-tools { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.mcp-tool-name { + font-family: monospace; + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--mat-sys-surface-container-high); + color: var(--mat-sys-on-surface); +} + .mcp-server-actions { display: flex; align-items: center; diff --git a/shell/src/app/settings/settings-view/settings.spec.ts b/shell/src/app/settings/settings-view/settings.spec.ts index c3d9ae5ee..50c8f7ca0 100644 --- a/shell/src/app/settings/settings-view/settings.spec.ts +++ b/shell/src/app/settings/settings-view/settings.spec.ts @@ -379,10 +379,10 @@ describe('Settings', () => { const harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, SettingsHarness); expect(component.isThirdParty()).toBe(true); - expect(await harness.getFormSectionsCount()).toBe(4); + expect(await harness.getFormSectionsCount()).toBe(3); const sections = await harness.getFormSectionsCount(); - expect(sections).toBe(4); + expect(sections).toBe(3); } finally { localStorage.removeItem('a2ui_composer_force_3p'); } @@ -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 b48c36397..8b0cfd21f 100644 --- a/shell/src/app/settings/settings-view/settings.ts +++ b/shell/src/app/settings/settings-view/settings.ts @@ -79,7 +79,6 @@ export class Settings implements OnInit { protected readonly settingsService = inject(SettingsService); protected readonly mcpManager = inject(McpClientManagerService); - readonly newMcpServerName = signal(''); readonly newMcpServerUrl = signal(''); protected readonly is1PAuthEnabled = inject(IS_1P_AUTH_ENABLED); @@ -175,12 +174,10 @@ export class Settings implements OnInit { } async addMcpServer(): Promise { - const name = this.newMcpServerName().trim(); const url = this.newMcpServerUrl().trim(); - if (!name || !url) return; - this.newMcpServerName.set(''); + if (!url) return; this.newMcpServerUrl.set(''); - await this.mcpManager.addServer(name, url); + await this.mcpManager.addServer(url); } async removeMcpServer(id: string): Promise { 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/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 f89ec3a32..0ff8d8cc6 100644 --- a/shell/src/app/storage/models/local-storage-keys.ts +++ b/shell/src/app/storage/models/local-storage-keys.ts @@ -50,8 +50,6 @@ export enum LocalStorageKey { A2A_BACKEND_MODE = 'a2ui_composer_a2a_backend_mode', /** Key for storing configured MCP servers. */ MCP_SERVERS = 'a2ui_composer_mcp_servers', - /** Key for storing whether MCP servers are enabled in chat. */ - MCP_ENABLED_IN_CHAT = 'a2ui_composer_mcp_enabled_in_chat', /** @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..07e4f8ded 100644 --- a/shell/src/config.json +++ b/shell/src/config.json @@ -8,6 +8,14 @@ "rendererUrl": "http://localhost:4200/samples/ng-basic-catalog/", "displayName": "Angular Basic (local)" }, + "angular-mcp": { + "rendererUrl": "https://a2ui-project.github.io/composer/samples/ng-basic-with-mcp-catalog/", + "displayName": "Angular Basic with MCP (github)" + }, + "angular-mcp-dev": { + "rendererUrl": "http://localhost:4200/samples/ng-basic-with-mcp-catalog/", + "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/yarn.lock b/yarn.lock index e9c6a289f..a23bc02ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6073,6 +6073,7 @@ __metadata: marked: "npm:18.0.11" monaco-editor: "npm:0.56.0" ng-basic-catalog: "npm:0.1.0" + ng-basic-with-mcp-catalog: "npm:0.1.0" prettier: "npm:3.9.6" react-basic-catalog: "npm:0.1.0" rxjs: "npm:7.8.2" @@ -10235,6 +10236,40 @@ __metadata: languageName: unknown linkType: soft +"ng-basic-with-mcp-catalog@npm:0.1.0, 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" From 89a65b7925795463f6ecbe4125b7b3e9913dcabf Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 15:41:35 -0400 Subject: [PATCH 07/16] fix prompt --- .../chat/chat-prompt-factory/chat-prompt-factory.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 14a81432a..d1da6d184 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 @@ -77,8 +77,8 @@ export class ChatPromptFactoryService { ## Available MCP Servers & Catalog Instructions When building surfaces that interact with MCP tools: - 1. Use \`"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic_with_mcp/catalog.json"\` in \`createSurface\`. - 2. Trigger MCP tools via button \`functionCall\` actions that chain \`updateDataModel\`, \`jmespath\`, and \`callMcpTool\`. Never specify a \`server\` property in \`callMcpTool\` (the host resolves the MCP server from the tool \`name\`): + + 1. Trigger MCP tools via button \`functionCall\` actions that chain \`updateDataModel\`, \`jmespath\`, and \`callMcpTool\`: \`\`\`json "action": { "functionCall": { From bdb37d98014dbb428882ed3de764fac176496fc2 Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 15:47:14 -0400 Subject: [PATCH 08/16] styling --- .../settings/settings-view/settings.ng.html | 109 +++++++++--------- .../app/settings/settings-view/settings.scss | 88 ++++++++++---- shell/src/global_styles.scss | 28 ++++- 3 files changed, 146 insertions(+), 79 deletions(-) diff --git a/shell/src/app/settings/settings-view/settings.ng.html b/shell/src/app/settings/settings-view/settings.ng.html index a4be6991f..4c36cabbb 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

@@ -147,13 +147,13 @@

Overlay Logs Preview

MCP Servers

-

+

Configure HTTP Model Context Protocol (MCP) servers for live tool execution in A2UI surfaces -

+

- + 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 }} - } +
+
+ +
+
+ {{ 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 657e3923f..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; @@ -135,46 +166,59 @@ mat-form-field { .mcp-add-row { display: flex; - gap: 8px; - align-items: flex-start; - margin-top: 12px; + gap: 12px; + align-items: center; + margin-top: 16px; .mcp-url-field { flex: 1; } .mcp-add-btn { - margin-top: 4px; - height: 48px; + height: 56px; + padding: 0 24px; + border-radius: 8px; white-space: nowrap; + flex-shrink: 0; } } .mcp-server-list { display: flex; flex-direction: column; - gap: 8px; - margin-top: 8px; + 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; - padding: 10px 12px; - border-radius: 8px; - background-color: var(--mat-sys-surface-container-low); gap: 12px; } .mcp-server-main { display: flex; align-items: center; - gap: 12px; + gap: 14px; min-width: 0; flex: 1; } +.mcp-server-toggle { + flex-shrink: 0; +} + .mcp-server-info { display: flex; flex-direction: column; @@ -192,12 +236,14 @@ mat-form-field { .mcp-server-name { font-weight: 600; font-size: 14px; + color: var(--mat-sys-on-surface); } .mcp-server-status { font-size: 12px; - padding: 2px 8px; - border-radius: 12px; + font-weight: 500; + padding: 2px 10px; + border-radius: 9999px; background-color: var(--mat-sys-surface-container-high); &[data-status='connected'] { @@ -221,14 +267,15 @@ mat-form-field { display: flex; flex-wrap: wrap; gap: 6px; - margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--mat-sys-outline-variant); } .mcp-tool-name { font-family: monospace; font-size: 11px; - padding: 2px 6px; - border-radius: 4px; + padding: 3px 8px; + border-radius: 6px; background-color: var(--mat-sys-surface-container-high); color: var(--mat-sys-on-surface); } @@ -237,4 +284,5 @@ mat-form-field { display: flex; align-items: center; gap: 4px; + flex-shrink: 0; } 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; } From 176c7c1fed4ffe6322bb0b8c8873ffe432697469 Mon Sep 17 00:00:00 2001 From: wrenj Date: Mon, 21 Sep 2026 15:48:56 -0400 Subject: [PATCH 09/16] a --- .../app/chat/chat-panel/chat-panel.ng.html | 4 +- .../app/chat/chat-panel/chat-panel.spec.ts | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 15813a604..d450631a3 100644 --- a/shell/src/app/chat/chat-panel/chat-panel.ng.html +++ b/shell/src/app/chat/chat-panel/chat-panel.ng.html @@ -278,7 +278,9 @@