Skip to content

Streamable HTTP server wrapper: Fix CORS for browser clients (missing handlers, missing headers on error paths, and Mcp-Session-Id not exposed)#42

Closed
polaon wants to merge 7 commits into
0xeb:mainfrom
polaon:main
Closed

Conversation

@polaon
Copy link
Copy Markdown
Contributor

@polaon polaon commented Apr 21, 2026

Summary:

Browser-based MCP clients (origin different from the server's origin) cannot use StreamableHttpServerWrapper reliably today. Three distinct CORS issues cause preflights and actual requests to be blocked by the browser, and cause the JavaScript client to be unable to read the Mcp-Session-Id response header even when it is physically present on the wire.

This PR fixes all three in src/server/streamable_http_server.cpp.

Problems:

GET /mcp returns a 405 without CORS headers:

The Get handler constructs a 405 "Method Not Allowed" response but never calls apply_additional_response_headers(res). Any cross-origin browser request to GET /mcp (which most MCP JS SDKs attempt as part of the Streamable HTTP transport handshake, since the spec allows GET for server-initiated SSE streams) therefore fails with:

Access to fetch at http://localhost:.../mcp from origin http://localhost:... has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.

DELETE /mcp is not handled at all:

The MCP Streamable HTTP spec (2025-03-26) allows clients to send DELETE /mcp to terminate a session. fastmcpp does not register a Delete handler, so httplib falls back to its default 404 response, which has no configured CORS headers and triggers the same browser error.

Additionally, the Options preflight handler advertises only POST, OPTIONS in Access-Control-Allow-Methods. Browsers therefore reject the DELETE preflight preemptively, before ever hitting the (currently non-existent) DELETE handler.

Mcp-Session-Id response header is not exposed to JS:

The Post handler attaches Mcp-Session-Id on the initialize response, but does not set Access-Control-Expose-Headers. Per CORS, browsers only expose a small whitelist of "safe" response headers to cross-origin JS (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma). Mcp-Session-Id is not in that whitelist, so response.headers.get("Mcp-Session-Id") in browser JS returns null even though curl -v clearly shows the header on the wire. The client then cannot store or echo the session id, and every subsequent non-initialize request fails with HTTP 400 Mcp-Session-Id header required.

Several early-return paths in the POST handler ran before apply_additional_response_headers(res):

The 401 Unauthorized response was emitted before apply_additional_response_headers(res) was called, so auth failures lacked CORS headers. In addition, none of the catch handlers re-applied the headers — this happens to work because httplib preserves headers already set on res across the try/catch boundary, but relying on that is brittle and obscures intent.

Changes:

POST handler: moved apply_additional_response_headers(res) to the very top of the lambda, before the auth check and outside the try block, so every response path (success, 401, 503, 400, 404, 5xx via catch) carries the configured CORS / custom headers unconditionally.

POST handler: added res.set_header("Access-Control-Expose-Headers", "Mcp-Session-Id") so browser JS can actually read the session id via response.headers.get("Mcp-Session-Id").

GET handler: added apply_additional_response_headers(res) at the top so the 405 response carries CORS. Updated the lambda capture from [] to [this] accordingly.

OPTIONS preflight: broadened Access-Control-Allow-Methods from POST, OPTIONS to GET, POST, DELETE, OPTIONS so browsers accept preflights for all methods the server now supports.

DELETE handler: registered. Best-effort erases the session from sessions_ when a valid Mcp-Session-Id header is present, and responds 204 No Content with the configured CORS headers attached.

No public API changes. No new dependencies. SseServerWrapper is not modified (the same "apply headers mid-handler" anti-pattern exists there, but fixing it is out of scope for this PR).

Reproduction:

Before the patch, starting a StreamableHttpServerWrapper with cors_origin = "*":

# GET -> 405 with NO Access-Control-Allow-Origin header -> browser CORS error.
curl -v -X GET "http://localhost:$PORT/mcp" -H "Origin: http://localhost:8080"

# DELETE -> 404 (httplib default) with NO CORS header -> browser CORS error.
curl -v -X DELETE "http://localhost:$PORT/mcp" -H "Origin: http://localhost:8080" \
    -H "Mcp-Session-Id: somesession"

# OPTIONS preflight for DELETE -> 204 but Access-Control-Allow-Methods: POST, OPTIONS
# -> browser rejects DELETE preflight before even attempting the request.
curl -v -X OPTIONS "http://localhost:$PORT/mcp" \
    -H "Origin: http://localhost:8080" \
    -H "Access-Control-Request-Method: DELETE"

# POST initialize -> 200 with Mcp-Session-Id on the wire, but Access-Control-Expose-Headers
# is missing -> browser JS cannot read the header.
curl -v -X POST "http://localhost:$PORT/mcp" \
    -H "Origin: http://localhost:8080" -H "Content-Type: application/json" \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

After the patch all four responses carry the appropriate CORS headers and the POST response carries Access-Control-Expose-Headers: Mcp-Session-Id.

0xeb pushed a commit that referenced this pull request May 16, 2026
…(PR #42)

Merges PR #42 from @polaon. Squashed from 7 commits for clean history.
Original author preserved; PR author and Copilot credited as co-authors below.

Bugs fixed in src/server/streamable_http_server.cpp:

1. GET /mcp returned 405 without CORS headers (Access-Control-Allow-Origin
   missing). Browsers blocked the response. Fix: call
   apply_additional_response_headers(res) at the top of the GET handler.

2. DELETE /mcp had no handler at all. MCP Streamable HTTP spec (2025-03-26)
   permits DELETE for session termination, but httplib fell through to its
   default 404 with no CORS. Fix: register DELETE handler that erases the
   session from sessions_ when a valid Mcp-Session-Id is present and
   responds 204 No Content with CORS headers.

3. OPTIONS preflight advertised Access-Control-Allow-Methods: POST, OPTIONS
   only, so browsers rejected DELETE preflights pre-emptively. Fix: broaden
   to GET, POST, DELETE, OPTIONS.

4. Mcp-Session-Id response header not exposed to JS. Per CORS, browser JS
   cannot read non-safelisted headers without Access-Control-Expose-Headers.
   The POST handler attached Mcp-Session-Id but never set Expose-Headers,
   so response.headers.get('Mcp-Session-Id') returned null in browser JS.
   Fix: set Access-Control-Expose-Headers: Mcp-Session-Id on the POST path.

5. 401 Unauthorized + other early-return paths in the POST handler emitted
   responses BEFORE apply_additional_response_headers(res). Fix: move the
   apply_additional_response_headers call to the very top of the POST
   lambda, before the auth check and outside the try block, so every
   response path (success, 401, 503, 400, 404, 5xx via catch) carries
   the configured CORS / custom headers unconditionally.

No public API changes. No new dependencies. SseServerWrapper not modified
(same anti-pattern exists there but out of scope for this PR).

CI: all 7 jobs green on PR (ubuntu/macos/windows x Debug+Release + format-check).

Closes PR #42.

Co-authored-by: polaon <107108922+polaon@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@0xeb
Copy link
Copy Markdown
Owner

0xeb commented May 16, 2026

Thanks @polaon! Merged into feature/parity-catchup-d8dcc273 as commit d3269e2 (squashed from your 7 commits with full author attribution preserved). The branch is stacked on feature/parity-catchup-ee48a0fd and will land on main once both stacked PRs are merged.

Verification on the merge result:

  • Full fastmcpp ctest: 103/103 GREEN
  • Deep interop (fastmcp ↔ fastmcpp): 241/241 across 9 scenarios GREEN

No code changes needed on your part; all five bugs you identified are fixed exactly as proposed. Closing this PR in favor of the merged commit.

@0xeb 0xeb closed this May 16, 2026
0xeb added a commit that referenced this pull request May 16, 2026
Parity catch-up: v3.1.0 -> v3.3.1 + community PRs #42 + #43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants