diff --git a/.gitignore b/.gitignore index 14a290e7b..e1104728b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ site/ a2a_agents/python/a2ui_agent/src/a2ui/assets/**/*.json ## new agent SDK path agent_sdks/python/src/a2ui/assets/**/*.json +## Generated JS file from the strictly-typed `sandbox.ts`. +samples/client/angular/projects/orchestrator/public/sandbox_iframe/sandbox.js diff --git a/samples/client/angular/package.json b/samples/client/angular/package.json index 8caa9f3e1..8e967a56b 100644 --- a/samples/client/angular/package.json +++ b/samples/client/angular/package.json @@ -13,7 +13,8 @@ "serve:ssr:contact": "node dist/contact/server/server.mjs", "build:renderer": "cd ../../../renderers && for dir in 'web_core' 'markdown/markdown-it'; do (cd \"$dir\" && npm install && npm run build); done", "serve:agent:restaurant": "cd ../../agent/adk/restaurant_finder && uv run .", - "demo:restaurant": "npm run build:renderer && concurrently -k -n \"AGENT,WEB\" -c \"magenta,blue\" \"npm run serve:agent:restaurant\" \"npm start -- restaurant\"" + "demo:restaurant": "npm run build:renderer && concurrently -k -n \"AGENT,WEB\" -c \"magenta,blue\" \"npm run serve:agent:restaurant\" \"npm start -- restaurant\"", + "build:sandbox": "esbuild projects/orchestrator/public/sandbox_iframe/sandbox.ts --bundle --outfile=projects/orchestrator/public/sandbox_iframe/sandbox.js --format=esm --platform=browser" }, "prettier": { "printWidth": 100, @@ -30,6 +31,7 @@ "private": true, "dependencies": { "@a2a-js/sdk": "^0.3.4", + "@modelcontextprotocol/ext-apps": "^1.2.0", "@a2ui/web_core": "file:../../../renderers/web_core", "@a2ui/markdown-it": "file:../../../renderers/markdown/markdown-it", "@angular/cdk": "^20.2.10", @@ -83,6 +85,7 @@ }, "workspaces": [ "projects/*", - "../../../renderers/web_core" + "../../../renderers/web_core", + "../../../renderers/markdown/markdown-it" ] } diff --git a/samples/client/angular/projects/orchestrator/public/sandbox_iframe/README.md b/samples/client/angular/projects/orchestrator/public/sandbox_iframe/README.md new file mode 100644 index 000000000..71a93370b --- /dev/null +++ b/samples/client/angular/projects/orchestrator/public/sandbox_iframe/README.md @@ -0,0 +1,27 @@ +# Sandbox Iframe + +This directory contains the `sandbox.html` and its associated resources. + +## Purpose + +`sandbox.html` is designed to be loaded into an ` + } + `, +}) +export class McpApp + extends DynamicComponent + implements OnDestroy, OnInit +{ + private readonly sanitizer = inject(DomSanitizer); + + readonly content = input.required(); + protected readonly resolvedContent = computed(() => { + let rawContent = super.resolvePrimitive(this.content() ?? null); + if (rawContent && rawContent.startsWith('url_encoded:')) { + rawContent = decodeURIComponent(rawContent.substring(12)); + } + return rawContent; + }); + + private readonly contentUpdate = effect(() => { + const rawContent = this.resolvedContent(); + const bridge = this.appBridge(); + if (bridge && rawContent) { + bridge.sendSandboxResourceReady({ + html: rawContent, + sandbox: 'allow-scripts', + }).catch(err => console.error('Failed to update sandbox content:', err)); + } + }); + + readonly allowedTools = input([]); + readonly title = input(); + protected readonly resolvedTitle = computed(() => + super.resolvePrimitive(this.title() ?? null), + ); + + protected readonly iframeSrc = signal( + this.sanitizer.bypassSecurityTrustResourceUrl('about:blank') + ); + + private iframe = viewChild.required>('iframe'); + private appBridge = signal(null); + private messageHandler: ((event: MessageEvent) => void) | null = null; + + ngOnInit() { + this.setupSandbox(); + } + + ngOnDestroy() { + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } + const bridge = this.appBridge(); + if (bridge) { + bridge.close().catch(e => console.error('Error closing AppBridge on destroy:', e)); + } + } + + private setupSandbox() { + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } + + this.messageHandler = (event: MessageEvent) => { + // Check if the message is from our iframe + if ( + this.iframe().nativeElement && + event.source === this.iframe().nativeElement.contentWindow && + event.data?.method === SANDBOX_PROXY_READY_METHOD + ) { + // Init bridge + this.initializeBridge(); + } + }; + + window.addEventListener('message', this.messageHandler); + + // Set src to trigger load AFTER listener is ready + // TODO: Make the sandbox URL configurable. To ensure CORS encapsulation, the sandbox + // should be served from a different origin than the orchestrator. + const sandboxUrl = 'sandbox_iframe/sandbox.html'; + this.iframeSrc.set( + this.sanitizer.bypassSecurityTrustResourceUrl(sandboxUrl), + ); + } + + private async initializeBridge() { + if (!this.iframe().nativeElement.contentWindow) { + return; + } + + const iframe = this.iframe().nativeElement; + + // The app bridge is initialized without a direct connection to MCP server. + // Communication with MCP server is expected to be handled by the sandbox iframe. + const emptyMcpClient = null; + const bridge = new AppBridge( + emptyMcpClient, + { name: 'A2UI Orchestrator', version: '1.0.0' }, + { + openLinks: {}, + logging: {}, + serverTools: {}, // Advertise support if we had a client + }, + ); + + bridge.onloggingmessage = (params) => { + console.log(`[MCP App Log] ${params.level}:`, params.data); + }; + + bridge.oninitialized = () => { + console.log('MCP App Initialized'); + }; + + bridge.onsizechange = ({ width, height }) => { + // TODO: Implement dynamic resizing + // Reference implementation in mcp-apps-custom-component.ts: + // - Listen for size changes from the embedded app + // - Update the iframe's width/height styles (with animation if desired) + // - This prevents scrollbars and ensures the app fits its content + // + // Example logic: + // if (height !== undefined) { + // this.iframe().nativeElement.style.height = `${height}px`; + // } + console.log(`[MCP App] Resize requested: ${width}x${height}`); + }; + + bridge.oncalltool = async (params) => { + // TODO: Implement tool execution security and dispatch + // Reference implementation in mcp-apps-custom-component.ts: + // 1. Check if params.name is in this.allowedTools() + // 2. If allowed, dispatch an event (e.g. 'a2ui.action') to the host + // 3. If not allowed, throw an error or warn + // + // Current implementation is read-only/logging only. + // + // Pseudo-code for dispatch: + // const actionName = params.name; + // if (this.allowedTools().includes(actionName)) { + // // Dispatch action to A2UI orchestrator/store + // // events.dispatch('a2ui.action', { name: actionName, ... }); + // return { content: [{ type: "text", text: "Action dispatched" }] }; + // } else { + // console.warn(`Tool '${actionName}' blocked.`); + // throw new Error("Tool not allowed"); + // } + console.log(`[MCP App] Tool call requested: ${params.name}`, params); + throw new Error('Tool execution not yet implemented'); + }; + + // Connect the bridge + // We must pass the iframe's contentWindow as the target + const transport = new PostMessageTransport( + // The first argument is the target window to send messages TO (the iframe). + // The second argument is the source window to validate messages FROM (also the iframe). + iframe.contentWindow!, + iframe.contentWindow!, + ); + await bridge.connect(transport); + + // Clean up old bridge to prevent memory leaks and zombie event listeners. + const oldBridge = this.appBridge(); + if (oldBridge) { + oldBridge.close().catch(e => console.error('Error closing previous AppBridge:', e)); + } + // Set the new bridge. + this.appBridge.set(bridge); + } +}