Skip to content
Open
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
41 changes: 41 additions & 0 deletions docs/csp-cors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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`).