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
5 changes: 5 additions & 0 deletions .changeset/webmcp-script-handler.md
Original file line number Diff line number Diff line change
@@ -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`).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions docs/WEBMCP.md
Original file line number Diff line number Diff line change
@@ -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
<script src="/webmcp.js" async></script>
```

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
<script src="/webmcp.js" nonce="<your-request-nonce>" async></script>
```

The tool calls themselves are same-origin `fetch`es, so the default `connect-src 'self'` already covers them.
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
6 changes: 6 additions & 0 deletions src/webmcp/index.ts
Original file line number Diff line number Diff line change
@@ -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";
192 changes: 192 additions & 0 deletions src/webmcp/script-handler.ts
Original file line number Diff line number Diff line change
@@ -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 <script> tag.
const configJson = JSON.stringify(config).replace(/</g, "\\u003c");

return `(() => {
"use strict";
const config = ${configJson};
const provider =
(typeof navigator !== "undefined" && navigator.modelContext) ||
(typeof document !== "undefined" && document.modelContext);
if (!provider || typeof provider.registerTool !== "function") {
return;
}

let protocolVersion = null;
let requestId = 0;

async function rpc(method, params, isNotification) {
const body = { jsonrpc: "2.0", method };
if (params !== undefined) body.params = params;
if (!isNotification) body.id = ++requestId;
const headers = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
};
if (protocolVersion) headers["mcp-protocol-version"] = protocolVersion;
const res = await fetch(config.endpoint, {
method: "POST",
credentials: config.credentials,
headers,
body: JSON.stringify(body),
});
if (isNotification) return null;
if (!res.ok) {
throw new Error("MCP request failed: HTTP " + res.status);
}
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
return parseSse(await res.text(), body.id);
}
return unwrap(await res.json(), body.id);
}

function parseSse(text, id) {
for (const event of text.split(/\\r?\\n\\r?\\n/)) {
const data = event
.split(/\\r?\\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\\n");
if (!data) continue;
let message;
try {
message = JSON.parse(data);
} catch {
continue;
}
if (
message &&
message.id === id &&
("result" in message || "error" in message)
) {
return unwrap(message, id);
}
}
throw new Error("No MCP response found in event stream");
}

function unwrap(message, id) {
if (message && message.error) {
throw new Error(
message.error.message || "MCP error " + message.error.code,
);
}
if (!message || message.id !== id) {
throw new Error("Unexpected MCP response");
}
return message.result;
}

(async () => {
const init = await rpc("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "mcp-handler/webmcp", version: "1.0.0" },
});
protocolVersion = (init && init.protocolVersion) || "2025-06-18";
await rpc("notifications/initialized", undefined, true).catch(() => null);

const listed = await rpc("tools/list", {});
for (const tool of (listed && listed.tools) || []) {
if (!config.tools.includes(tool.name)) continue;
provider.registerTool({
name: tool.name,
description: tool.description || tool.title || "",
inputSchema: tool.inputSchema,
execute: (args) =>
rpc("tools/call", { name: tool.name, arguments: args || {} }),
});
}
})().catch((error) => {
console.warn("[mcp-handler/webmcp] failed to register tools:", error);
});
})();
`;
}
77 changes: 2 additions & 75 deletions tests/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from "vitest";
import { z } from "zod";
import {
createServer,
IncomingMessage,
ServerResponse,
type Server,
} from "node:http";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import { createMcpHandler } from "../src/index";
import { withMcpAuth } from "../src/auth/auth-wrapper";
import { nodeToWebHandler } from "./helpers";

describe("e2e", () => {
let server: Server;
Expand Down Expand Up @@ -385,72 +381,3 @@ describe("e2e", () => {
).rejects.toThrow("Invalid token");
});
});

function nodeToWebHandler(
handler: (req: Request) => Promise<Response>,
): (req: IncomingMessage, res: ServerResponse) => void {
return async (req, res) => {
const method = (req.method || "GET").toUpperCase();
const requestBody =
method === "GET" || method === "HEAD"
? undefined
: await new Promise<ArrayBuffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => {
chunks.push(chunk);
});
req.on("end", () => {
const buf = Buffer.concat(chunks);
resolve(
buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
),
);
});
req.on("error", () => {
reject(new Error("Failed to read request body"));
});
});

const requestHeaders = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const val of value) {
requestHeaders.append(key, val);
}
} else {
requestHeaders.append(key, value);
}
}

const reqUrl = new URL(req.url || "/", "http://localhost");
const webReq = new Request(reqUrl, {
method: req.method,
headers: requestHeaders,
body: requestBody,
});

const webResp = await handler(webReq);

const responseHeaders = Object.fromEntries(webResp.headers);
res.writeHead(webResp.status, webResp.statusText, responseHeaders);

if (webResp.body) {
const reader = webResp.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
}
} finally {
reader.releaseLock();
}
}
res.end();
};
}
Loading