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