diff --git a/.changeset/webmcp-script-handler.md b/.changeset/webmcp-script-handler.md new file mode 100644 index 0000000..8582bc9 --- /dev/null +++ b/.changeset/webmcp-script-handler.md @@ -0,0 +1,5 @@ +--- +"mcp-handler": minor +--- + +Add experimental `mcp-handler/webmcp` export with `experimental_createWebMcpScriptHandler`, which serves a browser script that registers an explicit allowlist of the MCP endpoint's tools with the page's WebMCP provider (`navigator.modelContext` / `document.modelContext`). diff --git a/README.md b/README.md index 925c94d..771406b 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ See [Authorization](docs/AUTHORIZATION.md) for wiring details. - [Client Integration](docs/CLIENTS.md) - Claude Desktop, Cursor, Windsurf setup - [Authorization](docs/AUTHORIZATION.md) - OAuth and token verification - [Advanced Usage](docs/ADVANCED.md) - Dynamic routing, Nuxt, configuration options +- [WebMCP Bridge](docs/WEBMCP.md) - Expose allowlisted tools to in-page agents (experimental) ## Features diff --git a/docs/WEBMCP.md b/docs/WEBMCP.md new file mode 100644 index 0000000..66ad5a2 --- /dev/null +++ b/docs/WEBMCP.md @@ -0,0 +1,74 @@ +# WebMCP Bridge (experimental) + +> **Experimental.** [WebMCP](https://github.com/webmachinelearning/webmcp) is an early-stage W3C Web Machine Learning CG proposal with no shipping browser support yet. This bridge targets polyfills and extension-based agents today, and the API may change as the proposal evolves. + +WebMCP lets a web page expose tools to in-page AI agents (browser built-ins, extensions, or iframe-hosted agents) through `navigator.modelContext` / `document.modelContext`. `mcp-handler/webmcp` bridges your server-side MCP tools into that surface: it serves a small script that lists your MCP endpoint's tools and registers an allowlisted subset with the page's WebMCP provider. + +Because tool calls run through `fetch` from the page, they carry the user's session cookies — an in-page agent calls your tools *as the signed-in user*, with no OAuth flow. + +## Usage + +Mount the script endpoint next to your MCP route: + +```typescript +// app/webmcp.js/route.ts +import { experimental_createWebMcpScriptHandler } from "mcp-handler/webmcp"; + +const handler = experimental_createWebMcpScriptHandler({ + endpoint: "/api/mcp", + // Only these tools are exposed to in-page agents. + tools: ["roll_dice", "search_docs"], +}); + +export { handler as GET }; +``` + +Then include it in your page: + +```html + +``` + +In a browser (or polyfill) with a WebMCP provider, the script initializes against the MCP endpoint, lists tools, and registers each allowlisted tool with `modelContext.registerTool()`, forwarding `execute` calls to `tools/call`. Without a provider it is a no-op. + +## Options + +| Option | Required | Default | Description | +| --- | --- | --- | --- | +| `endpoint` | yes | — | URL or path of the MCP endpoint the script talks to. | +| `tools` | yes | — | Allowlist of tool names exposed to the page. Tools not listed are never registered. | +| `credentials` | no | `"same-origin"` | Credentials mode for the fetches issued from the page (`"same-origin"`, `"include"`, `"omit"`). | +| `cacheControl` | no | `"public, max-age=300"` | `Cache-Control` header on the script response. | + +## Security notes + +- **The allowlist is deliberate and required.** Any script or agent in the page can invoke registered tools with the user's credentials, so expose only tools that are safe to call on the user's behalf. Prefer read-only tools; treat side-effectful tools like you would a same-site form submission. +- The allowlist controls what is surfaced to in-page agents — it does not restrict the MCP endpoint itself, which continues to serve its full tool set to regular MCP clients. +- If your MCP endpoint uses `withMcpAuth` with bearer tokens, the bridged calls will be unauthenticated unless your verifier also accepts session cookies. Cookie-session verification is the natural pairing for this bridge. + +## Hardening + +### Gate cookie auth on `Sec-Fetch-Site: same-origin` + +Browsers attach `Sec-Fetch-Site` to every request, and the bridge's tool calls are always `same-origin`. If your verifier honors session cookies, reject cookie-authenticated calls from anywhere else — this cuts off any residual cross-site angle while leaving bearer-token clients untouched: + +```typescript +const handler = withMcpAuth(mcpHandler, async (req, bearerToken) => { + // Remote MCP clients: OAuth bearer path. + if (bearerToken) return verifyOAuthToken(bearerToken); + + // WebMCP bridge: only honor cookies for same-origin, browser-issued calls. + if (req.headers.get("sec-fetch-site") !== "same-origin") return undefined; + return verifySessionCookie(req); +}); +``` + +### CSP nonce for the script tag + +The bridge is a regular same-origin external script, so under a nonce-based CSP (`script-src 'nonce-...' 'strict-dynamic'`) it needs the nonce on its tag like any other script: + +```html + +``` + +The tool calls themselves are same-origin `fetch`es, so the default `connect-src 'self'` already covers them. diff --git a/package.json b/package.json index 3b0b7d9..8c4815b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,14 @@ }, "import": "./dist/index.mjs", "require": "./dist/index.js" + }, + "./webmcp": { + "types": { + "import": "./dist/webmcp/index.d.mts", + "require": "./dist/webmcp/index.d.ts" + }, + "import": "./dist/webmcp/index.mjs", + "require": "./dist/webmcp/index.js" } }, "files": [ diff --git a/src/webmcp/index.ts b/src/webmcp/index.ts new file mode 100644 index 0000000..ccbb2b4 --- /dev/null +++ b/src/webmcp/index.ts @@ -0,0 +1,6 @@ +export { + // WebMCP is an early-stage proposal; this export is experimental and may + // change or be removed in a minor release. + createWebMcpScriptHandler as experimental_createWebMcpScriptHandler, + type WebMcpScriptHandlerOptions, +} from "./script-handler"; diff --git a/src/webmcp/script-handler.ts b/src/webmcp/script-handler.ts new file mode 100644 index 0000000..0069212 --- /dev/null +++ b/src/webmcp/script-handler.ts @@ -0,0 +1,192 @@ +/** + * Options for the WebMCP bridge script endpoint. + */ +export type WebMcpScriptHandlerOptions = { + /** + * URL of the MCP endpoint the script talks to. May be a path relative to + * the page origin ("/api/mcp") or an absolute URL. + */ + endpoint: string; + /** + * Explicit allowlist of tool names exposed to in-page agents. Tools not + * listed here are never registered with the browser, even though they + * remain reachable through the MCP endpoint itself. + */ + tools: string[]; + /** + * Credentials mode for the tool-call fetches issued from the page. + * @default "same-origin" + */ + credentials?: "same-origin" | "include" | "omit"; + /** + * Value served in the script response's Cache-Control header. + * @default "public, max-age=300" + */ + cacheControl?: string; +}; + +type ScriptConfig = { + endpoint: string; + tools: string[]; + credentials: string; +}; + +/** + * Returns a Web-standard handler that serves a small browser script. When + * loaded in a page, the script lists the tools of the MCP endpoint, filters + * them down to the configured allowlist, and registers each one with the + * page's WebMCP provider (`navigator.modelContext` / `document.modelContext`) + * so in-page agents can call them. Tool calls run through `fetch` and carry + * the user's session according to the configured credentials mode. + * + * WebMCP is an early-stage W3C proposal; in browsers without a provider (or + * polyfill) the script is a no-op. + */ +export function createWebMcpScriptHandler( + options: WebMcpScriptHandlerOptions, +): (req: Request) => Response { + const { + endpoint, + tools, + credentials = "same-origin", + cacheControl = "public, max-age=300", + } = options; + + if (typeof endpoint !== "string" || endpoint.length === 0) { + throw new Error("createWebMcpScriptHandler: `endpoint` is required"); + } + if ( + !Array.isArray(tools) || + tools.some((name) => typeof name !== "string" || name.length === 0) + ) { + throw new Error( + "createWebMcpScriptHandler: `tools` must be an array of tool names — only allowlisted tools are exposed to the web", + ); + } + + const script = buildScript({ endpoint, tools, credentials }); + const headers = { + "content-type": "text/javascript; charset=utf-8", + "cache-control": cacheControl, + }; + + return function webMcpScriptHandler(req: Request): Response { + if (req.method === "HEAD") { + return new Response(null, { status: 200, headers }); + } + if (req.method !== "GET") { + return new Response("Method not allowed", { + status: 405, + headers: { allow: "GET, HEAD" }, + }); + } + return new Response(script, { status: 200, headers }); + }; +} + +function buildScript(config: ScriptConfig): string { + // "<" is escaped so the config can never terminate an inline "], + }); + const script = await handler( + new Request("http://localhost/webmcp.js"), + ).text(); + expect(script).not.toContain(""); + }); + + it("rejects non-GET requests", () => { + const handler = createWebMcpScriptHandler({ + endpoint: "/api/mcp", + tools: [], + }); + const res = handler( + new Request("http://localhost/webmcp.js", { method: "POST" }), + ); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET, HEAD"); + }); + + it("requires an explicit tools allowlist", () => { + expect(() => + createWebMcpScriptHandler({ endpoint: "/api/mcp" } as never), + ).toThrow("`tools` must be an array of tool names"); + expect(() => + createWebMcpScriptHandler({ tools: ["echo"] } as never), + ).toThrow("`endpoint` is required"); + }); +}); + +describe("webmcp bridge e2e", () => { + let server: Server; + let endpoint: string; + + beforeEach(async () => { + const mcpHandler = createMcpHandler((server) => { + server.registerTool( + "echo", + { + description: "Echo a message", + inputSchema: z.object({ message: z.string() }), + }, + async ({ message }) => ({ + content: [{ type: "text", text: `Tool echo: ${message}` }], + }), + ); + server.registerTool( + "secret", + { + description: "Not for the web", + inputSchema: z.object({}), + }, + async () => ({ content: [{ type: "text", text: "secret" }] }), + ); + }); + + server = createServer(nodeToWebHandler(mcpHandler)); + await new Promise((resolve) => { + server.listen(0, () => resolve()); + }); + const port = (server.address() as AddressInfo | null)?.port; + endpoint = `http://localhost:${port}/api/mcp`; + }); + + afterEach(() => { + server.close(); + }); + + async function runBridgeScript(): Promise { + const scriptHandler = createWebMcpScriptHandler({ + endpoint, + tools: ["echo"], + }); + const script = await scriptHandler( + new Request("http://localhost/webmcp.js"), + ).text(); + + const registered: RegisteredTool[] = []; + const provider = { + registerTool: (tool: RegisteredTool) => { + registered.push(tool); + }, + }; + // Shadow the globals the script feature-detects; fetch stays global. + new Function("navigator", "document", script)( + { modelContext: provider }, + undefined, + ); + + await vi.waitFor(() => { + expect(registered.length).toBeGreaterThan(0); + }); + return registered; + } + + it("registers only allowlisted tools with the WebMCP provider", async () => { + const registered = await runBridgeScript(); + expect(registered).toHaveLength(1); + expect(registered[0].name).toBe("echo"); + expect(registered[0].description).toBe("Echo a message"); + expect(registered[0].inputSchema).toMatchObject({ + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }); + }); + + it("executes tool calls against the MCP endpoint", async () => { + const [echo] = await runBridgeScript(); + const result = (await echo.execute({ message: "Are you there?" })) as { + content: Array<{ type: string; text: string }>; + }; + expect(result.content[0].text).toBe("Tool echo: Are you there?"); + }); + + it("is a no-op when no WebMCP provider exists", async () => { + const scriptHandler = createWebMcpScriptHandler({ + endpoint, + tools: ["echo"], + }); + const script = await scriptHandler( + new Request("http://localhost/webmcp.js"), + ).text(); + expect(() => + new Function("navigator", "document", script)(undefined, undefined), + ).not.toThrow(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 3559fd5..2ed1edf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts', 'src/cli/index.ts'], + entry: ['src/index.ts', 'src/cli/index.ts', 'src/webmcp/index.ts'], format: ['esm', 'cjs'], dts: true, splitting: true,