diff --git a/docs/migrate-to-3.md b/docs/migrate-to-3.md new file mode 100644 index 000000000..afa974554 --- /dev/null +++ b/docs/migrate-to-3.md @@ -0,0 +1,148 @@ +--- +title: Migrate to v3 +group: Getting Started +description: Upgrade from ext-apps 2.x to 3.x — tools a View exposes to the Host move to WebMCP, with before/after code for Views and the changes Hosts and Sandbox proxies must make. +--- + +# Migrating from ext-apps 2.x to 3.x + +## Tools exposed by the View (WebMCP) + +Tools a View exposes to the Host now use +[WebMCP](https://webmachinelearning.github.io/webmcp/) +(`document.modelContext`) instead of SDK-specific plumbing, so the same View +code works in any MCP Apps Host and in a WebMCP-capable browser +([#797](https://github.com/modelcontextprotocol/ext-apps/issues/797)). + +**This is a breaking change.** A View on the new SDK that calls +`app.registerTool()` exposes **no tools** on a Host that has not adopted the +changes below. The only signal is a one-time `console.warn` in the View. + +Deprecated, but still working: `app.registerTool()`, `app.oncalltool`, +`app.onlisttools` (each logs a one-time warning in the View), +`app.sendToolListChanged()`, the `tools` app capability and +`AppBridge.listTools()` / `callTool()`. + +`registerTool()` no longer declares the `tools` capability for you. Code that +assigns a raw `app.oncalltool` / `app.onlisttools` after `registerTool()`, +without declaring `tools` itself, now throws at the assignment: declare +`{ tools: {} }` in the `App` constructor, or move to WebMCP. + +### Views + +```ts +// Before +app.registerTool( + "set_zoom", + { + description: "Set the zoom level", + inputSchema: z.object({ level: z.number().min(1) }), + }, + async ({ level }) => { + setZoom(level); + return { + content: [{ type: "text", text: `Zoom ${level}` }], + structuredContent: { level }, + }; + }, +); + +// After +// Types: `npm i -D webmcp-types`, then add it to "types" in tsconfig.json +const SetZoom = z.object({ level: z.number().min(1) }); +const controller = new AbortController(); +await document.modelContext?.registerTool( + { + name: "set_zoom", + description: "Set the zoom level", // required + inputSchema: z.toJSONSchema(SetZoom), // plain JSON Schema + execute: async (input) => { + const { level } = SetZoom.parse(input); // input is not validated for you + setZoom(level); + return { level }; // plain data; throw to report an error + }, + }, + { signal: controller.signal }, +); +// Unregister with controller.abort() +``` + +`registerTool()` returns a Promise that rejects on a duplicate or invalid name +or a missing description. `document.modelContext` also offers `getTools()`, +`executeTool()` and a `toolchange` event. There is no `outputSchema`. + +**What the Host receives.** The value `execute()` returns arrives as +`{ content: [], structuredContent: value }`: a non-object `v` becomes +`{ result: v }` and nothing becomes `{ result: null }`. A throw becomes +`isError: true` with the message as text. The value is JSON, so images and +other binary content cannot be returned: the pdf-server `get_screenshot` tool, +which returns an image, is an example of what does not map. The deprecated +`app.registerTool()` keeps its `CallToolResult` callbacks: it forwards +`structuredContent` (or `{ content }`) and turns `isError: true` into a +throw. It exposes `readOnlyHint` only, and drops `_meta` and `outputSchema`. + +### Hosts + +1. **Polyfill.** `AppBridge.sendSandboxResourceReady()` injects a small + `document.modelContext` polyfill into the View HTML by default. Pass + `modelContextPolyfill: false` in `HostOptions` to opt out. A native + `document.modelContext` always wins; the polyfill does not replace it. +2. **Sandbox proxy.** The proxy is the only party same-origin with the View, + so it reads the View's tools. Adopt + `createModelContextRelay({ getViewDocument, postToHost })` from + `@modelcontextprotocol/ext-apps/app-bridge`: + + ```ts + const relay = createModelContextRelay({ + getViewDocument: () => inner.contentDocument, + postToHost: (message) => window.parent.postMessage(message, hostOrigin), + }); + + // Host -> View + if (relay.handleHostMessage(event.data)) return; // true: answered, don't forward + inner.contentWindow.postMessage(event.data, "*"); + // View -> Host (before forwarding) + relay.handleViewMessage(event.data); + ``` + + Use a **fresh inner iframe for every View**: a View can leave its own + `document.modelContext` behind in a document that is written again, where + it would be read as the next View's. + +3. **Host page.** Call `bridge.listWebMcpTools()` and + `bridge.callWebMcpTool()`. They travel as the reserved sandbox messages + `ui/sandbox-list-tools` and `ui/sandbox-call-tool`, which the relay answers; + the View never sees them. Tool changes arrive as + `ui/notifications/sandbox-tools-changed`, exposed as + `bridge.onwebmcptoolschange` (re-list the tools in the handler). + `bridge.listTools()` / `callTool()` still reach Views that declare `tools` + (older SDKs), so a Host that supports both can branch on the capability: + + ```ts + const listViewTools = (bridge: AppBridge) => + bridge.getAppCapabilities()?.tools + ? bridge.listTools({}) + : bridge.listWebMcpTools(); + ``` + +4. **Native WebMCP.** In a browser with native WebMCP, delegate the `tools` + Permissions Policy feature to the sandbox iframe (`allow="tools"`). Native + support tracks the current WebMCP spec; Chrome builds that still take + JSON-string input are accommodated. The message of an error a tool throws + is not visible through the native API. The SDK does not use native WebMCP + of the parent page itself. +5. **CSP.** The injected polyfill is an inline script, so the View's CSP must + allow inline scripts. MCP Apps single-file HTML already does. + +See `examples/basic-host/src/sandbox.ts` for a complete Sandbox proxy, and +`examples/debug-server` for a View that logs its WebMCP calls. + +## Checklist + +1. Views: move `app.registerTool` / `oncalltool` / `onlisttools` to + `document.modelContext.registerTool()`. +2. Hosts: keep the polyfill injection on, adopt `createModelContextRelay` in + the Sandbox proxy (with a fresh inner iframe per View), and read tools with + `bridge.listWebMcpTools()` / `callWebMcpTool()`. +3. Hosts in browsers with native WebMCP: add `allow="tools"` to the sandbox + iframe. diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md index c64cda75d..168517e1b 100644 --- a/examples/basic-host/README.md +++ b/examples/basic-host/README.md @@ -41,3 +41,12 @@ Host (port 8080) - Messages flow through the outer iframe which validates and relays them bidirectionally This architecture ensures that even if tool UI code is malicious, it cannot access the host application's DOM, cookies, or JavaScript context. + +## App tools (WebMCP) + +Views can register tools with [WebMCP](https://webmachinelearning.github.io/webmcp/) (`document.modelContext.registerTool()`). The host lists them in an "App tools (WebMCP)" panel, where they can be called with JSON input. + +- `AppBridge.sendSandboxResourceReady()` prepends a WebMCP polyfill to the View HTML by default (a native `document.modelContext` is left untouched), so the sandbox proxy injects nothing. For native WebMCP, `loadSandboxProxy` also delegates the `tools` permissions policy to the sandbox iframe (`allow="tools"`). +- The host reaches the tools with `appBridge.listWebMcpTools()` / `appBridge.callWebMcpTool()`, which send the reserved sandbox messages `ui/sandbox-list-tools` / `ui/sandbox-call-tool`. The panel refreshes (debounced) on `appBridge.onwebmcptoolschange` (`ui/notifications/sandbox-tools-changed`). +- Only the sandbox proxy is same-origin with the View, so it reads the tools: `createModelContextRelay()` (from `@modelcontextprotocol/ext-apps/app-bridge`) answers those reserved requests in `sandbox.ts` via `handleHostMessage()` (they are not forwarded to the View), and `handleViewMessage()` lets it start watching the View's tools once the View has initialized. +- `tools/list` / `tools/call` (`appBridge.listTools()` / `callTool()`, deprecated) still go straight to the View, which only answers them if it handles them itself. diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 5f680ffdc..0c36c49cf 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -170,11 +170,13 @@ export function loadSandboxProxy( 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); - } + // Set Permission Policy allow attribute based on requested permissions, plus + // `tools`: in a browser with native WebMCP, the View's `document.modelContext` + // only works in nested cross-origin frames if the policy is delegated. + const allowAttribute = [buildAllowAttribute(permissions), "tools"] + .filter(Boolean) + .join("; "); + iframe.setAttribute("allow", allowAttribute); const readyNotification: McpUiSandboxProxyReadyNotification["method"] = "ui/notifications/sandbox-proxy-ready"; @@ -247,6 +249,17 @@ export async function initializeApp( ); } +/** + * Lists the tools the View registered with WebMCP (`document.modelContext`). + * The sandbox proxy answers from the View's document, so the View does not need + * to declare anything. + */ +export async function listViewTools(appBridge: AppBridge): Promise { + const { tools } = await appBridge.listWebMcpTools({}); + log.info("View tools (WebMCP):", tools.map((tool) => tool.name)); + return tools; +} + /** * Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when * the MCP App is initialized (i.e., when the inner iframe is ready). @@ -270,6 +283,7 @@ export interface AppBridgeCallbacks { onContextUpdate?: (context: ModelContext | null) => void; onMessage?: (message: AppMessage) => void; onDisplayModeChange?: (mode: "inline" | "fullscreen") => void; + onAppToolsChanged?: () => void; } export interface AppBridgeOptions { @@ -348,6 +362,11 @@ export function newAppBridge( return {}; }; + appBridge.onwebmcptoolschange = () => { + log.info("View tools (WebMCP) changed"); + callbacks?.onAppToolsChanged?.(); + }; + appBridge.onloggingmessage = (params) => { log.info("Log message from MCP App:", params); }; diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 0bd09dd05..d894470c9 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -250,6 +250,47 @@ font-size: 0.75rem; } +.appTool { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.5rem; + cursor: default; +} + +.appToolName { + font-family: monospace; + font-weight: 600; +} + +.appToolCall { + display: flex; + gap: 0.5rem; + + textarea { + flex: 1; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background-color: var(--color-bg); + color: var(--color-text); + font-family: monospace; + font-size: 0.8rem; + } + + button { + border: none; + border-radius: 4px; + background-color: var(--color-primary); + color: white; + cursor: pointer; + + &:hover { + background-color: var(--color-primary-hover); + } + } +} + .collapsiblePreview { margin-top: 0.25rem; color: var(--color-text-secondary); diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 00c0d52b2..9db16642d 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -1,8 +1,8 @@ import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Tool } from "@modelcontextprotocol/client"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; 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, type ModelContext, type AppMessage } from "./implementation"; +import { callTool, connectToServer, hasAppHtml, initializeApp, listViewTools, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme"; import styles from "./index.module.css"; @@ -417,6 +417,54 @@ function CollapsiblePanel({ icon, label, content, badge, defaultExpanded = false } +/** + * Lists the tools the View registered (WebMCP `document.modelContext`, or the + * deprecated `App.registerTool`) and lets you call them with JSON input. + */ +interface AppToolsPanelProps { + tools: Tool[]; + onCall: (name: string, args: Record) => Promise; +} +function AppToolsPanel({ tools, onCall }: AppToolsPanelProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)}> + 🧰 App tools (WebMCP) + {tools.length} tool{tools.length > 1 ? "s" : ""} + {expanded ? "▼" : "▶"} +
+ {expanded && tools.map((tool) => )} +
+ ); +} + +function AppToolRow({ tool, onCall }: { tool: Tool; onCall: AppToolsPanelProps["onCall"] }) { + const [input, setInput] = useState(() => getToolDefaults(tool)); + const [result, setResult] = useState(""); + + const call = async () => { + try { + setResult(JSON.stringify(await onCall(tool.name, JSON.parse(input)), null, 2)); + } catch (error) { + setResult(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + return ( +
+
{tool.name} {tool.description}
+
+