diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index f2523f25e..c436f9d51 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -2164,17 +2164,33 @@ Key considerations: - Audit trail for app tool invocations - User confirmation for tools with side effects -#### Relation to WebMCP +#### WebMCP Interoperability -This feature is inspired by [WebMCP](https://github.com/webmachinelearning/webmcp) (W3C incubation), which proposes allowing web pages to register JavaScript functions as tools via `navigator.modelContext.registerTool()`. +App-provided tools and [WebMCP](https://webmachinelearning.github.io/webmcp/) (W3C incubation, `document.modelContext.registerTool()`) are two surfaces for the same capability: a web page exposing JavaScript functions as tools. This specification treats the two surfaces as equivalent and interoperable. Neither deprecates the other: a View MAY expose tools through either surface, and Hosts and SDKs MUST NOT require one surface over the other. -Key differences: -- **WebMCP**: General web pages, browser API, manifest-based discovery -- **This spec**: MCP Apps, standard MCP messages, capability-based negotiation +**SDK mirroring (Apps → WebMCP).** The SDK SHOULD mirror tools registered with `registerTool()` into `document.modelContext` when the document provides one, so a WebMCP-enabled Host (one that consumes `document.modelContext`, natively or through its own machinery) discovers them without speaking the App `tools/list` channel. The `tools/list` / `tools/call` wire path is unaffected: mirroring is additive. For native registration to succeed inside a sandboxed iframe, the embedder MUST delegate the `tools` [Permissions Policy feature](https://webmachinelearning.github.io/webmcp/#permissions-policy) to the frame chain (e.g. `allow="tools"` on the iframe); where it is not delegated, registration fails and the tool remains available over `tools/list` only. -Similar to WebMCP but without turning the App (embedded page) into an MCP server - apps register tools within the App/Host architecture. +**SDK bridging (WebMCP → Apps).** The SDK SHOULD serve tools the page registered on `document.modelContext` over the App `tools/list` / `tools/call` channel, declaring the `tools` app capability on the View's behalf when the page carries WebMCP tools at connect time, and relaying `toolchange` as `notifications/tools/list_changed`, so WebMCP-authored Views work on Hosts that do not provide WebMCP. A View that must also run where neither the browser nor the Host provides `document.modelContext` can install the SDK's polyfill (`installWebMcpPolyfill()`) before registering tools; a Host MAY instead provide `document.modelContext` itself. -See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) for discussion. +**No double exposure.** +- The SDK MUST NOT echo a tool back onto the surface it came from: a tool mirrored into `document.modelContext` is not re-served from there over `tools/list` (tools registered with `registerTool()` shadow same-named WebMCP entries), and a bridged WebMCP tool is not re-mirrored into `document.modelContext`. +- A Host able to discover a View's tools both through WebMCP and through `tools/list` MUST consume exactly one surface per View — they expose the same tools. Consuming native WebMCP where present is RECOMMENDED. +- The SDK MUST NOT serve tools that the implementation attributes to another frame: native `getTools()` also returns tools of the page's other frames, which are not the View's to expose. (A descriptor that carries no frame attribution cannot be told apart and is served.) + +**WebMCP ↔ MCP mapping.** A WebMCP tool descriptor maps to an MCP `Tool` as follows; a descriptor with an invalid `name` or a non-string `description` is omitted from `tools/list`: + +| WebMCP descriptor | MCP `Tool` | +| ----------------- | ---------- | +| `name` (`[A-Za-z0-9_.-]{1,128}`) | `name`, unchanged | +| `title` | `title`, when a non-empty string | +| `description` | `description` (required string) | +| `inputSchema` | `inputSchema`, with `type: "object"` enforced; `{ "type": "object", "properties": {} }` when absent | +| `annotations.readOnlyHint` | `annotations.readOnlyHint`; other annotations are not carried | +| — | no `outputSchema` | + +A tool's return value `v` becomes `{ "content": [], "structuredContent": v }` when `v` is a plain object, and `{ "content": [], "structuredContent": { "result": v } }` otherwise; a thrown error becomes `{ "content": [{ "type": "text", "text": "" }], "isError": true }`. In the mirrored direction, a `CallToolResult` with `isError: true` surfaces to WebMCP callers as a thrown error carrying the result's text, and otherwise resolves with `structuredContent` when present, else `{ "content": [...] }`. + +See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35), [ext-apps#796](https://github.com/modelcontextprotocol/ext-apps/issues/796) and [ext-apps#797](https://github.com/modelcontextprotocol/ext-apps/issues/797) for discussion. ### Client\<\>Server Capability Negotiation diff --git a/src/app-webmcp-interop.test.ts b/src/app-webmcp-interop.test.ts new file mode 100644 index 000000000..c7d7e4915 --- /dev/null +++ b/src/app-webmcp-interop.test.ts @@ -0,0 +1,372 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + InMemoryTransport, + Server, + type JSONRPCMessage, +} from "@modelcontextprotocol/server"; +import { z } from "zod/v4"; + +import { App, installWebMcpPolyfill } from "./app.js"; +import { + LATEST_PROTOCOL_VERSION, + McpUiInitializeRequestSchema, + McpUiInitializeResultSchema, + McpUiInitializedNotificationSchema, + type McpUiAppCapabilities, +} from "./types.js"; + +/** + * A fake `document` carrying a WebMCP `modelContext` (the SDK's own + * polyfill), as a WebMCP-enabled environment would provide. + */ +function fakeDocument() { + const doc = { + defaultView: { fake: "window" }, + location: { origin: "null" }, + } as unknown as Document & { + modelContext: { + registerTool( + tool: object, + options?: { signal?: AbortSignal }, + ): Promise; + getTools(): Promise>>; + executeTool(tool: { name: string }, input?: object): Promise; + addEventListener(type: string, listener: () => void): void; + }; + }; + installWebMcpPolyfill(doc); + return doc; +} + +function setDocument(doc: unknown) { + (globalThis as { document?: unknown }).document = doc; +} + +afterEach(() => { + delete (globalThis as { document?: unknown }).document; +}); + +type ConnectedPair = { + app: App; + server: Server; + sentByApp: JSONRPCMessage[]; + initializeParams: () => McpUiAppCapabilities | undefined; +}; + +const connected: Array> = []; + +afterEach(async () => { + await Promise.all( + connected + .splice(0) + .flatMap(({ app, server }) => [app.close(), server.close()]), + ); +}); + +async function connectPair( + app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false }, + ), +): Promise { + const server = new Server( + { name: "inner-mcp-server", version: "0.0.0" }, + { capabilities: { tools: {} } }, + ); + const [appTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const sentByApp: JSONRPCMessage[] = []; + const send = appTransport.send.bind(appTransport); + appTransport.send = async (message, options) => { + sentByApp.push(message); + await send(message, options); + }; + + let appCapabilities: McpUiAppCapabilities | undefined; + let resolveInitialized!: () => void; + const initialized = new Promise((resolve) => { + resolveInitialized = resolve; + }); + server.setRequestHandler( + "ui/initialize", + { + params: McpUiInitializeRequestSchema.shape.params, + result: McpUiInitializeResultSchema, + }, + (params) => { + appCapabilities = params.appCapabilities; + return { + protocolVersion: LATEST_PROTOCOL_VERSION, + hostCapabilities: {}, + hostInfo: { name: "apps-host", version: "2.0.0" }, + hostContext: {}, + }; + }, + ); + server.setNotificationHandler( + "ui/notifications/initialized", + { params: McpUiInitializedNotificationSchema.shape.params }, + () => resolveInitialized(), + ); + + await server.connect(serverTransport); + await app.connect(appTransport); + await initialized; + + const pair = { + app, + server, + sentByApp, + initializeParams: () => appCapabilities, + }; + connected.push(pair); + return pair; +} + +describe("WebMCP interop: registerTool mirrored into document.modelContext", () => { + it("registers, serves, and unregisters the mirror alongside the wire path", async () => { + const doc = fakeDocument(); + setDocument(doc); + + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false }, + ); + const tool = app.registerTool( + "greet", + { + description: "Greets a person", + inputSchema: z.object({ who: z.string() }), + }, + async ({ who }) => ({ + content: [{ type: "text", text: `hello ${who}` }], + }), + ); + // Mirroring is async; let it settle. + await Bun.sleep(0); + + const tools = await doc.modelContext.getTools(); + expect(tools.map((t) => t.name)).toEqual(["greet"]); + expect(tools[0].inputSchema).toMatchObject({ type: "object" }); + + // A content-only result comes back as { content } (no structuredContent). + const result = JSON.parse( + await doc.modelContext.executeTool({ name: "greet" }, { who: "ada" }), + ); + expect(result).toEqual({ + content: [{ type: "text", text: "hello ada" }], + }); + + tool.disable(); + await Bun.sleep(0); + expect(await doc.modelContext.getTools()).toEqual([]); + + tool.enable(); + await Bun.sleep(0); + expect((await doc.modelContext.getTools()).map((t) => t.name)).toEqual([ + "greet", + ]); + }); + + it("surfaces isError results as thrown errors, per WebMCP", async () => { + const doc = fakeDocument(); + setDocument(doc); + + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false }, + ); + app.registerTool("fail", { description: "Always fails" }, async () => ({ + content: [{ type: "text", text: "boom" }], + isError: true, + })); + await Bun.sleep(0); + + await expect( + doc.modelContext.executeTool({ name: "fail" }), + ).rejects.toThrow("boom"); + }); + + it("does not mirror when mirrorToolsToWebMcp is false", async () => { + const doc = fakeDocument(); + setDocument(doc); + + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false, mirrorToolsToWebMcp: false }, + ); + app.registerTool("hidden", { description: "Not mirrored" }, async () => ({ + content: [], + })); + await Bun.sleep(0); + + expect(await doc.modelContext.getTools()).toEqual([]); + }); +}); + +describe("WebMCP interop: page-registered tools served over tools/list and tools/call", () => { + it("declares the tools capability and serves WebMCP tools to an Apps host", async () => { + const doc = fakeDocument(); + setDocument(doc); + await doc.modelContext.registerTool({ + name: "page-tool", + description: "Registered via WebMCP", + inputSchema: { type: "object", properties: { n: { type: "number" } } }, + execute: async (input: { n?: number }) => ({ doubled: (input.n ?? 0) * 2 }), + }); + + const pair = await connectPair(); + + expect(pair.initializeParams()?.tools).toEqual({ listChanged: true }); + + const listed = await pair.server.request({ + method: "tools/list", + params: {}, + }); + expect(listed.tools.map((tool: { name: string }) => tool.name)).toEqual([ + "page-tool", + ]); + expect(listed.tools[0].inputSchema).toMatchObject({ type: "object" }); + + const result = await pair.server.request({ + method: "tools/call", + params: { name: "page-tool", arguments: { n: 21 } }, + }); + expect(result).toEqual({ + content: [], + structuredContent: { doubled: 42 }, + }); + }); + + it("wraps non-object results and converts thrown errors to isError", async () => { + const doc = fakeDocument(); + setDocument(doc); + await doc.modelContext.registerTool({ + name: "scalar", + description: "Returns a number", + execute: async () => 7, + }); + await doc.modelContext.registerTool({ + name: "thrower", + description: "Throws", + execute: async () => { + throw new Error("nope"); + }, + }); + + const pair = await connectPair(); + + expect( + await pair.server.request({ + method: "tools/call", + params: { name: "scalar", arguments: {} }, + }), + ).toEqual({ content: [], structuredContent: { result: 7 } }); + + expect( + await pair.server.request({ + method: "tools/call", + params: { name: "thrower", arguments: {} }, + }), + ).toEqual({ + content: [{ type: "text", text: "nope" }], + isError: true, + }); + }); + + it("never lists a tool twice: registered tools shadow their own mirrors", async () => { + const doc = fakeDocument(); + setDocument(doc); + await doc.modelContext.registerTool({ + name: "page-only", + description: "From the page", + execute: async () => "ok", + }); + + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false }, + ); + // Mirrored into the same modelContext AND served over tools/list; it must + // appear exactly once, with the registered (wire) shape. + app.registerTool( + "registered", + { description: "From registerTool", _meta: { marker: true } }, + async () => ({ content: [] }), + ); + await Bun.sleep(0); + expect((await doc.modelContext.getTools()).map((t) => t.name)).toEqual([ + "page-only", + "registered", + ]); + + const pair = await connectPair(app); + const listed = await pair.server.request({ + method: "tools/list", + params: {}, + }); + const names = listed.tools.map((tool: { name: string }) => tool.name); + expect([...names].sort()).toEqual(["page-only", "registered"]); + expect( + listed.tools.find((tool: { name: string }) => tool.name === "registered") + ?._meta, + ).toEqual({ marker: true }); + }); + + it("relays toolchange as notifications/tools/list_changed", async () => { + const doc = fakeDocument(); + setDocument(doc); + await doc.modelContext.registerTool({ + name: "first", + description: "Present from the start", + execute: async () => "ok", + }); + + const pair = await connectPair(); + + await doc.modelContext.registerTool({ + name: "second", + description: "Registered after connect", + execute: async () => "ok", + }); + await Bun.sleep(0); + + const listChanged = pair.sentByApp.filter( + (message) => + "method" in message && + message.method === "notifications/tools/list_changed", + ); + expect(listChanged.length).toBeGreaterThanOrEqual(1); + + const listed = await pair.server.request({ + method: "tools/list", + params: {}, + }); + expect( + listed.tools.map((tool: { name: string }) => tool.name).sort(), + ).toEqual(["first", "second"]); + }); + + it("does not bridge when bridgeWebMcpTools is false", async () => { + const doc = fakeDocument(); + setDocument(doc); + await doc.modelContext.registerTool({ + name: "page-tool", + description: "Registered via WebMCP", + execute: async () => "ok", + }); + + const pair = await connectPair( + new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false, bridgeWebMcpTools: false }, + ), + ); + expect(pair.initializeParams()?.tools).toBeUndefined(); + }); +}); diff --git a/src/app.ts b/src/app.ts index 89c6c35e7..820231e13 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,8 +38,22 @@ export type { LegacyRequestHandlerSetter, } from "./legacy-handlers.js"; export { EventDispatcher } from "./events.js"; +export { installWebMcpPolyfill } from "./webmcp-polyfill.js"; import { PostMessageTransport } from "./message-transport.js"; +import { + callToolResultText, + getWebMcpConsumer, + getWebMcpModelContext, + isPlainObject, + ownWebMcpTools, + parseWebMcpResult, + toMcpTool, + webMcpErrorMessage, + webMcpExecuteInput, + type WebMcpConsumer, + type WebMcpModelContext, +} from "./webmcp.js"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, @@ -164,6 +178,27 @@ export type AppOptions = Omit & { * @default false */ allowUnsafeEval?: boolean; + /** + * Mirror tools registered with {@link App.registerTool `registerTool`} into + * `document.modelContext` ([WebMCP](https://webmachinelearning.github.io/webmcp/)) + * when the document provides one, so WebMCP-enabled hosts discover them + * without speaking the Apps `tools/list` channel. The `tools/list` / + * `tools/call` wire path is unaffected either way. + * + * @default true + */ + mirrorToolsToWebMcp?: boolean; + /** + * Serve tools the page registered on `document.modelContext` (WebMCP) over + * the Apps `tools/list` / `tools/call` channel, so WebMCP-authored Views + * work on MCP Apps hosts that do not provide WebMCP themselves. Tools + * registered with {@link App.registerTool `registerTool`} win on name + * collisions, and tools the {@link App `App`} itself mirrored into WebMCP + * are never echoed back. + * + * @default true + */ + bridgeWebMcpTools?: boolean; }; type RequestHandlerExtra = BaseContext; @@ -307,6 +342,9 @@ export class App extends Protocol { private _hostInfo?: Implementation; private _hostContext?: McpUiHostContext; private _registeredTools: { [name: string]: RegisteredAppTool } = {}; + private readonly _webMcpMirrors = new Map(); + private _webMcpCallCount = 0; + private _webMcpWatched?: { consumer: WebMcpConsumer; listener: () => void }; private _initializedSent = false; private readonly _registeredEvents = new Set(); private readonly _events = new EventDispatcher(); @@ -605,6 +643,7 @@ export class App extends Protocol { if (app._initializedSent && app._appCapabilities.tools?.listChanged) { void app.sendToolListChanged(); } + app.syncWebMcpMirror(name); }; // Arity is fixed at registration time even if inputSchema is later // update()d, since cb's signature can't change. @@ -692,10 +731,17 @@ export class App extends Protocol { this.oncalltool = async (params, extra) => { const tool = this._registeredTools[params.name]; - if (!tool) { - throw new Error(`Tool ${params.name} not found`); + if (tool) { + return tool.handler(params.arguments, extra); } - return tool.handler(params.arguments, extra); + const bridged = await this.callWebMcpBridgedTool( + params, + extra.mcpReq.signal, + ); + if (bridged) { + return bridged; + } + throw new Error(`Tool ${params.name} not found`); }; this.onlisttools = async (_params, _extra) => { const tools: Tool[] = await Promise.all( @@ -730,10 +776,225 @@ export class App extends Protocol { return result; }), ); + tools.push(...(await this.listWebMcpBridgedTools())); return { tools }; }; } + /** + * The page's own WebMCP (`document.modelContext`) tools as MCP `Tool`s, + * ready to serve over `tools/list`. Tools registered with + * {@link App.registerTool `registerTool`} shadow same-named entries — which + * also keeps the mirrors this App itself registered into WebMCP from being + * echoed back. + */ + private async listWebMcpBridgedTools(): Promise { + if (this.options?.bridgeWebMcpTools === false) return []; + const consumer = getWebMcpConsumer(); + if (!consumer) return []; + try { + return (await ownWebMcpTools(consumer)).flatMap((descriptor) => { + const tool = toMcpTool(descriptor); + return tool && !this._registeredTools[tool.name] + ? [tool as unknown as Tool] + : []; + }); + } catch { + return []; // A broken model context must not take down tools/list. + } + } + + /** + * Execute a `tools/call` against the page's own WebMCP tools, or + * `undefined` when the tool is not served that way (so the caller reports + * "not found"). A failing execution is a `CallToolResult` with + * `isError: true`, not a thrown error. + */ + private async callWebMcpBridgedTool( + params: CallToolRequest["params"], + signal?: AbortSignal, + ): Promise { + if (this.options?.bridgeWebMcpTools === false) return undefined; + if (this._registeredTools[params.name]) return undefined; + const consumer = getWebMcpConsumer(); + if (!consumer) return undefined; + + let frameTools, found; + try { + frameTools = await ownWebMcpTools(consumer); + // Names map through toMcpTool unchanged, so match on the raw name and + // convert only the match (validating it serves as a listed tool). + found = frameTools.find((descriptor) => { + try { + return descriptor.name === params.name; + } catch { + return false; + } + }); + if (!found || !toMcpTool(found)) return undefined; + } catch { + return undefined; + } + + try { + const value = parseWebMcpResult( + await consumer.executeTool( + found, + webMcpExecuteInput(frameTools, params.arguments ?? {}), + { signal }, + ), + ); + return { + content: [], + structuredContent: (isPlainObject(value) + ? value + : { result: value }) as CallToolResult["structuredContent"], + }; + } catch (error) { + return { + content: [{ type: "text", text: webMcpErrorMessage(error) }], + isError: true, + }; + } + } + + /** + * Bring the `document.modelContext` (WebMCP) mirror of the tool registered + * under `name` in line with its current state: (re-)registered while + * present and enabled, unregistered otherwise. A no-op when the document + * provides no model context — the tool is still served over `tools/list` — + * or when `mirrorToolsToWebMcp` is `false`. + */ + private syncWebMcpMirror(name: string): void { + this._webMcpMirrors.get(name)?.abort(); + this._webMcpMirrors.delete(name); + + if (this.options?.mirrorToolsToWebMcp === false) return; + const tool = this._registeredTools[name]; + if (!tool?.enabled) return; + const modelContext = getWebMcpModelContext(); + if (!modelContext) return; + + const controller = new AbortController(); + this._webMcpMirrors.set(name, controller); + void this.mirrorToWebMcp(name, tool, modelContext, controller.signal); + } + + private async mirrorToWebMcp( + name: string, + tool: RegisteredAppTool, + modelContext: WebMcpModelContext, + signal: AbortSignal, + ): Promise { + try { + const inputSchema = tool.inputSchema + ? await standardSchemaToJsonSchema(tool.inputSchema, "input") + : undefined; + if (signal.aborted) return; + await modelContext.registerTool( + { + name, + title: tool.title, + description: tool.description || tool.title || name, + inputSchema, + annotations: + tool.annotations?.readOnlyHint === undefined + ? undefined + : { readOnlyHint: tool.annotations.readOnlyHint }, + execute: async (input, options) => { + const result = await tool.handler( + input, + this.buildWebMcpContext(options.signal), + ); + if (result.isError) throw new Error(callToolResultText(result)); + return result.structuredContent ?? { content: result.content }; + }, + }, + { signal }, + ); + } catch (error) { + // A failed registration (invalid name, unserializable schema, + // duplicate, permissions policy…) means the tool is not visible through + // WebMCP; it is still served over tools/list. + if (!signal.aborted) { + console.warn( + `[MCP Apps] Tool "${name}" could not be mirrored into document.modelContext:`, + error, + ); + } + } + } + + /** + * Handler context for a tool call that arrived through WebMCP rather than a + * `tools/call` request: same shape as the wire path, with a synthetic + * request id and `send`/`notify` going out over this App's own transport. + */ + private buildWebMcpContext(signal: AbortSignal): RequestHandlerExtra { + return { + mcpReq: { + id: `webmcp-${++this._webMcpCallCount}`, + method: "tools/call", + signal, + requestState: () => undefined, + send: ((...args: [never, ...never[]]) => + (this.request as (...a: unknown[]) => unknown)( + ...args, + )) as RequestHandlerExtra["mcpReq"]["send"], + notify: (notification) => this.notification(notification), + }, + }; + } + + /** + * Relay WebMCP `toolchange` events as `notifications/tools/list_changed`, + * so a host learns when the page's own WebMCP tools come and go. + */ + private watchWebMcpToolChanges(): void { + if (this.options?.bridgeWebMcpTools === false) return; + const consumer = getWebMcpConsumer(); + if (!consumer || consumer === this._webMcpWatched?.consumer) return; + const listener = () => { + if ( + this.transport && + this._initializedSent && + this._appCapabilities.tools?.listChanged + ) { + void this.sendToolListChanged(); + } + }; + this.unwatchWebMcpToolChanges(); + try { + consumer.addEventListener("toolchange", listener); + this._webMcpWatched = { consumer, listener }; + } catch { + this._webMcpWatched = undefined; + } + } + + private unwatchWebMcpToolChanges(): void { + try { + this._webMcpWatched?.consumer.removeEventListener( + "toolchange", + this._webMcpWatched.listener, + ); + } catch { + // The model context is the page's to break; nothing to clean up then. + } + this._webMcpWatched = undefined; + } + + protected override _onclose(): void { + // Unwatch before aborting the mirrors: unregistering them fires + // `toolchange`, which must not try to notify over the closed transport. + this.unwatchWebMcpToolChanges(); + for (const controller of this._webMcpMirrors.values()) { + controller.abort(); + } + this._webMcpMirrors.clear(); + super._onclose(); + } + async sendToolListChanged( params: ToolListChangedNotification["params"] = {}, ): Promise { @@ -2012,6 +2273,29 @@ export class App extends Protocol { } this._initializedSent = false; + // WebMCP interop: when the page carries WebMCP tools in a readable model + // context, they are served over tools/list / tools/call, which requires + // declaring the `tools` capability before the handshake. Views that + // answer tools/* themselves (oncalltool/onlisttools assigned directly) + // are left alone. + if ( + this.options?.bridgeWebMcpTools !== false && + (this._toolHandlersInitialized || !(this.oncalltool || this.onlisttools)) + ) { + const consumer = getWebMcpConsumer(); + const pageTools = consumer + ? await ownWebMcpTools(consumer).catch( + () => [] as Awaited>, + ) + : []; + if (pageTools.some((descriptor) => toMcpTool(descriptor))) { + if (!this._appCapabilities.tools) { + this.registerAppCapabilities({ tools: { listChanged: true } }); + } + this.ensureToolHandlersInitialized(); + } + } + try { // Protocol.connect only wires the transport — no MCP handshake. The // ui/initialize request below is the sole negotiation on this channel, @@ -2043,6 +2327,13 @@ export class App extends Protocol { method: "ui/notifications/initialized", }); this._initializedSent = true; + this.watchWebMcpToolChanges(); + // Mirrors are independent of the transport but are torn down on close: + // re-establish them on (re)connect, which also picks up a model context + // that appeared after registration. + for (const name of Object.keys(this._registeredTools)) { + this.syncWebMcpMirror(name); + } if (this.options?.autoResize) { this.setupSizeChangedNotifications(); diff --git a/src/webmcp-polyfill.ts b/src/webmcp-polyfill.ts new file mode 100644 index 000000000..c00ccfd13 --- /dev/null +++ b/src/webmcp-polyfill.ts @@ -0,0 +1,157 @@ +/** + * A minimal, realm-local WebMCP polyfill: just enough of the + * `document.modelContext` API (https://webmachinelearning.github.io/webmcp/) + * for tools registered by this page to be read back and executed. No + * cross-frame support: `exposedTo` is accepted and ignored. + * + * A WebMCP-authored View calls this before registering tools, so it also runs + * where neither the browser nor the Host provides `document.modelContext`; + * the {@link App `App`} then serves the registered tools to MCP Apps hosts + * over `tools/list` / `tools/call`. A native `document.modelContext` (or a + * polyfill the Host injected) wins: installing over one is a no-op, except + * that this polyfill replaces an earlier instance of itself, since + * `document.open()` keeps expando properties of a document that is written + * again. + */ + +import { WEBMCP_TOOL_NAME } from "./webmcp.js"; + +const OWN = Symbol.for("@modelcontextprotocol/ext-apps.modelContextPolyfill"); + +const NAME = WEBMCP_TOOL_NAME; + +interface PolyfillTool { + name: string; + title?: string; + description: string; + inputSchema?: Record; + annotations?: { readOnlyHint?: boolean }; + execute( + input: Record, + options: { signal: AbortSignal }, + ): Promise; +} + +/** + * Install the WebMCP polyfill on `doc` (the current document by default). + * A no-op where `document.modelContext` already exists, unless that is an + * earlier instance of this polyfill. + */ +export function installWebMcpPolyfill( + doc: Document = (globalThis as unknown as { document: Document }).document, +): void { + const existing = (doc as { modelContext?: unknown }).modelContext; + if ( + "modelContext" in doc && + !(existing && (existing as Record)[OWN]) + ) { + return; + } + + const invalid = (message: string) => + typeof DOMException === "function" + ? new DOMException(message, "InvalidStateError") + : new Error(message); + // name -> { json snapshot of the descriptor (getTools returns deep copies), execute } + const registry = new Map< + string, + { json: string; execute: PolyfillTool["execute"] } + >(); + let ontoolchange: ((this: unknown, event: Event) => void) | null = null; + const fireToolChange = (target: EventTarget) => { + const event = new Event("toolchange"); + target.dispatchEvent(event); + if (ontoolchange) { + try { + ontoolchange.call(target, event); + } catch (error) { + setTimeout(() => { + throw error; + }); + } + } + }; + + class ModelContext extends EventTarget { + get ontoolchange() { + return ontoolchange; + } + set ontoolchange(handler) { + ontoolchange = typeof handler === "function" ? handler : null; + } + + async registerTool( + tool: PolyfillTool, + options: { signal?: AbortSignal } = {}, + ): Promise { + const { signal } = options; + if (signal?.aborted) throw signal.reason; + // Read each member once: a getter can answer differently next time. + const { name, title, description, inputSchema, annotations, execute } = + tool; + if (typeof name !== "string" || !NAME.test(name)) { + throw invalid("Invalid tool name: " + name); + } + if (!description) throw invalid("Tool description must not be empty"); + if (typeof execute !== "function") { + throw new TypeError("Tool execute must be a function"); + } + if (registry.has(name)) throw invalid("Duplicate tool name: " + name); + // Throws if inputSchema is not JSON-serializable. + const json = JSON.stringify({ + name, + title, + description, + inputSchema, + annotations, + }); + const entry = { json, execute }; + registry.set(name, entry); + if (signal) { + signal.addEventListener("abort", () => { + if (registry.get(name) === entry) { + registry.delete(name); + fireToolChange(this); + } + }); + } + fireToolChange(this); + } + + async getTools(): Promise>> { + return [...registry.values()] + .map(({ json }) => ({ + ...JSON.parse(json), + window: doc.defaultView, + origin: doc.location && doc.location.origin, + })) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + } + + async executeTool( + tool: { name: string }, + input: object = {}, + options: { signal?: AbortSignal } = {}, + ): Promise { + const entry = registry.get(tool.name); + if (!entry) throw invalid("Unknown tool: " + tool.name); + const signal = options.signal || new AbortController().signal; + if (signal.aborted) throw signal.reason; + // Unregistering mid-flight does not cancel a running execution. + // Like native: the tool gets a copy made in the page's own realm. + const result = await entry.execute(JSON.parse(JSON.stringify(input)), { + signal, + }); + const json = JSON.stringify(result); + return json === undefined ? "null" : json; + } + } + + const modelContext = new ModelContext(); + Object.defineProperty(modelContext, OWN, { value: true }); + Object.defineProperty(doc, "modelContext", { + value: modelContext, + configurable: true, + enumerable: true, + }); +} diff --git a/src/webmcp.ts b/src/webmcp.ts new file mode 100644 index 000000000..afb87c468 --- /dev/null +++ b/src/webmcp.ts @@ -0,0 +1,288 @@ +/** + * WebMCP (https://webmachinelearning.github.io/webmcp/) interop helpers. + * + * MCP Apps and WebMCP are two surfaces for the same capability: a web page + * exposing JavaScript functions as tools. The {@link App `App`} bridges them + * in both directions: + * + * - tools registered with `App.registerTool()` are mirrored into + * `document.modelContext` when the document provides one, so WebMCP-enabled + * hosts discover them without speaking the Apps `tools/list` channel; + * - tools the page registers on `document.modelContext` are served over the + * Apps `tools/list` / `tools/call` channel, so WebMCP-authored Views work on + * MCP Apps hosts. + * + * The types here are a structural subset of the WebMCP API, declared locally + * (rather than depending on `webmcp-types`) so the SDK adds neither a + * dependency nor ambient DOM type augmentations for consumers. + */ + +/** + * The subset of a WebMCP tool descriptor the SDK registers. + * + * @internal + */ +export interface WebMcpTool { + name: string; + title?: string; + description: string; + inputSchema?: Record; + annotations?: { readOnlyHint?: boolean }; + execute( + input: Record, + options: { signal: AbortSignal }, + ): Promise; +} + +/** + * The producer side of WebMCP's `ModelContext`: what the SDK needs to mirror + * tools into it. + * + * @internal + */ +export interface WebMcpModelContext { + /** + * Resolves once the tool is registered; rejects if it cannot be (duplicate + * or invalid name, insecure context, permissions policy, …). Aborting + * `options.signal` unregisters the tool. + */ + registerTool( + tool: WebMcpTool, + options?: { signal?: AbortSignal }, + ): Promise; +} + +/** The subset of a WebMCP `RegisteredTool` descriptor the SDK reads back. */ +export interface WebMcpRegisteredTool { + name: string; + title?: string; + description: string; + inputSchema?: Record; + annotations?: { readOnlyHint?: boolean }; + window?: unknown; +} + +/** + * The consumer side of WebMCP's `ModelContext`: what the SDK needs to serve + * page-registered tools over `tools/list` / `tools/call`. + * + * @internal + */ +export interface WebMcpConsumer { + getTools(): Promise; + executeTool( + tool: WebMcpRegisteredTool, + input?: object, + options?: { signal?: AbortSignal }, + ): Promise; + addEventListener(type: "toolchange", listener: () => void): void; + removeEventListener(type: "toolchange", listener: () => void): void; +} + +/** + * The page's WebMCP model context, wherever the environment keeps it. The + * W3C draft moved `modelContext` from `Navigator` to `Document`; read the + * current shape first and fall back to the older one, and treat anything + * that goes wrong while reading (a throwing accessor, say) as "not + * available". + */ +function readModelContext(): unknown { + const g = globalThis as { + document?: { modelContext?: unknown }; + navigator?: { modelContext?: unknown }; + }; + try { + return g.document?.modelContext ?? g.navigator?.modelContext; + } catch { + return undefined; + } +} + +/** + * The document's WebMCP `modelContext` producer API, if the browser provides + * one natively, the host injected a polyfill into the View's HTML, or the + * page installed {@link installWebMcpPolyfill `installWebMcpPolyfill`}. + * + * @internal + */ +export function getWebMcpModelContext(): WebMcpModelContext | undefined { + try { + const modelContext = readModelContext() as + | Partial + | undefined; + return typeof modelContext?.registerTool === "function" + ? (modelContext as WebMcpModelContext) + : undefined; + } catch { + return undefined; + } +} + +/** + * The document's WebMCP `modelContext` consumer API, if it has one. Anything + * that goes wrong while reading it just means "not available". + * + * @internal + */ +export function getWebMcpConsumer(): WebMcpConsumer | undefined { + try { + const modelContext = readModelContext() as + | Partial + | undefined; + return typeof modelContext?.getTools === "function" && + typeof modelContext.executeTool === "function" && + typeof modelContext.addEventListener === "function" + ? (modelContext as WebMcpConsumer) + : undefined; + } catch { + return undefined; + } +} + +/** + * The tools registered in this document's own frame. Native `getTools()` also + * returns tools of the page's other frames, which are not this View's to + * serve. + * + * @internal + */ +export async function ownWebMcpTools( + consumer: WebMcpConsumer, +): Promise { + const view = (globalThis as { document?: { defaultView?: unknown } }) + .document?.defaultView; + return (await consumer.getTools()).filter((tool) => { + try { + return tool.window === undefined || tool.window === view; + } catch { + return false; // malformed entry (null, throwing getter): drop it + } + }); +} + +/** The text blocks of a `CallToolResult`, joined (used as an error message). */ +export function callToolResultText(result: { + content: Array<{ type: string; text?: string }>; +}): string { + return ( + result.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n") || "Tool call failed" + ); +} + +// Everything read from a model context may come from another realm or an +// implementation the SDK does not know: copy it so the wire only ever sees +// plain JSON. +const plain = (value: T): T => JSON.parse(JSON.stringify(value)); + +/** @internal */ +export const isPlainObject = ( + value: unknown, +): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The value behind an `executeTool()` result string. Implementations answer + * with a tool's return value as JSON, except that a string may come back raw + * (not JSON-quoted) and nothing as "" or "undefined". + * + * @internal + */ +export function parseWebMcpResult(json: unknown): unknown { + if (json === undefined || json === "" || json === "undefined") return null; + // The implementation controls what executeTool() resolves with: only text + // is a result. + if (typeof json !== "string") { + throw new TypeError("The tool did not return a serializable result"); + } + try { + return JSON.parse(json); + } catch { + return json; + } +} + +/** + * The tool-name pattern shared by the bridge and the polyfill. + * + * @internal + */ +export const WEBMCP_TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/; +const TOOL_NAME = WEBMCP_TOOL_NAME; + +/** + * Reads a WebMCP tool descriptor into a plain MCP `Tool` shape, or + * `undefined` if it is malformed. The descriptor is not necessarily the SDK's + * own: never rely on the types it reports, and read every member once (a + * getter may answer differently the second time). + * + * @internal + */ +export function toMcpTool( + tool: WebMcpRegisteredTool, +): ({ name: string } & Record) | undefined { + try { + const { name, title, description, annotations } = tool; + let schema: unknown = tool.inputSchema; + const readOnlyHint = annotations?.readOnlyHint; + if ( + typeof name !== "string" || + !TOOL_NAME.test(name) || + typeof description !== "string" + ) { + return undefined; + } + // Some implementations report the schema as a JSON string. + if (typeof schema === "string") schema = JSON.parse(schema); + return plain({ + name, + ...(typeof title === "string" && title !== "" && { title }), + description, + // MCP requires a schema of `type: "object"`; WebMCP does not. + inputSchema: { + properties: {}, + ...(isPlainObject(schema) ? schema : {}), + type: "object", + }, + ...(typeof readOnlyHint === "boolean" && { + annotations: { readOnlyHint }, + }), + }) as { name: string } & Record; + } catch { + return undefined; + } +} + +/** + * The input to hand `executeTool()`. Chrome's current native implementation + * reports schemas as JSON strings and takes the input as one too (the spec + * has objects for both). That is a property of the implementation, which a + * tool without a schema does not show: look at all of the frame's tools. + * + * @internal + */ +export function webMcpExecuteInput( + frameTools: WebMcpRegisteredTool[], + args: Record, +): object { + const copied = plain(args); + return frameTools.some((tool) => typeof tool?.inputSchema === "string") + ? (JSON.stringify(copied) as unknown as object) + : copied; +} + +/** + * An error's message; errors from a model context are not necessarily + * `instanceof Error` here. + * + * @internal + */ +export function webMcpErrorMessage(error: unknown): string { + try { + return String((error as { message?: unknown } | null)?.message ?? error); + } catch { + return "Tool call failed"; + } +}