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"; const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html"; const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" }; export const log = { info: console.log.bind(console, "[HOST]"), warn: console.warn.bind(console, "[HOST]"), error: console.error.bind(console, "[HOST]"), }; export interface ServerInfo { name: string; client: Client; tools: Map; appHtmlCache: Map; } export async function connectToServer(serverUrl: URL): Promise { const client = new Client(IMPLEMENTATION); log.info("Connecting to server:", serverUrl.href); await client.connect(new StreamableHTTPClientTransport(serverUrl)); log.info("Connection successful"); const name = client.getServerVersion()?.name ?? serverUrl.href; const toolsList = await client.listTools(); const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool])); log.info("Server tools:", Array.from(tools.keys())); return { name, client, tools, appHtmlCache: new Map() }; } interface UiResourceData { html: string; csp?: McpUiResourceCsp; permissions?: McpUiResourcePermissions; } export interface ToolCallInfo { serverInfo: ServerInfo; tool: Tool; input: Record; resultPromise: Promise; appResourcePromise?: Promise; } export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required { return !!toolCallInfo.appResourcePromise; } export function callTool( serverInfo: ServerInfo, name: string, input: Record, ): ToolCallInfo { log.info("Calling tool", name, "with input", input); const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise; const tool = serverInfo.tools.get(name); if (!tool) { throw new Error(`Unknown tool: ${name}`); } const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise }; const uiResourceUri = getToolUiResourceUri(tool); if (uiResourceUri) { toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri); } return toolCallInfo; } async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { log.info("Reading UI resource:", uri); const resource = await serverInfo.client.readResource({ uri }); if (!resource) { throw new Error(`Resource not found: ${uri}`); } if (resource.contents.length !== 1) { throw new Error(`Unexpected contents count: ${resource.contents.length}`); } const content = resource.contents[0]; // Per the MCP App specification, "text/html;profile=mcp-app" signals this // resource is indeed for an MCP App UI. if (content.mimeType !== RESOURCE_MIME_TYPE) { throw new Error(`Unsupported MIME type: ${content.mimeType}`); } const html = "blob" in content ? atob(content.blob) : content.text; // Extract CSP and permissions metadata from resource content._meta.ui (or content.meta for Python SDK) log.info("Resource content keys:", Object.keys(content)); log.info("Resource content._meta:", (content as any)._meta); // Try both _meta (spec) and meta (Python SDK quirk) const contentMeta = (content as any)._meta || (content as any).meta; const csp = contentMeta?.ui?.csp; const permissions = contentMeta?.ui?.permissions; return { html, csp, permissions }; } export function loadSandboxProxy( iframe: HTMLIFrameElement, csp?: McpUiResourceCsp, permissions?: McpUiResourcePermissions, ): 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"; const readyPromise = new Promise((resolve) => { const listener = ({ source, data }: MessageEvent) => { if (source === iframe.contentWindow && data?.method === readyNotification) { log.info("Sandbox proxy loaded") window.removeEventListener("message", listener); resolve(true); } }; window.addEventListener("message", listener); }); // Build sandbox URL with CSP query param for HTTP header-based CSP const sandboxUrl = new URL(SANDBOX_PROXY_BASE_URL); if (csp) { sandboxUrl.searchParams.set("csp", JSON.stringify(csp)); } log.info("Loading sandbox proxy...", csp ? `(CSP: ${JSON.stringify(csp)})` : ""); iframe.src = sandboxUrl.href; return readyPromise; } export async function initializeApp( iframe: HTMLIFrameElement, appBridge: AppBridge, { input, resultPromise, appResourcePromise }: Required, ): Promise { const appInitializedPromise = hookInitializedCallback(appBridge); // Connect app bridge (triggers MCP initialization handshake) // // IMPORTANT: Pass `iframe.contentWindow` as BOTH target and source to ensure // this proxy only responds to messages from its specific iframe. await appBridge.connect( new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), ); // Load inner iframe HTML with CSP and permissions metadata const { html, csp, permissions } = await appResourcePromise; log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : ""); await appBridge.sendSandboxResourceReady({ html, csp, permissions }); // Wait for inner iframe to be ready log.info("Waiting for MCP App to initialize..."); await appInitializedPromise; log.info("MCP App initialized"); // Send tool call input to iframe log.info("Sending tool call input to MCP App:", input); appBridge.sendToolInput({ arguments: input }); // Schedule tool call result (or cancellation) to be sent to MCP App resultPromise.then( (result) => { log.info("Sending tool call result to MCP App:", result); appBridge.sendToolResult(result); }, (error) => { log.error("Tool call failed, sending cancellation to MCP App:", error); appBridge.sendToolCancelled({ reason: error instanceof Error ? error.message : String(error), }); }, ); } /** * Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when * the MCP App is initialized (i.e., when the inner iframe is ready). */ function hookInitializedCallback(appBridge: AppBridge): Promise { const oninitialized = appBridge.oninitialized; return new Promise((resolve) => { appBridge.oninitialized = (...args) => { resolve(); appBridge.oninitialized = oninitialized; appBridge.oninitialized?.(...args); }; }); } 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 // sending requests immediately after the initialization handshake, so any // handlers registered after connect() might miss early requests. appBridge.onmessage = async (params, _extra) => { log.info("Message from MCP App:", params); callbacks?.onMessage?.(params); return {}; }; appBridge.onopenlink = async (params, _extra) => { log.info("Open link request:", params); window.open(params.url, "_blank", "noopener,noreferrer"); return {}; }; appBridge.onloggingmessage = (params) => { 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 // must add border thickness to `width` and `height` to compute the actual // necessary width and height (in order to prevent a resize feedback loop). const style = getComputedStyle(iframe); const isBorderBox = style.boxSizing === "border-box"; // Animate the change for a smooth transition. const from: Keyframe = {}; const to: Keyframe = {}; if (width !== undefined) { if (isBorderBox) { width += parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth); } // Use min-width instead of width to allow responsive growing. // With auto-resize (the default), the app reports its minimum content // width; we honor that as a floor but allow the iframe to expand when // the host layout allows. And we use `min(..., 100%)` so that the iframe // shrinks with its container. from.minWidth = `${iframe.offsetWidth}px`; iframe.style.minWidth = to.minWidth = `min(${width}px, 100%)`; } if (height !== undefined) { if (isBorderBox) { height += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth); } from.height = `${iframe.offsetHeight}px`; iframe.style.height = to.height = `${height}px`; } iframe.animate([from, to], { duration: 300, easing: "ease-out" }); }; return appBridge; }