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:
OPTIONSgets204withAccess-Control-Allow-Methods: GET, POST, DELETE, OPTIONS, the requested headers allowed (reflected fromAccess-Control-Request-Headers, soauthorization,mcp-protocol-version,mcp-method,mcp-name,last-event-idandMcp-Param-*all work) andAccess-Control-Max-Age: 86400.- Every response gets
Access-Control-Allow-Origin(*, or the request's origin when it's on the list, withVary: Origin) and exposesmcp-session-id,mcp-protocol-versionandwww-authenticate. allowHeaders,exposeHeadersandmaxAgecan 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
OPTIONSorPOST(unchanged behaviour) cors: true: preflight is204with the expected headers; aninitializePOST keeps its status, content type and body and gains the CORS headers- origin allow-list: allowed origins are echoed with
Vary: Origin, others get noAccess-Control-Allow-Origin; a single origin string works - fixed
allowHeaders/ extraexposeHeaders/ custommaxAge - a real
Client+StreamableHTTPClientTransportsession (2025-era and pinned 2026-07-28) through the wrapper: list and call a tool withMcpAuth: preflight answered without callingverifyToken;401carries the headers and exposesWWW-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.