#200

feat: add opt-in `cors` option for browser MCP clients

OpenRohanGlitchedwants to merge intofrom

What

An opt-in cors option on createMcpHandler and withMcpAuth:

const handler = createMcpHandler(register, {
  cors: { origin: ["https://inspector.example.com"] }, // or `cors: true` for any origin
});

export { handler as GET, handler as POST, handler as DELETE, handler as OPTIONS };

With it set:

  • OPTIONS gets 204 with Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS, the requested headers allowed (reflected from Access-Control-Request-Headers, so authorization, mcp-protocol-version, mcp-method, mcp-name, last-event-id and Mcp-Param-* all work) and Access-Control-Max-Age: 86400.
  • Every response gets Access-Control-Allow-Origin (*, or the request's origin when it's on the list, with Vary: Origin) and exposes mcp-session-id, mcp-protocol-version and www-authenticate.
  • allowHeaders, exposeHeaders and maxAge can be overridden.

On withMcpAuth the same option answers preflights before the token check (browsers never send credentials on a preflight, so required: true would otherwise 401 them) and puts the headers on the 401/403 challenges, so a browser client can read WWW-Authenticate and find the resource metadata.

It's off by default. Without cors, OPTIONS still gets 405 and no response carries CORS headers, so existing handlers don't change.

Why

I run an MCP server on mcp-handler 2 (Lowtide, an energy assistant for Alexa+, a Next.js app on AWS Lambda served at https://d1soo7510nfzf4.cloudfront.net/api/mcp), and the clients that hit it from a browser (MCP inspectors and Countertop, an MCP Apps test bench I wrote) are on other origins. The browser blocks every request until the server answers the preflight and sends the right headers, so I had to wrap the handler myself (web/lib/mcp/handler.ts). That wrapper is easy to get subtly wrong (the 2026-07-28 headers like mcp-method/mcp-name, exposing mcp-session-id, the auth challenge), so it seemed worth having in the package.

This is separate from the Origin validation in #178: that one decides which origins the server accepts, this one sends the headers a browser needs to use the response. They should fit together if both land.

How it's tested

New tests/cors.test.ts (11 tests):

  • no option: no CORS headers on OPTIONS or POST (unchanged behaviour)
  • cors: true: preflight is 204 with the expected headers; an initialize POST keeps its status, content type and body and gains the CORS headers
  • origin allow-list: allowed origins are echoed with Vary: Origin, others get no Access-Control-Allow-Origin; a single origin string works
  • fixed allowHeaders / extra exposeHeaders / custom maxAge
  • a real Client + StreamableHTTPClientTransport session (2025-era and pinned 2026-07-28) through the wrapper: list and call a tool
  • withMcpAuth: preflight answered without calling verifyToken; 401 carries the headers and exposes WWW-Authenticate; no option means no change

pnpm exec tsc --noEmit (also with --noUnusedLocals --noUnusedParameters), pnpm test (153 passing) and pnpm build all pass.

Docs: a "Browser Clients (CORS)" section in docs/ADVANCED.md, a short section in docs/AUTHORIZATION.md for the auth case, a pointer in the README. Includes a minor changeset.