diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index ace31439..faa5bf90 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,4 +1,4 @@ -import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; @@ -122,12 +122,19 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { // Prevent reload if (iframe.src) return Promise.resolve(false); iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + // Set Permission Policy allow attribute based on requested permissions + const allowAttribute = buildAllowAttribute(permissions); + if (allowAttribute) { + iframe.setAttribute("allow", allowAttribute); + } + const readyNotification: McpUiSandboxProxyReadyNotification["method"] = "ui/notifications/sandbox-proxy-ready"; @@ -215,12 +222,26 @@ function hookInitializedCallback(appBridge: AppBridge): Promise { } -export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge { +export type ModelContext = McpUiUpdateModelContextRequest["params"]; +export type AppMessage = McpUiMessageRequest["params"]; + +export interface AppBridgeCallbacks { + onContextUpdate?: (context: ModelContext | null) => void; + onMessage?: (message: AppMessage) => void; +} + +export function newAppBridge( + serverInfo: ServerInfo, + iframe: HTMLIFrameElement, + callbacks?: AppBridgeCallbacks, +): AppBridge { const serverCapabilities = serverInfo.client.getServerCapabilities(); const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, { openLinks: {}, serverTools: serverCapabilities?.tools, serverResources: serverCapabilities?.resources, + // Declare support for model context updates + updateModelContext: { text: {} }, }); // Register all handlers before calling connect(). The Guest UI can start @@ -229,6 +250,7 @@ export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): appBridge.onmessage = async (params, _extra) => { log.info("Message from MCP App:", params); + callbacks?.onMessage?.(params); return {}; }; @@ -242,6 +264,15 @@ export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): log.info("Log message from MCP App:", params); }; + appBridge.onupdatemodelcontext = async (params) => { + log.info("Model context update from MCP App:", params); + // Normalize: empty content array means clear context + const hasContent = params.content && params.content.length > 0; + const hasStructured = params.structuredContent && Object.keys(params.structuredContent).length > 0; + callbacks?.onContextUpdate?.(hasContent || hasStructured ? params : null); + return {}; + }; + appBridge.onsizechange = async ({ width, height }) => { // The MCP App has requested a `width` and `height`, but if // `box-sizing: border-box` is applied to the outer iframe element, then we diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 0c4a8b0a..e319e35d 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -126,6 +126,46 @@ min-width: 0; } +.appOutputPanel { + flex: 1; + min-width: 0; +} + +.appHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + position: relative; + + .toolName { + font-family: monospace; + } + + .closeButton { + position: absolute; + top: 0; + right: 0; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: none; + border-radius: 4px; + background: #e0e0e0; + font-size: 1.25rem; + line-height: 1; + color: #666; + cursor: pointer; + + &:hover { + background: #d0d0d0; + color: #333; + } + } +} + .jsonBlock { flex-grow: 1; min-height: 0; @@ -148,6 +188,66 @@ } } +.collapsiblePanel { + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + border: 1px solid #e0e0e0; + border-radius: 4px; + background-color: #fafafa; + font-size: 0.875rem; + cursor: pointer; + transition: background-color 0.15s; + + &:hover { + background-color: #f0f0f0; + } +} + +.collapsibleHeader { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.collapsibleLabel { + font-weight: 600; + color: #555; +} + +.collapsibleSize { + color: #888; + font-size: 0.75rem; +} + +.collapsibleToggle { + margin-left: auto; + color: #888; + font-size: 0.75rem; +} + +.collapsiblePreview { + margin-top: 0.25rem; + color: #666; + font-family: monospace; + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.collapsibleFull { + margin: 0.5rem 0 0; + padding: 0.5rem; + border-radius: 4px; + background-color: #f5f5f5; + font-family: monospace; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow: auto; +} + .error { padding: 1.5rem; background-color: #ddd; diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 1e1313d3..d3331fe3 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -1,7 +1,7 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; -import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation"; +import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; import styles from "./index.module.css"; @@ -207,23 +207,41 @@ function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onClose className={styles.toolCallInfoPanel} style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined} > -
-

- {toolCallInfo.serverInfo.name} - {toolCallInfo.tool.name} - {onRequestClose && !isDestroying && ( - - )} -

- -
-
+ {/* For non-app tools, show input/output side by side */} + {!isApp && ( +
+

+ {toolCallInfo.serverInfo.name} + {toolCallInfo.tool.name} + {onRequestClose && !isDestroying && ( + + )} +

+ +
+ )} +
+ {/* For apps, show header above the app: ServerName:tool_name */} + {isApp && ( +
+ {toolCallInfo.serverInfo.name}:{toolCallInfo.tool.name} + {onRequestClose && !isDestroying && ( + + )} +
+ )} { @@ -252,6 +270,43 @@ function JsonBlock({ value }: { value: object }) { } +interface CollapsiblePanelProps { + icon: string; + label: string; + content: string; + badge?: string; + defaultExpanded?: boolean; +} +function CollapsiblePanel({ icon, label, content, badge, defaultExpanded = false }: CollapsiblePanelProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
setExpanded(!expanded)} + title={expanded ? "Click to collapse" : "Click to expand"} + > +
+ {icon} {label} + + {badge ?? `${content.length} chars`} + + + {expanded ? "▼" : "▶"} + +
+ {expanded ? ( +
{content}
+ ) : ( +
+ {content.slice(0, 100)}{content.length > 100 ? "…" : ""} +
+ )} +
+ ); +} + + interface AppIFramePanelProps { toolCallInfo: Required; isDestroying?: boolean; @@ -260,25 +315,34 @@ interface AppIFramePanelProps { function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppIFramePanelProps) { const iframeRef = useRef(null); const appBridgeRef = useRef | null>(null); + const [modelContext, setModelContext] = useState(null); + const [toolResult, setToolResult] = useState(null); + const [messages, setMessages] = useState([]); useEffect(() => { const iframe = iframeRef.current!; - // First get CSP from resource, then load sandbox with CSP in query param - // This ensures CSP is set via HTTP headers (tamper-proof) - toolCallInfo.appResourcePromise.then(({ csp }) => { - loadSandboxProxy(iframe, csp).then((firstTime) => { + // First get CSP and permissions from resource, then load sandbox + // CSP is set via HTTP headers (tamper-proof), permissions via iframe allow attribute + toolCallInfo.appResourcePromise.then(({ csp, permissions }) => { + loadSandboxProxy(iframe, csp, permissions).then((firstTime) => { // The `firstTime` check guards against React Strict Mode's double // invocation (mount → unmount → remount simulation in development). // Outside of Strict Mode, this `useEffect` runs only once per // `toolCallInfo`. if (firstTime) { - const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe); + const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe, { + onContextUpdate: setModelContext, + onMessage: (msg) => setMessages((prev) => [...prev, msg]), + }); appBridgeRef.current = appBridge; initializeApp(iframe, appBridge, toolCallInfo); } }); }); + + // Track tool result for display + toolCallInfo.resultPromise.then(setToolResult).catch(() => {}); }, [toolCallInfo]); // Graceful teardown: wait for guest to respond before unmounting @@ -303,9 +367,57 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI }); }, [isDestroying, onTeardownComplete]); + // Format content blocks - handle text, images, resources, etc. + const formatContentBlock = (c: { type: string; [key: string]: unknown }) => { + switch (c.type) { + case "text": + return (c as { type: "text"; text: string }).text; + case "image": + return ``; + case "audio": + return ``; + case "resource": + return ``; + default: + return `<${c.type}>`; + } + }; + + // Format context for display + const contextText = modelContext?.content?.map(formatContentBlock).join("\n") ?? ""; + const contextJson = modelContext?.structuredContent + ? JSON.stringify(modelContext.structuredContent, null, 2) + : ""; + const fullContext = [contextText, contextJson].filter(Boolean).join("\n\n"); + + const inputJson = JSON.stringify(toolCallInfo.input, null, 2); + const resultJson = toolResult ? JSON.stringify(toolResult, null, 2) : null; + + // Format messages + const formatMessage = (m: AppMessage) => { + const content = m.content.map(formatContentBlock).join("\n"); + return `[${m.role}] ${content}`; + }; + const messagesText = messages.map(formatMessage).join("\n\n"); + return (
+