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,