diff --git a/samples/agent/adk/mcp_app_proxy/__main__.py b/samples/agent/adk/mcp_app_proxy/__main__.py index 709a1c59c..eea7e22b0 100644 --- a/samples/agent/adk/mcp_app_proxy/__main__.py +++ b/samples/agent/adk/mcp_app_proxy/__main__.py @@ -109,6 +109,7 @@ async def get_calculator_app(tool_context: ToolContext): "McpApp": { "content": {"literalString": encoded_html}, "title": {"literalString": "Calculator"}, + "allowedTools": ["calculate"], } }, }], @@ -125,7 +126,39 @@ async def get_calculator_app(tool_context: ToolContext): logger.error(f"Error fetching calculator app: {e} {traceback.format_exc()}") return {"error": f"Failed to connect to MCP server or fetch app. Details: {e}"} - tools = [get_calculator_app] + async def calculate_via_mcp(operation: str, a: float, b: float): + """Calculates via the MCP server's Calculate tool. + + Args: + operation: The mathematical operation (e.g. 'add', 'subtract', 'multiply', 'divide'). + a: First operand. + b: Second operand. + """ + mcp_server_host = os.getenv("MCP_SERVER_HOST", "localhost") + mcp_server_port = os.getenv("MCP_SERVER_PORT", "8000") + sse_url = f"http://{mcp_server_host}:{mcp_server_port}/sse" + + try: + async with sse_client(sse_url) as streams: + async with ClientSession(streams[0], streams[1]) as session: + await session.initialize() + + result = await session.call_tool( + "calculate", arguments={"operation": operation, "a": a, "b": b} + ) + + if ( + result.content + and len(result.content) > 0 + and hasattr(result.content[0], "text") + ): + return result.content[0].text + return "No result text from MCP calculate tool." + except Exception as e: + logger.error(f"Error calling MCP calculate: {e} {traceback.format_exc()}") + return f"Error connecting to MCP server: {e}" + + tools = [get_calculator_app, calculate_via_mcp] agent = McpAppProxyAgent( base_url=base_url, diff --git a/samples/agent/adk/mcp_app_proxy/agent.py b/samples/agent/adk/mcp_app_proxy/agent.py index 48e7ab31f..7ef04d594 100644 --- a/samples/agent/adk/mcp_app_proxy/agent.py +++ b/samples/agent/adk/mcp_app_proxy/agent.py @@ -32,11 +32,14 @@ When the user asks for the calculator, you MUST call the `get_calculator_app` tool. IMPORTANT: Do NOT attempt to construct the JSON manually. The tool `get_calculator_app` handles it automatically. + +When the user interacts with the calculator and issues a `calculate` action, you MUST call the `calculate_via_mcp` tool to perform the calculation via the remote MCP server. Return the resulting number directly as text to the user. """ WORKFLOW_DESCRIPTION = """ -1. **Analyze Request**: User asks for calculator. -2. **Fetch App**: Call `get_calculator_app`. +1. **Analyze Request**: + - If User asks for calculator: Call `get_calculator_app`. + - 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. """ UI_DESCRIPTION = """ diff --git a/samples/agent/adk/mcp_app_proxy/mcp_app_catalog.json b/samples/agent/adk/mcp_app_proxy/mcp_app_catalog.json index 3fcb1cea1..07aad50cc 100644 --- a/samples/agent/adk/mcp_app_proxy/mcp_app_catalog.json +++ b/samples/agent/adk/mcp_app_proxy/mcp_app_catalog.json @@ -30,6 +30,13 @@ "type": "string" } } + }, + "allowedTools": { + "type": "array", + "description": "List of tool names the app is allowed to call.", + "items": { + "type": "string" + } } }, "required": [ diff --git a/samples/agent/mcp/mcp-apps-calculcator/README.md b/samples/agent/mcp/mcp-apps-calculcator/README.md index 84ef85592..187dca36a 100644 --- a/samples/agent/mcp/mcp-apps-calculcator/README.md +++ b/samples/agent/mcp/mcp-apps-calculcator/README.md @@ -20,3 +20,12 @@ npx @modelcontextprotocol/inspector ``` Connect to http://localhost:8000/sse using Transport Type SSE and fetch the resources. + +## Available Resources + +- **`ui://calculator/app`**: A simple calculator application UI (`text/html;profile=mcp-app`). + +## Available Tools + +- **`calculate`**: Performs basic arithmetic calculations (`add`, `subtract`, `multiply`, `divide`). It takes `operation`, `a`, and `b` as arguments and returns the result. + - Used by the **"🤖 ="** button in the calculator app to delegate calculations to the MCP server via a `tools/call` request. diff --git a/samples/agent/mcp/mcp-apps-calculcator/apps/calculator.html b/samples/agent/mcp/mcp-apps-calculcator/apps/calculator.html index 02945ba13..18c885cc4 100644 --- a/samples/agent/mcp/mcp-apps-calculcator/apps/calculator.html +++ b/samples/agent/mcp/mcp-apps-calculcator/apps/calculator.html @@ -81,6 +81,14 @@ button.equals:hover { background-color: #2da84e; } + button.mcp-equals { + background-color: #5856d6; + color: white; + grid-column: span 2; + } + button.mcp-equals:hover { + background-color: #4342a1; + } button.clear { background-color: #ff3b30; color: white; @@ -110,7 +118,8 @@ - + + @@ -145,7 +154,7 @@ function appendOperator(op) { if (currentInput === '') return; if (previousInput !== '') { - calculate(); + calculateJavascript(); } operator = op; previousInput = currentInput; @@ -159,7 +168,7 @@ updateDisplay(); } - function calculate() { + function calculateJavascript() { if (previousInput === '' || currentInput === '') return; let result; const prevVal = parseFloat(previousInput); @@ -202,6 +211,41 @@ previousInput = ''; } + function calculateToolCall() { + if (previousInput === '' || currentInput === '') return; + const prevVal = parseFloat(previousInput); + const currentVal = parseFloat(currentInput); + if (isNaN(prevVal) || isNaN(currentVal)) return; + + let opName = ''; + switch (operator) { + case '+': opName = 'add'; break; + case '-': opName = 'subtract'; break; + case '*': opName = 'multiply'; break; + case '/': opName = 'divide'; break; + default: return; + } + + sendRequest('tools/call', { + name: 'calculate', + arguments: { + operation: opName, + a: prevVal, + b: currentVal + } + }).then(result => { + console.log("Tool call result:", result); + }).catch(err => { + console.error("Tool call failed:", err); + }); + + // Reset state locally as per user requirement (UI can forget about it) + currentInput = ''; + previousInput = ''; + operator = null; + updateDisplay(); + } + // MCP Communication let nextId = 1; diff --git a/samples/agent/mcp/mcp-apps-calculcator/server.py b/samples/agent/mcp/mcp-apps-calculcator/server.py index 0a951d489..1b18a957f 100644 --- a/samples/agent/mcp/mcp-apps-calculcator/server.py +++ b/samples/agent/mcp/mcp-apps-calculcator/server.py @@ -53,6 +53,79 @@ async def read_resource(uri: str) -> str | bytes: raise ValueError(f"Resource file not found for uri: {uri}") raise ValueError(f"Unknown resource: {uri}") + @app.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "calculate": + try: + operation = arguments.get("operation") + a = float(arguments.get("a")) + b = float(arguments.get("b")) + result = None + if operation == "add": + result = a + b + elif operation == "subtract": + result = a - b + elif operation == "multiply": + result = a * b + elif operation == "divide": + if b == 0: + raise ValueError("Division by zero") + result = a / b + else: + raise ValueError(f"Unknown operation: {operation}") + + symbols = {"add": "+", "subtract": "-", "multiply": "*", "divide": "/"} + symbol = symbols.get(operation, "?") + return { + "content": [ + { + "type": "text", + "text": f"The calculation of {a} {symbol} {b} is {result}" + } + ] + } + except Exception as e: + return { + "content": [ + { + "type": "text", + "text": f"Error: {str(e)}" + } + ], + "isError": True, + } + + raise ValueError(f"Unknown tool: {name}") + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="calculate", + title="Calculate", + description="Performs a basic arithmetic calculation.", + inputSchema={ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The math operation to perform." + }, + "a": { + "type": "number", + "description": "The first operand." + }, + "b": { + "type": "number", + "description": "The second operand." + } + }, + "required": ["operation", "a", "b"] + } + ), + ] + if transport == "sse": from mcp.server.sse import SseServerTransport from starlette.applications import Starlette diff --git a/samples/client/angular/projects/mcp_calculator/README.md b/samples/client/angular/projects/mcp_calculator/README.md index 25e0c7ea5..498843813 100644 --- a/samples/client/angular/projects/mcp_calculator/README.md +++ b/samples/client/angular/projects/mcp_calculator/README.md @@ -21,3 +21,18 @@ Sample application using the Chat-Canvas component with MCP Calculator Agent. - `npm start -- mcp_calculator` 6. Open http://localhost:4200/ + +## Usage + +Once the application is running, open `http://localhost:4200/` in your browser. You will see the MCP Calculator interface. + +### How to Use + +You can perform calculations using standard operations. When computing the expression, there are two different "equals" buttons: + +- **Local JavaScript Calculation (💻 =)**: Click this button to evaluate the expression immediately using standard JavaScript in the browser. The result will appear in the calculator display. +- **MCP Agent Tool Call Calculation (🤖 =)**: Click this button to dispatch a tool call to the MCP server. The local display will reset, and the calculation will be handled by the agent asynchronously (the result will appear in the chat conversation). + +## Features + +- **Client-side Tool Dispatching**: The application supports tool call requests from the MCP client bridge. It maps tool arguments to A2UI Action context and dispatches them to the host agent. diff --git a/samples/client/angular/projects/mcp_calculator/public/sandbox_iframe/sandbox.ts b/samples/client/angular/projects/mcp_calculator/public/sandbox_iframe/sandbox.ts index 58b73f59f..56002a0d8 100644 --- a/samples/client/angular/projects/mcp_calculator/public/sandbox_iframe/sandbox.ts +++ b/samples/client/angular/projects/mcp_calculator/public/sandbox_iframe/sandbox.ts @@ -47,6 +47,8 @@ const bridge = new AppBridge( // By default no features will be allowed for the sandbox iframe. const DEFAULT_SANDBOX_ALLOWED_FEATURES = ''; +let innerFrameWindow: Window | null = null; + bridge.oncalltool = async (params: any) => { // Forward tool calls to parent if needed, or handle locally // For now, we just log @@ -60,30 +62,64 @@ bridge.oncalltool = async (params: any) => { // Notify parent we are ready (standard) window.parent.postMessage({ method: SANDBOX_PROXY_READY_METHOD }, window.location.origin); -// Listen for resource ready message -window.addEventListener('message', async (event) => { - // Validate the origin of incoming messages - if (event.origin !== window.location.origin) { +/** + * Renders the provided HTML inside a nested, sandboxed iframe. + * + * This follows the **Double Iframe Isolation Pattern**: + * 1. The outer iframe (this script) is same-origin with the parent app, preventing + * security exceptions from browser extensions and DevTools. + * 2. This function creates a nested, heavily sandboxed inner iframe to render + * untrusted third-party content. + * 3. By default, no features are allowed for the inner iframe, isolating it from + * the rest of the application. + * + * @param html The raw HTML string to render inside the iframe's srcdoc. + * @param sandbox Allowed features for the sandbox attribute (e.g., 'allow-scripts'). + */ +function renderNestedIframe(html: string, sandbox?: string): void { + const content = document.getElementById('content'); + if (!content) { + console.error('[Sandbox] Content container not found'); return; } - const data = event.data; - if (data && data.method === SANDBOX_RESOURCE_READY_METHOD) { - const { html, sandbox } = data.params; - const content = document.getElementById('content'); - if (html && content) { - // Create an inner iframe with srcdoc to enable Javascript execution if any. - const innerFrame = document.createElement('iframe'); - innerFrame.srcdoc = html; - innerFrame.style.width = '100%'; - innerFrame.style.height = '100%'; - innerFrame.style.border = 'none'; - innerFrame.sandbox = sandbox || DEFAULT_SANDBOX_ALLOWED_FEATURES; - - // Clear any existing content and inject the new iframe - content.innerHTML = ''; - content.appendChild(innerFrame); + const innerFrame = document.createElement('iframe'); + innerFrame.srcdoc = html; + innerFrame.style.width = '100%'; + innerFrame.style.height = '100%'; + innerFrame.style.border = 'none'; + innerFrame.sandbox = sandbox || DEFAULT_SANDBOX_ALLOWED_FEATURES; + + content.innerHTML = ''; + content.appendChild(innerFrame); + innerFrameWindow = innerFrame.contentWindow; +} + +// Listen for resource ready message +window.addEventListener('message', async (event) => { + if (event.source === window.parent) { + // From Parent (Angular App) + if (event.origin !== window.location.origin) { + return; + } + + const data = event.data; + if (data && data.method === SANDBOX_RESOURCE_READY_METHOD) { + const { html, sandbox } = data.params; + if (html) { + renderNestedIframe(html, sandbox); + } + } else { + // TODO: Enable downward communication from parent to inner iframe + // so that the MCP Apps loaded within can receive messages from the host. + console.log('[Sandbox] Received message from parent:', data); } + } else if (innerFrameWindow && event.source === innerFrameWindow) { + // Allow messages to pass through to the parent only if they come from + // the inner iframe to prevent the parent from receiving messages from + // other iframes on the same page. - This is where the outer-layer iframe + // acts as the guard for the parent + window.parent.postMessage(event.data, window.location.origin); } }); diff --git a/samples/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts b/samples/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts index 311a50965..35b8becca 100644 --- a/samples/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts +++ b/samples/client/angular/projects/mcp_calculator/src/a2ui-catalog/mcp-app.ts @@ -195,26 +195,41 @@ export class McpApp }; 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 host store - // // events.dispatch('host.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'); + + if (!this.allowedTools().includes(params.name)) { + console.warn(`[MCP App] Tool '${params.name}' not allowed.`); + throw new Error(`Tool '${params.name}' not allowed`); + } + + const args = params.arguments || {}; + + // Map arguments to A2UI Action context + const context: any[] = []; + for (const [key, value] of Object.entries(args)) { + if (typeof value === 'number') { + context.push({ key, value: { literalNumber: value } }); + } else if (typeof value === 'string') { + context.push({ key, value: { literalString: value } }); + } else if (typeof value === 'boolean') { + context.push({ key, value: { literalBoolean: value } }); + } + } + + const action: Types.Action = { + name: params.name, + context: context.length > 0 ? context : undefined, + }; + + console.log('Sending action:', action); + + // Dispatch action asynchronously to the host/agent + super.sendAction(action).catch((err) => + console.error('Failed to send action:', err), + ); + + // Return empty result immediately (calculator UI can forget about it) + return { content: [] }; }; // Connect the bridge