Skip to content
Open
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 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`).
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
44 changes: 44 additions & 0 deletions docs/AUTHORIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthInfo | undefined> => {
// 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`:
Expand Down
92 changes: 92 additions & 0 deletions docs/WEBMCP.md
Original file line number Diff line number Diff line change
@@ -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
<script src="/api/mcp?webmcp-script" 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.

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
<script src="/your-webmcp-polyfill.js" defer></script>
<script src="/api/mcp?webmcp-script" defer></script>
```

## 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
<script src="/api/mcp?webmcp-script" nonce="<your-request-nonce>" async></script>
```

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.
92 changes: 92 additions & 0 deletions examples/auth-cookie/route.ts
Original file line number Diff line number Diff line change
@@ -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<AuthInfo | undefined> {
// 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<AuthInfo | undefined> => {
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 };
5 changes: 3 additions & 2 deletions src/handler/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions src/handler/mcp-api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebMcpScriptHandlerOptions, "endpoint">;

/**
* Options for the MCP handler: the SDK's `ServerOptions` (capabilities,
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -90,6 +114,20 @@ export function initializeMcpApiHandler(
);

return async function mcpApiHandler(req: Request): Promise<Response> {
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();
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading