diff --git a/renderers/web_core/src/v0_9/schema/client-to-server.test.ts b/renderers/web_core/src/v0_9/schema/client-to-server.test.ts index 0b829d3a38..4a120ddd04 100644 --- a/renderers/web_core/src/v0_9/schema/client-to-server.test.ts +++ b/renderers/web_core/src/v0_9/schema/client-to-server.test.ts @@ -23,7 +23,7 @@ describe('Client-to-Server Schema Verification', () => { versions.forEach(version => { describe(`Protocol ${version}`, () => { - it(`validates a valid action message`, () => { + it('validates a valid action message', () => { const validAction = { version, action: { @@ -38,7 +38,7 @@ describe('Client-to-Server Schema Verification', () => { assert.ok(result.success, result.success ? '' : result.error.message); }); - it(`validates a valid error message (validation failed)`, () => { + it('validates a valid error message (validation failed)', () => { const validError = { version, error: { @@ -52,7 +52,7 @@ describe('Client-to-Server Schema Verification', () => { assert.ok(result.success, result.success ? '' : result.error.message); }); - it(`validates a valid error message (generic)`, () => { + it('validates a valid error message (generic)', () => { const validError = { version, error: { @@ -65,7 +65,7 @@ describe('Client-to-Server Schema Verification', () => { assert.ok(result.success, result.success ? '' : result.error.message); }); - it(`validates a valid data model message`, () => { + it('validates a valid data model message', () => { const validDataModel = { version, surfaces: { diff --git a/samples/community/agent/adk/mcp_app_proxy/agent.py b/samples/community/agent/adk/mcp_app_proxy/agent.py index b866eec382..517380d469 100644 --- a/samples/community/agent/adk/mcp_app_proxy/agent.py +++ b/samples/community/agent/adk/mcp_app_proxy/agent.py @@ -30,7 +30,7 @@ from google.adk.sessions import InMemorySessionService from google.genai import types from pydantic import PrivateAttr -from tools import get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, commentate_pong_game +from tools import get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, get_pong_app_web_frame_json, commentate_pong_game from agent_executor import get_a2ui_enabled, get_a2ui_catalog, get_a2ui_examples logger = logging.getLogger(__name__) @@ -39,6 +39,7 @@ You are an expert A2UI Proxy Agent. Your primary functions are to fetch the Calculator App or the Pong App and display it to the user. When the user asks for the calculator, you MUST call the `get_calculator_app` tool. When the user asks for Pong with MCP Apps, you MUST call the `get_pong_mcp_app_json` tool. +When the user asks for Pong with WebApp URL, you MUST call the `get_pong_app_web_frame_json` tool. IMPORTANT: Do NOT attempt to construct the JSON manually. The tools handle it automatically. @@ -51,6 +52,7 @@ 1. **Analyze Request**: - If User asks for calculator: Call `get_calculator_app`. - If User asks for Pong with MCP Apps: Call `get_pong_mcp_app_json`. + - If User asks for Pong with WebApp URL: Call `get_pong_app_web_frame_json`. - If User interacts with the calculator (ACTION: calculate): Extract 'operation', 'a', and 'b' from the event context and call `calculate_via_mcp`. Return the result to the user. - If you receive a `"commentate_pong"` action: Call `commentate_pong_game` with `"game_event"` from `"context" -> "game_event"`. Do not generate text responses; only call the tool. """ @@ -167,6 +169,13 @@ def _build_agent_card(self) -> AgentCard: tags=["html", "app", "demo", "tool"], examples=["open pong with mcp apps"], ), + AgentSkill( + id="open_pong_web_frame", + name="Open Pong with WebApp URL", + description="Opens Pong using the new WebAppFrame URL method.", + tags=["html", "app", "demo", "tool"], + examples=["open pong with webapp url"], + ), ], ) @@ -205,6 +214,7 @@ def _build_llm_agent( get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, + get_pong_app_web_frame_json, commentate_pong_game, ], planner=BuiltInPlanner( diff --git a/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json b/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json index ddefc2f156..2c8596284b 100644 --- a/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json +++ b/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json @@ -87,6 +87,65 @@ ], "unevaluatedProperties": false }, + "WebAppFrameUrl": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "WebAppFrameUrl", + "description": "The component type identifier." + }, + "url": { + "$ref": "common_types.json#/$defs/DynamicString", + "description": "The external URL to load inside the iframe." + }, + "height": { + "$ref": "common_types.json#/$defs/DynamicNumber", + "description": "The height of the iframe in pixels." + }, + "allowedEvents": { + "type": "object", + "description": "A map of authorized action names to JSON Schemas defining the expected data payload.", + "additionalProperties": {"type": "object"} + }, + "allowedFunctions": { + "type": "object", + "description": "A map of authorized host client functions to JSON Schemas defining their expected arguments.", + "additionalProperties": {"type": "object"} + }, + "mutableData": { + "type": "object", + "description": "A map of data model keys that the embedded application is authorized to mutate in the parent A2UI Data Model, mapped to JSON Schemas defining their allowed values.", + "additionalProperties": {"type": "object"} + }, + "config": { + "type": "object", + "description": "A dictionary of static key-value initialization properties passed directly to the embedded application without reactive data model binding." + }, + "data": { + "type": "object", + "description": "Data binding configuration for the component.", + "properties": { + "paths": { + "type": "object", + "description": "A dictionary mapping custom state keys to distinct JSON Pointer paths in the data model.", + "additionalProperties": {"type": "string"} + } + }, + "required": ["paths"], + "additionalProperties": false + } + }, + "required": ["component", "url"] + } + ], + "unevaluatedProperties": false + }, "PongScoreBoard": { "type": "object", "allOf": [ @@ -156,6 +215,9 @@ }, { "$ref": "#/components/Column" + }, + { + "$ref": "#/components/WebAppFrameUrl" } ] } diff --git a/samples/community/agent/adk/mcp_app_proxy/pong_engine.js b/samples/community/agent/adk/mcp_app_proxy/pong_engine.js index 564ccb56d1..336ab61603 100644 --- a/samples/community/agent/adk/mcp_app_proxy/pong_engine.js +++ b/samples/community/agent/adk/mcp_app_proxy/pong_engine.js @@ -43,6 +43,9 @@ function hideOverlay() { overlayEl.classList.add('hidden'); } +// Default winning score for when the winning score is not provided by the host context +const DEFAULT_WINNING_SCORE = 3; + // Reference dimensions for scaling const REF_WIDTH = 600; const REF_HEIGHT = 400; @@ -236,10 +239,12 @@ function syncScore(player) { }); } + const targetScore = typeof winningScore !== 'undefined' ? winningScore : DEFAULT_WINNING_SCORE; + let eventDescription = 'player scored'; - if (localPlayerScore >= 3) { + if (localPlayerScore >= targetScore) { eventDescription = 'player won the match'; - } else if (localCpuScore >= 3) { + } else if (localCpuScore >= targetScore) { eventDescription = 'cpu won the match'; } else if (player === 'cpu') { eventDescription = 'cpu scored'; @@ -253,12 +258,12 @@ function syncScore(player) { }, }).catch(e => console.error('Failed to request commentary:', e)); - if (localPlayerScore >= 3 || localCpuScore >= 3) { + if (localPlayerScore >= targetScore || localCpuScore >= targetScore) { isPaused = true; - displayOverlay(localPlayerScore >= 3 ? 'YOU WIN!' : 'CPU WINS!'); + displayOverlay(localPlayerScore >= targetScore ? 'YOU WIN!' : 'CPU WINS!'); sendRequest('ui/requests/function-call', { call: 'showWinnerModal', - args: {winner: localPlayerScore >= 3 ? 'player' : 'cpu'}, + args: {winner: localPlayerScore >= targetScore ? 'player' : 'cpu'}, }).catch(e => console.error('Failed to trigger showWinnerModal:', e)); } } diff --git a/samples/community/agent/adk/mcp_app_proxy/tools.py b/samples/community/agent/adk/mcp_app_proxy/tools.py index 7ca6d051ff..c09be67a61 100644 --- a/samples/community/agent/adk/mcp_app_proxy/tools.py +++ b/samples/community/agent/adk/mcp_app_proxy/tools.py @@ -206,6 +206,86 @@ async def get_pong_mcp_app_json(tool_context: ToolContext): return {"validated_a2ui_json": messages} +async def get_pong_app_web_frame_json(tool_context: ToolContext): + """Fetches the Pong game app using the WebAppFrameUrl component.""" + + # Reset score on reload + global PONG_CURRENT_SCORE + PONG_CURRENT_SCORE = {"player": 0, "cpu": 0} + + messages = [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": PONG_SURFACE_ID, + "catalogId": ( + "https://a2ui.org/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json" + ), + }, + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": PONG_SURFACE_ID, + "path": "/", + "value": { + "pong_state": { + "player_score": PONG_CURRENT_SCORE["player"], + "cpu_score": PONG_CURRENT_SCORE["cpu"], + "commentary": "Let the match begin!", + } + }, + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": PONG_SURFACE_ID, + "components": [ + { + "id": "root", + "component": "PongLayout", + "mcpComponent": "web_frame_app_root", + "scoreboardComponent": "scoreboard_root", + }, + { + "id": "web_frame_app_root", + "component": "WebAppFrameUrl", + "url": "http://localhost:8081/pong_app_web_frame.html", + "allowedEvents": { + "commentate_pong": { + "type": "object", + "properties": { + "game_event": {"type": "string"}, + "silent": {"type": "boolean"}, + }, + } + }, + "allowedFunctions": { + "showWinnerModal": { + "type": "object", + "properties": {"winner": {"type": "string"}}, + } + }, + "mutableData": {"state": {}}, + "config": {"matchingScore": 5}, + "data": {"paths": {"state": "/pong_state"}}, + }, + { + "id": "scoreboard_root", + "component": "PongScoreBoard", + "playerScore": {"path": "/pong_state/player_score"}, + "cpuScore": {"path": "/pong_state/cpu_score"}, + "commentary": {"path": "/pong_state/commentary"}, + }, + ], + }, + }, + ] + tool_context.actions.skip_summarization = True + return {"validated_a2ui_json": messages} + + async def commentate_pong_game(tool_context: ToolContext, game_event: str): """Generates a witty neon-themed sports commentary or lighthearted trash talk comment based on the game event description, and applies it to the game scoreboard. diff --git a/samples/community/client/angular/package.json b/samples/community/client/angular/package.json index 36f288499e..8447e721f5 100644 --- a/samples/community/client/angular/package.json +++ b/samples/community/client/angular/package.json @@ -5,7 +5,7 @@ "scripts": { "ng": "ng", "start": "yarn build:sandbox && ng serve", - "build:sandbox": "mkdir -p projects/mcp_calculator/public/mcp_apps_inner_iframe && npx esbuild ../../client/shared/mcp_apps_inner_iframe/sandbox.ts --bundle --outfile=projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.js --format=esm --platform=browser && cp ../../client/shared/mcp_apps_inner_iframe/sandbox.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.html", + "build:sandbox": "mkdir -p projects/mcp_calculator/public/mcp_apps_inner_iframe && npx esbuild ../../client/shared/mcp_apps_inner_iframe/sandbox.ts --bundle --outfile=projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.js --format=esm --platform=browser && cp ../../client/shared/mcp_apps_inner_iframe/sandbox.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.html && cp ../../client/shared/mcp_apps_inner_iframe/sandbox-url.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox-url.html", "build": "ng build a2a-chat-canvas && ng build orchestrator && ng build mcp_calculator", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -34,6 +34,7 @@ "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", "express": "^5.2.1", + "fast-json-stable-stringify": "^2.1.0", "markdown-it": "^14.2.0", "ng2-charts": "^10.0.0", "rxjs": "^7.8.2", @@ -47,6 +48,7 @@ "@angular/cli": "^21.2.5", "@angular/compiler-cli": "^21.2.5", "@types/express": "^5.0.6", + "@types/fast-json-stable-stringify": "^2.1.2", "@types/markdown-it": "^14.1.2", "@types/node": "^25.9.3", "@types/uuid": "^11.0.0", diff --git a/samples/community/client/angular/projects/mcp_calculator/package.json b/samples/community/client/angular/projects/mcp_calculator/package.json index d90d236782..b507800d58 100644 --- a/samples/community/client/angular/projects/mcp_calculator/package.json +++ b/samples/community/client/angular/projects/mcp_calculator/package.json @@ -12,7 +12,8 @@ }, "dependencies": { "@a2ui/angular": "^0.10.0", - "@a2ui/web_core": "^0.10.0" + "@a2ui/web_core": "^0.10.0", + "ajv": "^8.20.0" }, "devDependencies": { "wireit": "^0.15.0-pre.2" diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts index da82f75b17..704230c96c 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/catalog.ts @@ -26,6 +26,7 @@ import {McpApp} from './mcp-app'; import {PongScoreBoard} from './pong-scoreboard'; import {PongLayout} from './pong-layout'; import {Column} from '@a2ui/angular'; +import {WebAppFrameUrl} from './web-app-frame-url'; /** * The catalog ID for the MCP App catalog. @@ -53,6 +54,16 @@ const PongLayoutSchema = z.object({ scoreboardComponent: z.string().optional(), }); +const WebAppFrameUrlSchema = z.object({ + url: DynamicStringSchema, + data: DynamicValueSchema.optional(), + height: DynamicNumberSchema.optional(), + allowedEvents: z.record(z.any()).optional(), + allowedFunctions: z.record(z.any()).optional(), + mutableData: z.record(z.any()).optional(), + disableSchemaValidation: z.boolean().optional(), +}); + export const SHOW_WINNER_MODAL_FN = createFunctionImplementation( { name: 'showWinnerModal', @@ -127,6 +138,7 @@ export const DEMO_CATALOG = new Catalog( {name: 'McpApp', component: McpApp, schema: McpAppSchema}, {name: 'PongScoreBoard', component: PongScoreBoard, schema: PongScoreBoardSchema}, {name: 'PongLayout', component: PongLayout, schema: PongLayoutSchema}, + {name: 'WebAppFrameUrl', component: WebAppFrameUrl, schema: WebAppFrameUrlSchema}, // Column should use ColumnApi.schema from @a2ui/web_core, but it is not currently // exported by the version of @a2ui/web_core resolved in this community sample. // We use z.any() to avoid duplicating the schema definition here. diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts index 7b487f7161..8b0306fb5c 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts @@ -22,6 +22,7 @@ import { PostMessageTransport, SANDBOX_PROXY_READY_METHOD, } from '@modelcontextprotocol/ext-apps/app-bridge'; +import stringify from 'fast-json-stable-stringify'; import { ChangeDetectionStrategy, Component, @@ -282,7 +283,7 @@ export class McpApp extends CatalogComponent implements OnDestroy, OnInit { // Two-way local data binding: Subscribe to host Data Model changes if (surface && Object.keys(dataPaths).length > 0) { for (const [key, dataPath] of Object.entries(dataPaths)) { - this.lastBoundRootValues[key] = JSON.stringify(surface.dataModel.get(dataPath) ?? null); + this.lastBoundRootValues[key] = stringify(surface.dataModel.get(dataPath) ?? null); const sub = surface.dataModel.subscribe(dataPath, value => { // Suppress echoes: If the update was initiated by the app itself, do not @@ -303,13 +304,13 @@ export class McpApp extends CatalogComponent implements OnDestroy, OnInit { // - For primitives: do a direct comparison of the values and update accordingly. const prevStr = this.lastBoundRootValues[key]; const prev = prevStr ? JSON.parse(prevStr) : null; - this.lastBoundRootValues[key] = JSON.stringify(value ?? null); + this.lastBoundRootValues[key] = stringify(value ?? null); if (value && typeof value === 'object') { // Diff the current root object against the previous cached root object for (const [k, v] of Object.entries(value)) { const oldVal = prev ? prev[k] : undefined; - if (JSON.stringify(oldVal) === JSON.stringify(v)) { + if (stringify(oldVal) === stringify(v)) { continue; } (currentBridge as any) @@ -327,7 +328,7 @@ export class McpApp extends CatalogComponent implements OnDestroy, OnInit { } } else { // Fallback for primitives - if (JSON.stringify(prev) === JSON.stringify(value)) { + if (stringify(prev) === stringify(value)) { return; } (currentBridge as any) @@ -363,7 +364,7 @@ export class McpApp extends CatalogComponent implements OnDestroy, OnInit { // Perform basic check against current live store state to prevent redundant writes const currentValue = surface.dataModel.get(targetPath); - if (JSON.stringify(currentValue) === JSON.stringify(params.value)) { + if (stringify(currentValue) === stringify(params.value)) { return; } diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts new file mode 100644 index 0000000000..49318ba070 --- /dev/null +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-url.ts @@ -0,0 +1,517 @@ +/** + * 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 {CatalogComponent, A2uiRendererService} from '@a2ui/angular/v0_9'; +import {ComponentApi, DataContext} from '@a2ui/web_core/v0_9'; +import {z} from 'zod'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + inject, + OnDestroy, + OnInit, + signal, + viewChild, +} from '@angular/core'; +import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; +import Ajv from 'ajv'; +import { + IncomingWebFrameMessageSchema, + IncomingWebFrameMessage, + A2uiMessageType, +} from './web-frame-messages'; +import stringify from 'fast-json-stable-stringify'; + +const WebAppFrameUrlPropsSchema = z.object({ + url: z.string().optional(), + config: z.record(z.unknown()).optional(), + data: z.any().optional(), + allowedEvents: z.record(z.unknown()).optional(), + allowedFunctions: z.record(z.unknown()).optional(), + mutableData: z.record(z.unknown()).optional(), + disableSchemaValidation: z.boolean().optional(), +}); + +export interface WebAppFrameUrlApi extends ComponentApi { + name: 'WebAppFrameUrl'; +} + +@Component({ + selector: 'a2ui-web-app-frame-url', + standalone: true, + imports: [], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: ` + :host { + display: flex; + flex-direction: column; + width: 100%; + height: 500px; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; + overflow: hidden; + position: relative; + } + + iframe { + flex: 1; + max-width: 100%; + max-height: 100%; + border: none; + background-color: white; /* Ensure content is readable */ + } + `, + template: ` `, +}) +export class WebAppFrameUrl + extends CatalogComponent + implements OnDestroy, OnInit +{ + private readonly sanitizer = inject(DomSanitizer); + private readonly rendererService = inject(A2uiRendererService); + + protected readonly allowedEvents = computed>( + () => this.props()['allowedEvents']?.value() || {}, + ); + protected readonly allowedFunctions = computed>( + () => this.props()['allowedFunctions']?.value() || {}, + ); + protected readonly mutableData = computed>( + () => this.props()['mutableData']?.value() || {}, + ); + protected readonly disableSchemaValidation = computed( + () => this.props()['disableSchemaValidation']?.value() || false, + ); + protected readonly dataPaths = computed>(() => { + const dataProp = this.props()['data']; + if (!dataProp) return {}; + + const rawPaths = (dataProp.raw as {paths?: Record})?.paths; + const valuePaths = dataProp.value()?.paths; + + return rawPaths ?? valuePaths ?? {}; + }); + + protected readonly iframeSrc = signal( + this.sanitizer.bypassSecurityTrustResourceUrl('about:blank'), + ); + + private ajv = new Ajv(); + private iframe = viewChild.required>('iframe'); + private messageHandler: ((event: MessageEvent) => void) | null = null; + private dataSubscriptions: {unsubscribe: () => void}[] = []; + private resizeTimeout: ReturnType | null = null; + private lastWidth?: number; + private lastHeight?: number; + private lastBoundRootValues: Record = {}; + private isProcessingAppWrite = false; + private expectedOrigin = window.location.origin; // In production this should be validated + private targetUrl: string | null = null; + + private appPort: MessagePort | null = null; + private hostResizeObserver: ResizeObserver | null = null; + + ngOnInit() { + const urlProp = this.props()['url']?.value(); + if (urlProp && typeof urlProp === 'string') { + const url = new URL(urlProp); + url.searchParams.set('origin', window.location.origin); + this.expectedOrigin = url.origin; + this.targetUrl = url.toString(); + } + + const urlParams = new URLSearchParams(window.location.search); + const disableSecuritySelfTest = urlParams.get('disable_security_self_test') === 'true'; + + const currentOrigin = window.location.origin; + let sandboxUrl = `${currentOrigin}/mcp_apps_inner_iframe/sandbox-url.html`; + if (disableSecuritySelfTest) { + sandboxUrl += '?disable_security_self_test=true'; + } + this.iframeSrc.set(this.sanitizer.bypassSecurityTrustResourceUrl(sandboxUrl)); + + this.setupSandbox(); + } + + ngOnDestroy() { + this.clearDataSubscriptions(); + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } + if (this.hostResizeObserver) { + this.hostResizeObserver.disconnect(); + this.hostResizeObserver = null; + } + } + + private clearDataSubscriptions() { + if (this.dataSubscriptions) { + this.dataSubscriptions.forEach(sub => sub.unsubscribe()); + this.dataSubscriptions = []; + } + } + + private handleSizeChange(width?: number, height?: number) { + if (this.resizeTimeout) { + return; + } + + this.resizeTimeout = setTimeout(() => { + this.resizeTimeout = null; + const iframeEl = this.iframe().nativeElement; + if (!iframeEl) return; + + const targetWidth = width !== undefined ? Math.max(200, Math.min(width, 3000)) : undefined; + const targetHeight = height !== undefined ? Math.max(100, Math.min(height, 2000)) : undefined; + + const widthDiff = + targetWidth !== undefined && this.lastWidth !== undefined + ? Math.abs(targetWidth - this.lastWidth) + : 100; + const heightDiff = + targetHeight !== undefined && this.lastHeight !== undefined + ? Math.abs(targetHeight - this.lastHeight) + : 100; + + if (targetWidth !== undefined && widthDiff >= 5) { + iframeEl.style.width = `${targetWidth}px`; + const parent = iframeEl.parentElement; + if (parent) { + parent.style.width = `${targetWidth}px`; + } + this.lastWidth = targetWidth; + } + + if (targetHeight !== undefined && heightDiff >= 5) { + iframeEl.style.height = `${targetHeight}px`; + const parent = iframeEl.parentElement; + if (parent) { + parent.style.height = `${targetHeight}px`; + parent.style.aspectRatio = 'auto'; + } + this.lastHeight = targetHeight; + } + }, 100); + } + + private handleSandboxProxyReady(iframeEl: HTMLIFrameElement) { + if (this.targetUrl && iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.SandboxResourceReady, + url: this.targetUrl, + }, + window.location.origin, + ); + } + } + + private handleAction( + data: Extract, + ) { + if (data.action in this.allowedEvents()) { + const schema = this.allowedEvents()[data.action]; + if (!this.disableSchemaValidation() && schema) { + const validate = this.ajv.compile(schema); + if (!validate(data.data || {})) { + console.warn(`Action ${data.action} failed schema validation:`, validate.errors); + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + surface.dispatchAction( + { + event: { + name: data.action, + context: data.data || {}, + }, + }, + this.componentId(), + ); + } + } else { + console.warn(`Action ${data.action} not in allowedEvents`); + } + } + + private handleDataModelChange( + data: Extract, + ) { + if (!(data.key in this.mutableData())) { + console.warn(`Data key ${data.key} not authorized for mutation`); + return; + } + const schema = this.mutableData()[data.key]; + if (!this.disableSchemaValidation() && schema) { + const validate = this.ajv.compile(schema); + if (!validate(data.value)) { + console.warn(`Data change for ${data.key} failed schema validation:`, validate.errors); + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + const dataPaths = this.dataPaths(); + + if (dataPaths[data.key]) { + const dataPath = dataPaths[data.key]; + const targetPath = data.subpath + ? `${dataPath}${data.subpath.startsWith('/') ? '' : '/'}${data.subpath}` + : dataPath; + + const currentValue = surface.dataModel.get(targetPath); + if (stringify(currentValue) !== stringify(data.value)) { + this.isProcessingAppWrite = true; + try { + surface.dataModel.set(targetPath, data.value); + } finally { + this.isProcessingAppWrite = false; + } + } + } + } + } + + private async handleFunctionCall( + data: Extract, + iframeEl: HTMLIFrameElement, + ) { + if (data.call in this.allowedFunctions()) { + const schema = this.allowedFunctions()[data.call]; + if (!this.disableSchemaValidation() && schema) { + const validate = this.ajv.compile(schema); + if (!validate(data.args || {})) { + console.warn(`Function ${data.call} failed schema validation:`, validate.errors); + if (iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.FunctionResult, + call: data.call, + callId: data.callId, + status: 'error', + error: { + code: 'VALIDATION_ERROR', + message: 'Arguments failed schema validation', + }, + }, + window.location.origin, + ); + } + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + if (surface) { + const dataContext = new DataContext(surface, '/'); + try { + const result = await surface.catalog.invoker(data.call, data.args || {}, dataContext); + if (iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.FunctionResult, + call: data.call, + callId: data.callId, + status: 'success', + result: result, + }, + window.location.origin, + ); + } + } catch (err: unknown) { + if (iframeEl.contentWindow) { + const errorMessage = + err instanceof Error ? err.message : String(err) || 'Error executing function'; + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.FunctionResult, + call: data.call, + callId: data.callId, + status: 'error', + error: { + code: 'EXECUTION_ERROR', + message: errorMessage, + }, + }, + window.location.origin, + ); + } + } + } + } else { + console.warn(`Function ${data.call} not in allowedFunctions`); + } + } + + private setupSandbox() { + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } + + this.messageHandler = async (event: MessageEvent) => { + // Basic origin check + if (event.origin !== this.expectedOrigin && event.origin !== window.location.origin) { + return; + } + + const iframeEl = this.iframe().nativeElement; + if (!iframeEl || event.source !== iframeEl.contentWindow) { + return; + } + + const parsedData = IncomingWebFrameMessageSchema.safeParse(event.data); + if (!parsedData.success) { + return; // Ignore invalid or unrecognized messages + } + + const data = parsedData.data; + + if (data.type === A2uiMessageType.SandboxProxyReady) { + this.handleSandboxProxyReady(iframeEl); + return; + } + + if (data.type === A2uiMessageType.AppFrameReady) { + this.initializeBridge(); + } else if (data.type === A2uiMessageType.Action) { + this.handleAction(data); + } else if (data.type === A2uiMessageType.DataModelChange) { + this.handleDataModelChange(data); + } else if (data.type === A2uiMessageType.FunctionCall) { + await this.handleFunctionCall(data, iframeEl); + } else if (data.type === A2uiMessageType.SizeChanged) { + this.handleSizeChange(data.width, data.height); + } + }; + + window.addEventListener('message', this.messageHandler); + } + + private initializeBridge() { + this.clearDataSubscriptions(); + + const surface = this.rendererService.surfaceGroup.getSurface(this.surfaceId()); + const dataPaths = this.dataPaths(); + + const initialData: Record = {}; + + if (surface && Object.keys(dataPaths).length > 0) { + for (const [key, dataPath] of Object.entries(dataPaths)) { + initialData[key] = surface.dataModel.get(dataPath); + this.lastBoundRootValues[key] = stringify(initialData[key] ?? null); + + const sub = surface.dataModel.subscribe(dataPath, value => { + if (this.isProcessingAppWrite) return; + + const iframeEl = this.iframe().nativeElement; + if (!iframeEl.contentWindow) return; + + const prevStr = this.lastBoundRootValues[key]; + const prev = prevStr ? JSON.parse(prevStr) : null; + this.lastBoundRootValues[key] = stringify(value ?? null); + + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + const oldVal = prev ? prev[k] : undefined; + if (stringify(oldVal) !== stringify(v)) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.DataModelUpdate, + key, + subpath: `/${k}`, + value: v, + }, + window.location.origin, + ); + } + } + } else { + if (stringify(prev) !== stringify(value)) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.DataModelUpdate, + key, + value, + }, + window.location.origin, + ); + } + } + }); + this.dataSubscriptions.push(sub); + } + } + + const iframeEl = this.iframe().nativeElement; + if (iframeEl.contentWindow) { + const channel = new MessageChannel(); + this.appPort = channel.port1; + + const rect = iframeEl.getBoundingClientRect(); + const hostContext = { + containerDimensions: { + width: rect.width, + height: rect.height, + }, + }; + + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.AppFrameInit, + value: { + config: this.props()['config']?.value() ?? {}, + initialData: initialData, + allowedEvents: this.allowedEvents(), + allowedFunctions: this.allowedFunctions(), + mutableDataKeys: Object.keys(this.mutableData()), + hostContext: hostContext, + }, + }, + window.location.origin, + [channel.port2], + ); + + if (this.hostResizeObserver) { + this.hostResizeObserver.disconnect(); + } + this.hostResizeObserver = new ResizeObserver(entries => { + const entry = entries[0]; + if (entry && iframeEl.contentWindow) { + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.HostContextUpdate, + value: { + containerDimensions: { + width: entry.contentRect.width, + height: entry.contentRect.height, + }, + }, + }, + window.location.origin, + ); + } + }); + this.hostResizeObserver.observe(iframeEl); + } + } +} diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md new file mode 100644 index 0000000000..712f812e7f --- /dev/null +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-component_spec.md @@ -0,0 +1,700 @@ +# A2UI WebApp Iframe Component Specification (v0.9) + +## A Specification for Sandboxed, Rich Interactive Components in the Agent-to-UI Protocol + +Jul 27, 2026 +Status: In progress + +# Abstract + +This specification document defines the A2UI Iframe Component (v0.9) for the secure, sandboxed +rendering of rich interactive web applications and model-generated HTML content. This document +serves two primary purposes: + +1. **Platform Implementation Blueprint:** It provides client-side platform developers with a strict, + standard set of instructions to implement compliant `WebAppFrameUrl` and `WebAppFrameSrcdoc` + components in any native programming language (e.g., TypeScript/Web, Kotlin/Android, Swift/iOS, + or Dart/Flutter) while maintaining identical security and sandboxing guarantees. +2. **Interoperable Application Standard:** It defines a secure, transport-agnostic runtime + environment and messaging contract. Embedded web application developers can build highly + portable, rich interactive tools that are guaranteed to run seamlessly, sync state, and invoke + local functions inside "any" A2UI-compliant client wrapper. + +# 1. Introduction and motivation + +The A2UI protocol is designed to stream structured, type-safe JSON component trees to a client +renderer. While A2UI provides standard primitive components (e.g., `Text`, `Row`, `Button`, +`TextField`), complex enterprise use cases often require: + +- **Deterministic rendering** of highly custom legacy dashboards, charts, and visualizations. +- **Interactive embeds** like maps, complex multi-step forms, and dynamic tools (e.g., rich text + editors, calculators, interactive games) served directly by remote servers. +- **Strict isolation** of untrusted third-party applications to protect the host application's DOM, + session cookies, and storage. + +The **A2UI Iframe Component** bridges this gap. It defines a secure runtime environment inside a +sandboxed proxy. + +In **A2UI v0.9**, the following new A2UI features can significantly increase the Iframe component's +utility: + +- **Local Client-Side Function Calls:** Allowing isolated apps to trigger secure local custom + functions (e.g., querying system hardware, opening URLs, local formatting). +- **Two-Way Local Data Binding:** Establishing a direct, reactive, network-free synchronization loop + between the iframe's internal state and the parent A2UI local Data Model. + +# 2. Architectural overview + +In complex agentic workflows, rendering rich third-party widgets, charts, and legacy dashboards +safely is critical. A2UI provides web-app embedding frames to run isolated code safely. + +To meet both security and performance requirements, A2UI separates this specification into two +layers: + +1. **The WebAppFrame Runtime & Communication Contract:** A single, unified transport protocol that + defines how _any_ application running inside an A2UI-based iframe communicates with the host. It + covers JSON-RPC event messaging, local Two-Way Data Binding, and client-side function execution + to support A2UI v0.9 features. +2. **Component Catalog Definitions & Rendering Setups:** Two separate frontend component + definitions—**WebAppFrameUrl** and **WebAppFrameSrcdoc**—each with a tailored schema and unique + sandbox/security configurations corresponding to their specific source type (external URL vs. raw + inline HTML). + +# 3. The WebAppFrame runtime and communication contract + +While simple, LLM-generated applications can use raw, fire-and-forget `window.postMessage` events +for zero-dependency execution, **human developers should use the official `@a2ui/web-bridge` SDK +(coming soon)** (or equivalent). + +The `@a2ui/web-bridge` SDK establishes a private `MessageChannel` between the host and the iframe +and wraps the underlying protocol into a secure, type-safe, and Promise-based API. This hides the +complexity of request correlation, deep-equality checks, and message origin validation. + +However, at the wire level, all communications occur using custom top-level message string tags +(`a2ui_*`) with flat keys. The protocol definitions below represent this underlying wire format. + +## 3.1. Sandbox bootstrap lifecycle + +Before the application-level handshake occurs, WebAppFrame components that rely on the +**Double-Iframe Sandboxing** architecture (such as `WebAppFrameUrl` loading external 3P content) +must complete an infrastructure-level bootstrap sequence. + +This bootstrap ensures that the untrusted URL or HTML content is securely injected into a strict +inner sandbox, rather than loading directly into the outer proxy frame. + +```mermaid +sequenceDiagram + participant Host as Host Client (WebAppFrameUrl) + participant Proxy as Outer Proxy (sandbox.html) + participant Inner as Inner Sandbox (Untrusted App) + + Proxy->>Host: 1. a2ui_sandbox_proxy_ready + Host->>Proxy: 2. a2ui_sandbox_resource_ready (url or htmlContent) + Proxy->>Inner: 3. Injects resource into sandboxed iframe +``` + +1. **`a2ui_sandbox_proxy_ready` (Proxy -> Host):** The outer proxy iframe (e.g. `sandbox.html`) is + loaded from a trusted origin. Once its script initializes, it sends this message to the Host to + signal it is ready to receive untrusted content. + ```json + { + "type": "a2ui_sandbox_proxy_ready" + } + ``` +2. **`a2ui_sandbox_resource_ready` (Host -> Proxy):** The Host intercepts the proxy ready signal and + replies with the untrusted URL (or raw HTML for Srcdoc). + ```json + { + "type": "a2ui_sandbox_resource_ready", + "url": "https://untrusted-3p-app.com/" + } + ``` +3. **Inner Sandbox Creation:** The proxy frame dynamically creates an inner `