Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions docs/migrate-to-3.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions examples/basic-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 24 additions & 5 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Tool[]> {
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).
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
};
Expand Down
41 changes: 41 additions & 0 deletions examples/basic-host/src/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
77 changes: 74 additions & 3 deletions examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string, unknown>) => Promise<CallToolResult>;
}
function AppToolsPanel({ tools, onCall }: AppToolsPanelProps) {
const [expanded, setExpanded] = useState(false);

return (
<div className={styles.collapsiblePanel}>
<div className={styles.collapsibleHeader} onClick={() => setExpanded(!expanded)}>
<span className={styles.collapsibleLabel}>🧰 App tools (WebMCP)</span>
<span className={styles.collapsibleSize}>{tools.length} tool{tools.length > 1 ? "s" : ""}</span>
<span className={styles.collapsibleToggle}>{expanded ? "▼" : "▶"}</span>
</div>
{expanded && tools.map((tool) => <AppToolRow key={tool.name} tool={tool} onCall={onCall} />)}
</div>
);
}

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 (
<div className={styles.appTool}>
<div><span className={styles.appToolName}>{tool.name}</span> {tool.description}</div>
<div className={styles.appToolCall}>
<textarea rows={2} value={input} onChange={(e) => setInput(e.target.value)} aria-label={`${tool.name} input JSON`} />
<button onClick={call}>Call</button>
</div>
{result && <pre className={styles.collapsibleFull}>{result}</pre>}
</div>
);
}


interface AppIFramePanelProps {
toolCallInfo: Required<ToolCallInfo>;
isDestroying?: boolean;
Expand All @@ -428,6 +476,22 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
const [modelContext, setModelContext] = useState<ModelContext | null>(null);
const [messages, setMessages] = useState<AppMessage[]>([]);
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
const [appTools, setAppTools] = useState<Tool[]>([]);

const refreshAppTools = () => {
const appBridge = appBridgeRef.current;
if (!appBridge) return;
listViewTools(appBridge).then(setAppTools, (err) => log.warn("Listing View tools failed:", err));
};

// A View registering several tools at once (e.g. 5 in a row) triggers one
// `webmcptoolschange` per tool: coalesce the burst into a single refresh.
const refreshTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const scheduleRefreshAppTools = () => {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(refreshAppTools, 100);
};
useEffect(() => () => clearTimeout(refreshTimerRef.current), []);

useEffect(() => {
const iframe = iframeRef.current!;
Expand All @@ -445,13 +509,14 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
onContextUpdate: setModelContext,
onMessage: (msg) => setMessages((prev) => [...prev, msg]),
onDisplayModeChange: setDisplayMode,
onAppToolsChanged: scheduleRefreshAppTools,
}, {
// Provide container dimensions - maxHeight for flexible sizing
containerDimensions: { maxHeight: 6000 },
displayMode: "inline",
});
appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo);
initializeApp(iframe, appBridge, toolCallInfo).then(refreshAppTools);
}
});
});
Expand Down Expand Up @@ -528,6 +593,12 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
{modelContext && (
<CollapsiblePanel icon="📋" label="Model Context" content={fullContext} />
)}
{appTools.length > 0 && (
<AppToolsPanel
tools={appTools}
onCall={(name, args) => appBridgeRef.current!.callWebMcpTool({ name, arguments: args })}
/>
)}
</div>
);
}
Expand Down
Loading
Loading