diff --git a/samples/community/agent/adk/mcp_app_proxy/agent.py b/samples/community/agent/adk/mcp_app_proxy/agent.py index 559d18a59..012c21923 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, get_pong_app_web_frame_json, commentate_pong_game +from tools import get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, get_pong_app_web_frame_json, get_pong_app_web_frame_srcdoc_json, commentate_pong_game from agent_executor import get_a2ui_enabled, get_a2ui_catalog, get_a2ui_examples logger = logging.getLogger(__name__) @@ -40,6 +40,7 @@ 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. +When the user asks for Pong with WebApp Srcdoc, you MUST call the `get_pong_app_web_frame_srcdoc_json` tool. IMPORTANT: Do NOT attempt to construct the JSON manually. The tools handle it automatically. @@ -53,6 +54,7 @@ - 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 asks for Pong with WebApp Srcdoc: Call `get_pong_app_web_frame_srcdoc_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. """ @@ -178,6 +180,13 @@ def _build_agent_card(self) -> AgentCard: tags=["html", "app", "demo", "tool"], examples=["open pong with webapp url"], ), + AgentSkill( + id="open_pong_web_frame_srcdoc", + name="Open Pong with WebApp Srcdoc", + description="Opens Pong using the new WebAppFrame Srcdoc method.", + tags=["html", "app", "demo", "tool"], + examples=["open pong with webapp srcdoc"], + ), ], ) @@ -217,6 +226,7 @@ def _build_llm_agent( calculate_via_mcp, get_pong_mcp_app_json, get_pong_app_web_frame_json, + get_pong_app_web_frame_srcdoc_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 2c8596284..eea07dbc6 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 @@ -146,6 +146,65 @@ ], "unevaluatedProperties": false }, + "WebAppFrameSrcdoc": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "WebAppFrameSrcdoc", + "description": "The component type identifier." + }, + "htmlContent": { + "type": "string", + "description": "The raw HTML string to render via srcdoc." + }, + "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", "htmlContent"] + } + ], + "unevaluatedProperties": false + }, "PongScoreBoard": { "type": "object", "allOf": [ @@ -218,6 +277,9 @@ }, { "$ref": "#/components/WebAppFrameUrl" + }, + { + "$ref": "#/components/WebAppFrameSrcdoc" } ] } diff --git a/samples/community/agent/adk/mcp_app_proxy/tools.py b/samples/community/agent/adk/mcp_app_proxy/tools.py index c09be67a6..9638b1b4d 100644 --- a/samples/community/agent/adk/mcp_app_proxy/tools.py +++ b/samples/community/agent/adk/mcp_app_proxy/tools.py @@ -11,8 +11,10 @@ # 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 asyncio import os import urllib.parse +import urllib.request import traceback import logging @@ -28,6 +30,12 @@ PONG_CURRENT_SCORE = {"player": 0, "cpu": 0} +def _fetch_url(url: str, headers: dict[str, str], timeout: float = 2.0) -> str: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as response: + return response.read().decode("utf-8") + + # Define get_calculator_app tool in a way that the LlmAgent can use. async def get_calculator_app(tool_context: ToolContext): """Fetches the calculator app.""" @@ -286,6 +294,101 @@ async def get_pong_app_web_frame_json(tool_context: ToolContext): return {"validated_a2ui_json": messages} +async def get_pong_app_web_frame_srcdoc_json(tool_context: ToolContext): + """Fetches the Pong game app using the WebAppFrameSrcdoc component.""" + + # Reset score on reload + global PONG_CURRENT_SCORE + PONG_CURRENT_SCORE = {"player": 0, "cpu": 0} + + remote_url = os.getenv( + "PONG_SERVER_URL", "http://localhost:8081/pong_app_web_frame_srcdoc.html" + ) + loop = asyncio.get_running_loop() + try: + html_content = await loop.run_in_executor( + None, + _fetch_url, + remote_url, + {"User-Agent": "A2UI-Agent"}, + ) + except Exception as e: + logger.error(f"Could not fetch remote pong app from {remote_url}: {e}.") + return {"error": f"Could not fetch pong app from remote server ({e})"} + + 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": "WebAppFrameSrcdoc", + "htmlContent": html_content, + "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 8447e721f..237a5d970 100644 --- a/samples/community/client/angular/package.json +++ b/samples/community/client/angular/package.json @@ -9,6 +9,7 @@ "build": "ng build a2a-chat-canvas && ng build orchestrator && ng build mcp_calculator", "lint": "eslint .", "lint:fix": "eslint . --fix", + "test": "echo \"Workspace has no tests.\" && exit 0", "clean": "rm -rf dist .tsbuildinfo .wireit out-tsc", "format": "prettier --write .", "format:check": "prettier --check ." 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 704230c96..f32804fc4 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 @@ -27,6 +27,7 @@ import {PongScoreBoard} from './pong-scoreboard'; import {PongLayout} from './pong-layout'; import {Column} from '@a2ui/angular'; import {WebAppFrameUrl} from './web-app-frame-url'; +import {WebAppFrameSrcdoc} from './web-app-frame-srcdoc'; /** * The catalog ID for the MCP App catalog. @@ -56,6 +57,18 @@ const PongLayoutSchema = z.object({ const WebAppFrameUrlSchema = z.object({ url: DynamicStringSchema, + config: z.record(z.any()).optional(), + 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(), +}); + +const WebAppFrameSrcdocSchema = z.object({ + htmlContent: z.string(), + config: z.record(z.any()).optional(), data: DynamicValueSchema.optional(), height: DynamicNumberSchema.optional(), allowedEvents: z.record(z.any()).optional(), @@ -139,6 +152,7 @@ export const DEMO_CATALOG = new Catalog( {name: 'PongScoreBoard', component: PongScoreBoard, schema: PongScoreBoardSchema}, {name: 'PongLayout', component: PongLayout, schema: PongLayoutSchema}, {name: 'WebAppFrameUrl', component: WebAppFrameUrl, schema: WebAppFrameUrlSchema}, + {name: 'WebAppFrameSrcdoc', component: WebAppFrameSrcdoc, schema: WebAppFrameSrcdocSchema}, // 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/web-app-frame-bridge.service.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-bridge.service.ts new file mode 100644 index 000000000..1072268a9 --- /dev/null +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-bridge.service.ts @@ -0,0 +1,472 @@ +/** + * 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 {A2uiRendererService} from '@a2ui/angular/v0_9'; +import {DataContext} from '@a2ui/web_core/v0_9'; +import {DestroyRef, ElementRef, inject, Injectable, Signal} from '@angular/core'; +import Ajv, {ValidateFunction} from 'ajv'; +import stringify from 'fast-json-stable-stringify'; +import { + A2uiMessageType, + IncomingWebFrameMessage, + IncomingWebFrameMessageSchema, +} from './web-frame-messages'; + +export interface WebAppFrameBridgeConfig { + iframe: Signal>; + props: Signal>; + surfaceId: Signal; + componentId: Signal; + getExpectedOrigin: () => string; + onSandboxProxyReady: (iframeEl: HTMLIFrameElement) => void; +} + +@Injectable() +export class WebAppFrameBridgeService { + private readonly destroyRef = inject(DestroyRef); + private readonly rendererService = inject(A2uiRendererService); + + private ajv = new Ajv(); + private validatorCache = new Map(); + 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 appPort: MessagePort | null = null; + private hostResizeObserver: ResizeObserver | null = null; + private config: WebAppFrameBridgeConfig | null = null; + + constructor() { + this.destroyRef.onDestroy(() => { + this.clearDataSubscriptions(); + if (this.appPort) { + this.appPort.close(); + this.appPort = null; + } + 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; + } + }); + } + + initialize(config: WebAppFrameBridgeConfig) { + this.config = config; + this.setupSandbox(); + } + + private allowedEvents(props: Record): Record { + return props['allowedEvents']?.value() || {}; + } + + private allowedFunctions(props: Record): Record { + return props['allowedFunctions']?.value() || {}; + } + + private mutableData(props: Record): Record { + return props['mutableData']?.value() || {}; + } + + private disableSchemaValidation(props: Record): boolean { + return props['disableSchemaValidation']?.value() || false; + } + + private dataPaths(props: Record): Record { + const dataProp = props['data']; + if (!dataProp) return {}; + + const rawPaths = (dataProp.raw as {paths?: Record})?.paths; + const valuePaths = dataProp.value()?.paths; + + return rawPaths ?? valuePaths ?? {}; + } + + private getValidator(schema: object): ValidateFunction { + const key = JSON.stringify(schema); + let validate = this.validatorCache.get(key); + if (!validate) { + validate = this.ajv.compile(schema); + this.validatorCache.set(key, validate); + } + return validate; + } + + 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; + if (!this.config) return; + const iframeEl = this.config.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 handleAction( + data: Extract, + ) { + if (!this.config) return; + const props = this.config.props(); + const allowedEvents = this.allowedEvents(props); + + if (data.action in allowedEvents) { + const schema = allowedEvents[data.action]; + if (!this.disableSchemaValidation(props) && schema) { + const validate = this.getValidator(schema as object); + if (!validate(data.data || {})) { + console.warn(`Action ${data.action} failed schema validation:`, validate.errors); + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.config.surfaceId()); + if (surface) { + surface.dispatchAction( + { + event: { + name: data.action, + context: data.data || {}, + }, + }, + this.config.componentId(), + ); + } + } else { + console.warn(`Action ${data.action} not in allowedEvents`); + } + } + + private handleDataModelChange( + data: Extract, + ) { + if (!this.config) return; + const props = this.config.props(); + const mutableData = this.mutableData(props); + + if (!(data.key in mutableData)) { + console.warn(`Data key ${data.key} not authorized for mutation`); + return; + } + const schema = mutableData[data.key]; + if (!this.disableSchemaValidation(props) && schema) { + const validate = this.getValidator(schema as object); + if (!validate(data.value)) { + console.warn(`Data change for ${data.key} failed schema validation:`, validate.errors); + return; + } + } + const surface = this.rendererService.surfaceGroup.getSurface(this.config.surfaceId()); + if (surface) { + const dataPaths = this.dataPaths(props); + + 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 (!this.config) return; + const props = this.config.props(); + const allowedFunctions = this.allowedFunctions(props); + + if (data.call in allowedFunctions) { + const schema = allowedFunctions[data.call]; + if (!this.disableSchemaValidation(props) && schema) { + const validate = this.getValidator(schema as object); + 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.config.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) => { + if (!this.config) return; + const expectedOrigin = this.config.getExpectedOrigin(); + if (event.origin !== expectedOrigin && event.origin !== window.location.origin) { + return; + } + + const iframeEl = this.config.iframe().nativeElement; + if (!iframeEl || event.source !== iframeEl.contentWindow) { + return; + } + + const parsedData = IncomingWebFrameMessageSchema.safeParse(event.data); + if (!parsedData.success) { + return; + } + + const data = parsedData.data; + + if (data.type === A2uiMessageType.SandboxProxyReady) { + this.config.onSandboxProxyReady(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() { + if (!this.config) return; + this.clearDataSubscriptions(); + + const surface = this.rendererService.surfaceGroup.getSurface(this.config.surfaceId()); + const props = this.config.props(); + const dataPaths = this.dataPaths(props); + + 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; + if (!this.config) return; + + const iframeEl = this.config.iframe().nativeElement; + if (!iframeEl || !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.config.iframe().nativeElement; + if (iframeEl && 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: props['config']?.value() ?? {}, + initialData: initialData, + allowedEvents: this.allowedEvents(props), + allowedFunctions: this.allowedFunctions(props), + mutableDataKeys: Object.keys(this.mutableData(props)), + 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-app-frame-srcdoc.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-srcdoc.ts new file mode 100644 index 000000000..0303b82d0 --- /dev/null +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-app-frame-srcdoc.ts @@ -0,0 +1,161 @@ +/** + * 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} from '@a2ui/angular/v0_9'; +import {ComponentApi} from '@a2ui/web_core/v0_9'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + signal, + viewChild, +} from '@angular/core'; +import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; +import {z} from 'zod'; +import {WebAppFrameBridgeService} from './web-app-frame-bridge.service'; +import {A2uiMessageType, WebAppFrameBasePropsSchema} from './web-frame-messages'; + +const WebAppFrameSrcdocPropsSchema = WebAppFrameBasePropsSchema.extend({ + htmlContent: z.string().optional(), +}); + +export interface WebAppFrameSrcdocApi extends ComponentApi { + name: 'WebAppFrameSrcdoc'; +} + +@Component({ + selector: 'a2ui-web-app-frame-srcdoc', + standalone: true, + imports: [], + providers: [WebAppFrameBridgeService], + 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 WebAppFrameSrcdoc extends CatalogComponent { + private readonly sanitizer = inject(DomSanitizer); + readonly bridge = inject(WebAppFrameBridgeService); + + protected readonly resolvedContent = computed(() => { + let rawContent = this.props()['htmlContent']?.value() ?? null; + if (rawContent && typeof rawContent === 'string' && rawContent.startsWith('url_encoded:')) { + rawContent = decodeURIComponent(rawContent.substring(12)); + } + return typeof rawContent === 'string' ? rawContent : null; + }); + + protected readonly iframeSrc = signal( + this.sanitizer.bypassSecurityTrustResourceUrl('about:blank'), + ); + + private iframe = viewChild.required>('iframe'); + + constructor() { + super(); + + 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.html`; + if (disableSecuritySelfTest) { + sandboxUrl += '?disable_security_self_test=true'; + } + this.iframeSrc.set(this.sanitizer.bypassSecurityTrustResourceUrl(sandboxUrl)); + + this.bridge.initialize({ + iframe: this.iframe, + props: this.props, + surfaceId: this.surfaceId, + componentId: this.componentId, + getExpectedOrigin: () => window.location.origin, + onSandboxProxyReady: iframeEl => this.handleSandboxProxyReady(iframeEl), + }); + } + + /** + * Injects a Content-Security-Policy (CSP) meta tag into the provided HTML string. + * + * Any existing CSP meta tags in the HTML are stripped and replaced with a restricted + * default policy (`default-src 'self' 'unsafe-inline' 'unsafe-eval' data:; connect-src 'none';`). + * The CSP meta tag is injected into the `` element, creating one if necessary. + * + * **Expected Effects of the Injected CSP:** + * - Prevents untrusted HTML from relaxing security policies by overriding preexisting CSP tags. + * - Blocks all outgoing network connections (`connect-src 'none'`), disabling `fetch`, `XMLHttpRequest`, + * `WebSocket`, and `EventSource` calls from within the sandbox iframe. + * - Restricts resource loading (`default-src`) to same-origin scripts/styles (`'self'`), inline code + * (`'unsafe-inline'`), dynamic eval execution (`'unsafe-eval'`), and `data:` URIs. + * + * @param html The raw HTML content string. + * @returns The HTML string with the CSP meta tag injected. + */ + private injectCsp(html: string): string { + let result = html.replace( + /]*?\s+)?http-equiv=["']?Content-Security-Policy["']?[^>]*>/gi, + '', + ); + + const cspMeta = ``; + + if (/(]*>)/i.test(result)) { + result = result.replace(/(]*>)/i, `$1\n ${cspMeta}`); + } else if (/(]*>)/i.test(result)) { + result = result.replace(/(]*>)/i, `$1\n \n ${cspMeta}\n `); + } else { + result = `\n ${cspMeta}\n\n` + result; + } + + return result; + } + + private handleSandboxProxyReady(iframeEl: HTMLIFrameElement) { + const rawContent = this.resolvedContent(); + if (rawContent && iframeEl.contentWindow) { + const securedHtml = this.injectCsp(rawContent); + iframeEl.contentWindow.postMessage( + { + type: A2uiMessageType.SandboxResourceReady, + html: securedHtml, + htmlContent: securedHtml, + sandbox: 'allow-scripts allow-forms allow-popups allow-modals', + }, + window.location.origin, + ); + } + } +} 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 index 49318ba07..b0fa68879 100644 --- 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 @@ -14,48 +14,37 @@ * 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 {CatalogComponent} from '@a2ui/angular/v0_9'; +import {ComponentApi} from '@a2ui/web_core/v0_9'; 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'; +import {z} from 'zod'; +import {WebAppFrameBridgeService} from './web-app-frame-bridge.service'; +import {A2uiMessageType, WebAppFrameBasePropsSchema} from './web-frame-messages'; -const WebAppFrameUrlPropsSchema = z.object({ +const WebAppFrameUrlPropsSchema = WebAppFrameBasePropsSchema.extend({ 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'; } +const INVALID_ORIGIN = 'about:invalid'; + @Component({ selector: 'a2ui-web-app-frame-url', standalone: true, imports: [], + providers: [WebAppFrameBridgeService], changeDetection: ChangeDetectionStrategy.OnPush, styles: ` :host { @@ -79,62 +68,52 @@ export interface WebAppFrameUrlApi extends ComponentApi `, }) -export class WebAppFrameUrl - extends CatalogComponent - implements OnDestroy, OnInit -{ +export class WebAppFrameUrl extends CatalogComponent { 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 ?? {}; - }); + readonly bridge = inject(WebAppFrameBridgeService); 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; + protected readonly targetUrl = computed(() => { + const urlProp = this.props()['url']?.value(); + if (urlProp && typeof urlProp === 'string') { + try { + const url = new URL(urlProp); + // Restrict to http: or https: schemes to prevent javascript:/data:/file: URI injection + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + console.warn(`[WebAppFrameUrl] Disallowed protocol: ${url.protocol}`); + return null; + } + url.searchParams.set('origin', window.location.origin); + return url.toString(); + } catch { + return null; + } + } + return null; + }); - ngOnInit() { + protected readonly expectedOrigin = computed(() => { 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(); + try { + const url = new URL(urlProp); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.origin; + } + } catch { + return INVALID_ORIGIN; + } } + return INVALID_ORIGIN; + }); + + private iframe = viewChild.required>('iframe'); + + constructor() { + super(); const urlParams = new URLSearchParams(window.location.search); const disableSecuritySelfTest = urlParams.get('disable_security_self_test') === 'true'; @@ -146,372 +125,26 @@ export class WebAppFrameUrl } 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); + this.bridge.initialize({ + iframe: this.iframe, + props: this.props, + surfaceId: this.surfaceId, + componentId: this.componentId, + getExpectedOrigin: () => this.expectedOrigin(), + onSandboxProxyReady: iframeEl => this.handleSandboxProxyReady(iframeEl), + }); } private handleSandboxProxyReady(iframeEl: HTMLIFrameElement) { - if (this.targetUrl && iframeEl.contentWindow) { + const targetUrl = this.targetUrl(); + if (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, - }, + url: targetUrl, }, 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 index 712f812e7..351671a35 100644 --- 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 @@ -448,7 +448,7 @@ Used to load standalone, sandboxed, model-generated HTML/JS layouts. }, "htmlContent": { "type": "string", - "description": "The raw HTML string to render via srcdoc." + "description": "The raw HTML string to render via srcdoc. Can be URL-encoded." }, "config": { "type": "object", @@ -468,6 +468,9 @@ Used to load standalone, sandboxed, model-generated HTML/JS layouts. "required": ["paths"], "additionalProperties": false }, + "height": { + "$ref": "common_types.json#/$defs/DynamicNumber" + }, "allowedEvents": { "type": "object", "description": "A map of allowed action names to their expected JSON Schema.", diff --git a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts index 684fda489..b1ba531e1 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts +++ b/samples/community/client/angular/projects/mcp_calculator/src/a2ui-catalog/web-frame-messages.ts @@ -69,3 +69,16 @@ export const IncomingWebFrameMessageSchema = z.discriminatedUnion('type', [ ]); export type IncomingWebFrameMessage = z.infer; + +/** + * Shared base Zod schema for A2UI WebAppFrame components. + * Contains common optional properties shared across URL-based and HTML-based frames. + */ +export const WebAppFrameBasePropsSchema = z.object({ + 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(), +}); diff --git a/samples/community/client/angular/projects/mcp_calculator/src/app/app.html b/samples/community/client/angular/projects/mcp_calculator/src/app/app.html index 5b96e63a6..8c659125e 100644 --- a/samples/community/client/angular/projects/mcp_calculator/src/app/app.html +++ b/samples/community/client/angular/projects/mcp_calculator/src/app/app.html @@ -41,6 +41,10 @@ public Open Pong from remote web server + diff --git a/samples/community/client/shared/mcp_apps_inner_iframe/sandbox.ts b/samples/community/client/shared/mcp_apps_inner_iframe/sandbox.ts index 3ffbda68c..795182f38 100644 --- a/samples/community/client/shared/mcp_apps_inner_iframe/sandbox.ts +++ b/samples/community/client/shared/mcp_apps_inner_iframe/sandbox.ts @@ -92,7 +92,8 @@ window.addEventListener('message', async event => { if (isMcpResourceReady || isA2uiResourceReady) { const payload = isMcpResourceReady ? event.data.params : event.data; - const {html, url, sandbox, permissions} = payload as any; + const {html, htmlContent, url, sandbox, permissions} = payload as any; + const contentHtml = html ?? htmlContent; if (typeof sandbox === 'string') { inner.setAttribute('sandbox', sandbox); } @@ -109,9 +110,9 @@ window.addEventListener('message', async event => { } }; - if (typeof html === 'string') { + if (typeof contentHtml === 'string') { inner.onload = sendInit; - inner.srcdoc = html; + inner.srcdoc = contentHtml; } else if (typeof url === 'string') { inner.onload = sendInit; inner.src = url; diff --git a/samples/community/web/pong/README.md b/samples/community/web/pong/README.md index ffda0fa76..a80d5fb1b 100644 --- a/samples/community/web/pong/README.md +++ b/samples/community/web/pong/README.md @@ -32,7 +32,9 @@ Once started, the server will output: Serving at port 8081 ``` -You can then use the Pong web frame application as a frame source URL in your web client at: -`http://localhost:8081/pong_app_web_frame.html` +You can then use the Pong web frame application in your web client at: + +- `http://localhost:8081/pong_app_web_frame.html` (for `WebAppFrameUrl`) +- `http://localhost:8081/pong_app_web_frame_srcdoc.html` (for `WebAppFrameSrcdoc`, fetched remotely by the agent) The server runs indefinitely until stopped. You can stop it by pressing `Ctrl+C` in your terminal. diff --git a/samples/community/web/pong/pong_server.py b/samples/community/web/pong/pong_server.py index eb95e5df3..a9faf2873 100644 --- a/samples/community/web/pong/pong_server.py +++ b/samples/community/web/pong/pong_server.py @@ -19,6 +19,9 @@ PORT = 8081 DIRECTORY = os.path.dirname(os.path.abspath(__file__)) +PONG_WEB_FRAME_PATH = "/pong_app_web_frame.html" +PONG_WEB_FRAME_SRCDOC_PATH = "/pong_app_web_frame_srcdoc.html" +PONG_APP_PATHS = (PONG_WEB_FRAME_PATH, PONG_WEB_FRAME_SRCDOC_PATH) class Handler(http.server.SimpleHTTPRequestHandler): @@ -28,7 +31,7 @@ def __init__(self, *args, **kwargs): def do_GET(self): parsed_path = urllib.parse.urlparse(self.path) - if parsed_path.path == "/pong_app_web_frame.html": + if parsed_path.path in PONG_APP_PATHS: self.send_response(200) self.send_header("Content-type", "text/html") self.send_header("Access-Control-Allow-Origin", "http://localhost:4200") @@ -55,9 +58,14 @@ def do_GET(self): html_content = html_content.replace("// {{BRIDGE_SCRIPT}}", bridge).replace( "// {{ENGINE_SCRIPT}}", engine ) - html_content = html_content.replace( - "🔌 Embedded MCP App", "🌐 Embedded Web App (URL)" - ) + if parsed_path.path == PONG_WEB_FRAME_SRCDOC_PATH: + html_content = html_content.replace( + "🔌 Embedded MCP App", "📦 Embedded Web App (Srcdoc)" + ) + else: + html_content = html_content.replace( + "🔌 Embedded MCP App", "🌐 Embedded Web App (URL)" + ) self.wfile.write(html_content.encode("utf-8")) return @@ -65,7 +73,7 @@ def do_GET(self): def end_headers(self): parsed_path = urllib.parse.urlparse(self.path) - if not parsed_path.path == "/pong_app_web_frame.html": + if parsed_path.path not in PONG_APP_PATHS: self.send_header("Access-Control-Allow-Origin", "http://localhost:4200") super().end_headers() diff --git a/samples/community/web/pong/pong_web_frame_bridge.js b/samples/community/web/pong/pong_web_frame_bridge.js index 2c50c9733..fab7bc534 100644 --- a/samples/community/web/pong/pong_web_frame_bridge.js +++ b/samples/community/web/pong/pong_web_frame_bridge.js @@ -23,13 +23,30 @@ * Failing to explicitly set this origin would allow any malicious parent site * to silently intercept sensitive messages and data sent from this iframe. */ -const urlParams = new URLSearchParams(window.location.search); -const PARENT_ORIGIN = urlParams.get('origin'); -if (!PARENT_ORIGIN) { +const PARENT_ORIGIN = (() => { + const urlParams = new URLSearchParams(window.location.search); + const originParam = urlParams.get('origin'); + if (originParam) { + return originParam; + } + + const isSrcdocOrSandboxed = + window.location.origin === 'null' || + window.location.protocol === 'about:' || + window.location.protocol === 'data:'; + + if (isSrcdocOrSandboxed) { + console.log( + 'A2UI Web Frame: Running in srcdoc/sandboxed mode without "?origin="; defaulting target origin to "*".', + ); + return '*'; + } + console.error( - 'A2UI Web Frame: The parent origin must be specified via the "?origin=" query parameter for security.', + 'A2UI Web Frame: Missing required "?origin=" parameter in URL mode. Refusing to broadcast postMessage to "*".', ); -} + return 'null'; +})(); const MSG_TYPE_ACTION = 'a2ui_action'; const MSG_TYPE_DATA_MODEL_CHANGE = 'a2ui_data_model_change';