diff --git a/.changeset/webmcp-script-handler.md b/.changeset/webmcp-script-handler.md new file mode 100644 index 0000000..fec1532 --- /dev/null +++ b/.changeset/webmcp-script-handler.md @@ -0,0 +1,5 @@ +--- +"mcp-handler": minor +--- + +Add an experimental `experimental_webMcp` option to `createMcpHandler`, which serves a browser bridge from the existing MCP route and registers an explicit allowlist of the 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/AUTHORIZATION.md b/docs/AUTHORIZATION.md index efe7079..7be059e 100644 --- a/docs/AUTHORIZATION.md +++ b/docs/AUTHORIZATION.md @@ -64,6 +64,50 @@ const authHandler = withMcpAuth(handler, verifyToken, { export { authHandler as GET, authHandler as POST }; ``` +## Browser session cookies for WebMCP + +The WebMCP bridge uses `fetch` with `credentials: "same-origin"` by default, so it automatically sends the session cookie already held by the browser. `verifyToken` receives the full request and can use that cookie when no bearer token is present: + +```typescript +const verifyAuth = async ( + req: Request, + bearerToken?: string, +): Promise => { + // Preserve OAuth support for regular MCP clients. + if (bearerToken) return verifyOAuthToken(bearerToken); + + // Only trust browser cookies on same-origin requests. + if (req.headers.get("sec-fetch-site") !== "same-origin") return undefined; + + const sessionToken = req.headers + .get("cookie") + ?.split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith("session=")) + ?.slice("session=".length); + if (!sessionToken) return undefined; + + const session = await verifySession(sessionToken); + if (!session) return undefined; + + return { + token: sessionToken, + scopes: ["read:stuff"], + clientId: session.userId, + extra: { userId: session.userId, authMethod: "cookie" }, + }; +}; + +const authHandler = withMcpAuth(handler, verifyAuth, { + required: true, + requiredScopes: ["read:stuff"], +}); +``` + +`verifyOAuthToken` and `verifySession` are calls into your auth provider or session store. Keep the session cookie `HttpOnly`, `Secure`, and `SameSite=Lax` or stricter. Do not return or log `authInfo.token` from a tool. + +See the [complete cookie-auth route](../examples/auth-cookie/route.ts) and the [WebMCP bridge guide](WEBMCP.md). + ## OAuth Protected Resource Metadata Create `app/.well-known/oauth-protected-resource/route.ts`: diff --git a/docs/WEBMCP.md b/docs/WEBMCP.md new file mode 100644 index 0000000..17b370e --- /dev/null +++ b/docs/WEBMCP.md @@ -0,0 +1,92 @@ +# WebMCP Bridge (experimental) + +> **Experimental.** [WebMCP](https://github.com/webmachinelearning/webmcp) is a W3C Web Machine Learning CG proposal under active development. Chrome offers an [origin trial and local testing flag](https://developer.chrome.com/docs/ai/webmcp#get-started). The API and browser availability may change as the proposal evolves. + +WebMCP lets a web page expose tools to in-page AI agents through `document.modelContext`. The bridge also supports the older `navigator.modelContext` surface used by some providers. `createMcpHandler` can serve a small script that lists the 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 can carry the user's session cookies — an in-page agent calls your tools *as the signed-in user*, with no browser-side OAuth flow. + +## Usage + +Enable the bridge on your existing MCP handler: + +```typescript +// app/api/mcp/route.ts +import { createMcpHandler } from "mcp-handler"; + +const handler = createMcpHandler( + (server) => { + // Register your MCP tools here. + }, + { + experimental_webMcp: { + // Only these tools are exposed to in-page agents. + tools: ["roll_dice", "search_docs"], + }, + }, +); + +export { handler as GET, handler as POST }; +``` + +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. + +Load any polyfill before the bridge script. For scripts that depend on each other's execution order, use ordered `defer` scripts rather than `async`: + +```html + + +``` + +## Compatibility and behavior + +- The current [WebMCP API](https://webmachinelearning.github.io/webmcp/) requires a secure context and an origin-isolated document. The `tools` Permissions Policy must allow registration. Cross-origin iframe agents need explicit tool exposure; this bridge uses the default same-origin exposure. +- The bridge targets stateless MCP endpoints with the `2025-06-18` Streamable HTTP protocol, including `mcp-handler`'s compatibility transport. It supports JSON and finite SSE responses, follows tool-list pagination, and does not manage MCP sessions or persistent notification streams. +- Tool registration happens once per script execution. A failed registration (for example, a duplicate tool name) is logged without preventing other tools from registering. The bridge does not replace existing tools or refresh the tool list when application state changes. +- Tool titles and `readOnlyHint` are preserved. If a description is missing, the bridge falls back to the title or name; provide descriptive MCP tool descriptions for useful agent discovery. MCP's other annotations are not automatically equivalent to WebMCP's `untrustedContentHint` or `consequentialHint`. +- Execution forwards the browser's cancellation signal to `fetch`. Cancelling a request does not guarantee cancellation or rollback of server-side work. MCP tool results, including `isError` and error content, are returned unchanged. + +## Choosing tools to bridge + +Bridge tools that are useful in the current page and can return server results directly. Register client-side tools for actions that must update visible UI, invalidate client caches, ask for confirmation, or follow a component's lifecycle. A successful server tool call does not automatically update the page. See the [WebMCP best practices](https://developer.chrome.com/docs/ai/webmcp/best-practices) for guidance on keeping tools and page state aligned. + +For tools that need WebMCP-specific output-trust or consequential-action hints, register them in the page with the appropriate [annotations](https://developer.chrome.com/docs/ai/webmcp/secure-tools#use-annotation-hints), and leave them out of the bridge allowlist. + +## Options + +| Option | Required | Default | Description | +| --- | --- | --- | --- | +| `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. A bearer-only deployment needs a same-origin session/BFF layer; do not put access tokens in the generated script. +- Wrapping the handler with `withMcpAuth` also protects `GET /api/mcp?webmcp-script`. This works naturally when the script request carries a valid session cookie. If you want the inert script asset to be public, dispatch that exact `GET` request to the MCP handler before applying auth, while continuing to authenticate every MCP protocol request. + +## Hardening + +### Gate cookie auth on `Sec-Fetch-Site: same-origin` + +For a same-origin MCP endpoint, browsers identify the bridge's tool calls with `Sec-Fetch-Site: same-origin`. If your verifier honors session cookies, reject cookie-authenticated calls from anywhere else while leaving bearer-token clients untouched. Set `required: true` so a rejected or missing session cannot fall through to unauthenticated tool execution. + +The [complete cookie-auth route](../examples/auth-cookie/route.ts) shows both paths. The browser reuses its existing `HttpOnly` session cookie, while regular MCP clients can still send OAuth bearer tokens. See [Browser session cookies for WebMCP](AUTHORIZATION.md#browser-session-cookies-for-webmcp) for the verifier by itself. + +### 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 + +``` + +For a same-origin MCP endpoint, `connect-src 'self'` covers the tool calls. An absolute endpoint URL on another origin needs an appropriate CSP and CORS policy; the cookie-auth example above deliberately rejects that configuration. diff --git a/examples/auth-cookie/route.ts b/examples/auth-cookie/route.ts new file mode 100644 index 0000000..461a3bc --- /dev/null +++ b/examples/auth-cookie/route.ts @@ -0,0 +1,92 @@ +import type { AuthInfo } from "@modelcontextprotocol/server"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { z } from "zod"; + +const SESSION_COOKIE = "session"; +const SCOPES = ["read:stuff"]; + +const mcpHandler = createMcpHandler( + (server) => { + server.registerTool( + "echo", + { + description: "Echo a message", + inputSchema: z.object({ message: z.string() }), + }, + async ({ message }, ctx) => ({ + content: [ + { + type: "text", + text: `Echo: ${message} for user ${ctx.http?.authInfo?.clientId}`, + }, + ], + }), + ); + }, + { + experimental_webMcp: { + tools: ["echo"], + }, + }, +); + +function readCookie(req: Request, name: string): string | undefined { + const prefix = `${name}=`; + const cookie = req.headers + .get("cookie") + ?.split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith(prefix)); + + return cookie?.slice(prefix.length); +} + +async function verifySession( + sessionToken: string, +): Promise<{ userId: string } | undefined> { + // Replace this with your application's session lookup. + if (!sessionToken.startsWith("__TEST_SESSION__")) return undefined; + return { userId: "user123" }; +} + +async function verifyBearerToken( + bearerToken: string, +): Promise { + // Keep this branch if non-browser MCP clients also use this endpoint. + if (!bearerToken.startsWith("__TEST_VALUE__")) return undefined; + return { + token: bearerToken, + scopes: SCOPES, + clientId: "remote-client", + }; +} + +const verifyAuth = async ( + req: Request, + bearerToken?: string, +): Promise => { + if (bearerToken) return verifyBearerToken(bearerToken); + + // Only accept browser cookies on same-origin requests. + if (req.headers.get("sec-fetch-site") !== "same-origin") return undefined; + + const sessionToken = readCookie(req, SESSION_COOKIE); + if (!sessionToken) return undefined; + + const session = await verifySession(sessionToken); + if (!session) return undefined; + + return { + token: sessionToken, + scopes: SCOPES, + clientId: session.userId, + extra: { userId: session.userId, authMethod: "cookie" }, + }; +}; + +const authHandler = withMcpAuth(mcpHandler, verifyAuth, { + required: true, + requiredScopes: SCOPES, +}); + +export { authHandler as GET, authHandler as POST }; diff --git a/src/handler/index.ts b/src/handler/index.ts index 5a56740..0baf5cb 100644 --- a/src/handler/index.ts +++ b/src/handler/index.ts @@ -1,10 +1,11 @@ import { initializeMcpApiHandler, type McpHandlerOptions, + type WebMcpOptions, } from "./mcp-api-handler"; import type { McpServer } from "@modelcontextprotocol/server"; -export type { McpHandlerOptions }; +export type { McpHandlerOptions, WebMcpOptions }; /** * Creates a MCP handler that can be used to handle MCP requests. @@ -15,7 +16,7 @@ export type { McpHandlerOptions }; * Hono, Nitro, ...). * * @param initializeServer - A function that initializes the MCP server. Use this to access the server instance and register tools, prompts, and resources. - * @param options - The SDK's server options plus handler extras (`serverInfo`, `verboseLogs`, `onEvent`, `maxSubscriptions`). + * @param options - The SDK's server options plus handler extras (`serverInfo`, `verboseLogs`, `onEvent`, `maxSubscriptions`, `experimental_webMcp`). * @returns A function that can be used to handle MCP requests. */ export default function createMcpRouteHandler( diff --git a/src/handler/mcp-api-handler.ts b/src/handler/mcp-api-handler.ts index 6c9a74f..7ebc3ee 100644 --- a/src/handler/mcp-api-handler.ts +++ b/src/handler/mcp-api-handler.ts @@ -10,6 +10,12 @@ import type { McpErrorEvent, } from "../lib/log-helper"; import { createEvent } from "../lib/log-helper"; +import { + createWebMcpScriptHandler, + type WebMcpScriptHandlerOptions, +} from "../webmcp/script-handler"; + +export type WebMcpOptions = Omit; /** * Options for the MCP handler: the SDK's `ServerOptions` (capabilities, @@ -39,6 +45,14 @@ export type McpHandlerOptions = McpServerOptions & { * This can be used to track analytics, debug issues, or implement custom behaviors. */ onEvent?: (event: McpEvent) => void; + /** + * Publishes an allowlisted subset of this server's tools to in-page agents + * through WebMCP. Load the generated bridge from the MCP route with the + * `?webmcp-script` query parameter. + * + * @experimental WebMCP is an early-stage browser API. + */ + experimental_webMcp?: WebMcpOptions; }; export function initializeMcpApiHandler( @@ -55,9 +69,19 @@ export function initializeMcpApiHandler( verboseLogs = false, onEvent, maxSubscriptions, + experimental_webMcp, ...mcpServerOptions } = options; + // Validate WebMCP configuration when the handler is created rather than on + // the first request. The real endpoint is inferred from the script request. + if (experimental_webMcp) { + createWebMcpScriptHandler({ + ...experimental_webMcp, + endpoint: "/", + }); + } + const emitError = (error: Error) => { if (verboseLogs) { console.error("MCP handler error:", error); @@ -90,6 +114,20 @@ export function initializeMcpApiHandler( ); return async function mcpApiHandler(req: Request): Promise { + if ( + experimental_webMcp && + (req.method === "GET" || req.method === "HEAD") + ) { + const scriptUrl = new URL(req.url); + if (scriptUrl.searchParams.has("webmcp-script")) { + scriptUrl.searchParams.delete("webmcp-script"); + return createWebMcpScriptHandler({ + ...experimental_webMcp, + endpoint: scriptUrl.toString(), + })(req); + } + } + let method: string | undefined; let parsedBody: unknown; const started = Date.now(); diff --git a/src/index.ts b/src/index.ts index a3b3873..3028236 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ // Re-export the framework-agnostic HTTP adapter export { default as createMcpHandler } from "./handler"; -export type { McpHandlerOptions } from "./handler"; +export type { McpHandlerOptions, WebMcpOptions } from "./handler"; /** * @deprecated Use withMcpAuth instead diff --git a/src/webmcp/script-handler.ts b/src/webmcp/script-handler.ts new file mode 100644 index 0000000..4bdc3fc --- /dev/null +++ b/src/webmcp/script-handler.ts @@ -0,0 +1,204 @@ +/** + * 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 response = await handler( + new Request("http://localhost/api/mcp?webmcp-script"), + ); + const script = await response.text(); + expect(script).not.toContain(""); + }); + + it("supports HEAD requests and configured cache control", async () => { + const handler = createMcpHandler(() => {}, { + experimental_webMcp: { + tools: [], + cacheControl: "private, no-store", + }, + }); + const res = await handler( + new Request("http://localhost/api/mcp?webmcp-script", { + method: "HEAD", + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + expect(await res.text()).toBe(""); + }); + + it("requires an explicit tools allowlist", () => { + expect(() => + createMcpHandler(() => {}, { experimental_webMcp: {} as never }), + ).toThrow("`tools` must be an array of tool names"); + }); +}); + +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" }] }), + ); + }, + { experimental_webMcp: { tools: ["echo"] } }, + ); + + 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 script = await fetch(`${endpoint}?webmcp-script`).then((response) => + response.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 script = await fetch(`${endpoint}?webmcp-script`).then((response) => + response.text(), + ); + expect(() => + new Function("navigator", "document", script)(undefined, undefined), + ).not.toThrow(); + }); +});