diff --git a/docs/csp-cors.md b/docs/csp-cors.md index 8978ab615..f14462b9d 100644 --- a/docs/csp-cors.md +++ b/docs/csp-cors.md @@ -60,5 +60,46 @@ registerAppResource( Note that `_meta.ui.csp` and `_meta.ui.domain` are set in the `contents[]` objects returned by the resource read callback, not in `registerAppResource()`'s config object. +## CORS for the MCP endpoint itself + +The section above covers requests that an app makes from its iframe. A second, separate CORS boundary appears when the **host** runs in a browser and connects to your MCP server on another origin. This is the case for `examples/basic-host`, for web-based inspectors, and for any web assistant that embeds MCP Apps. In that setup, every MCP request is a cross-origin `fetch` from the host page to your `/mcp` endpoint, and the browser sends a preflight `OPTIONS` request first. + +The Express examples in this repo use `app.use(cors())`, which reflects the requested headers and works out of the box. If you set CORS headers by hand (for example in a Cloudflare Worker, Deno or Bun `fetch` handler around `createMcpHandler`), allow the headers the TypeScript client sends: + +| Header | Sent when | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Content-Type`, `Accept` | Every request | +| `Mcp-Session-Id` | Stateful sessions | +| `Mcp-Protocol-Version` | After negotiation, and on every 2026-07-28 request | +| `Mcp-Method`, `Mcp-Name` | 2026-07-28 requests: the client mirrors the JSON-RPC method, and the tool, resource or prompt name, into headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) | +| `Authorization` (and `DPoP`, if used) | Authenticated servers | +| `Last-Event-ID` | Resuming an SSE stream | + +and expose `Mcp-Session-Id` and `Mcp-Protocol-Version` so the client can read them: + +```ts +const CORS = { + "Access-Control-Allow-Origin": "*", // or your host's origin + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID", + "Access-Control-Expose-Headers": "Mcp-Session-Id, Mcp-Protocol-Version", +}; + +export default { + async fetch(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS }); + } + const response = await handler.fetch(request); // from createMcpHandler(...) + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(CORS)) headers.set(name, value); + return new Response(response.body, { status: response.status, headers }); + }, +}; +``` + +Leaving out `Mcp-Method` and `Mcp-Name` is easy to miss: requests on the 2025 protocol keep working, but the 2026-07-28 preflight fails with _"Request header field mcp-method is not allowed by Access-Control-Allow-Headers"_. A client using `versionNegotiation: { mode: "auto" }` then falls back to the 2025 handshake or fails to connect. + > [!NOTE] > For full examples that configures CSP, see: [`examples/sheet-music-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) (`connectDomains`) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (`connectDomains` and `resourceDomains`).