Download examples/basic-host/src/implementation.ts from AbdulElahGwaith/ext-appss: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/AbdulElahGwaith/ext-appss/resolve/main/examples/basic-host/src/implementation.ts
- Command line
-
hf download hf://AbdulElahGwaith/ext-appss/examples/basic-host/src/implementation.ts
-
curl -L -o implementation.ts https://huggingface.co/AbdulElahGwaith/ext-appss/resolve/main/examples/basic-host/src/implementation.ts
10.9 kB
| 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<string, Tool>; | |
| appHtmlCache: Map<string, string>; | |
| } | |
| export async function connectToServer(serverUrl: URL): Promise<ServerInfo> { | |
| 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<string, unknown>; | |
| resultPromise: Promise<CallToolResult>; | |
| appResourcePromise?: Promise<UiResourceData>; | |
| } | |
| export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> { | |
| return !!toolCallInfo.appResourcePromise; | |
| } | |
| export function callTool( | |
| serverInfo: ServerInfo, | |
| name: string, | |
| input: Record<string, unknown>, | |
| ): ToolCallInfo { | |
| log.info("Calling tool", name, "with input", input); | |
| const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise<CallToolResult>; | |
| 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<UiResourceData> { | |
| 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<boolean> { | |
| // 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<boolean>((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<ToolCallInfo>, | |
| ): Promise<void> { | |
| 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<void> { | |
| const oninitialized = appBridge.oninitialized; | |
| return new Promise<void>((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; | |
| } | |