Serve MCP over Streamable HTTP, alongside stdio - #33
Merged
Merged
Conversation
A reverse proxy cannot front a process that talks over stdin/stdout, so there was nothing to host. This is the blocker for putting an instance behind the Reactome site, and it is why createServer() was split out of the stdio entrypoint in #28. stdio is unchanged and stays the default: every existing user has a client configured to spawn `dist/index.js`, and that keeps working exactly as before. The live sweep -- which drives the server over stdio -- still calls all 53 tools with no suspicious output. MCP_HTTP_PORT=4320 node dist/http-server.js POST /mcp to initialize and then to send requests, GET /mcp for the server stream, DELETE /mcp to end a session, GET /health for whatever fronts it. **One server per session.** Sharing an instance would let two clients' in-flight requests interleave on shared state; createServer() exists so building one per session is cheap. **Sessions cannot accumulate without bound.** A client that never sends DELETE would otherwise leave a server behind for the life of the process, so idle sessions are reaped (MCP_SESSION_TTL_MS, 30 min) and concurrency is capped (MCP_MAX_SESSIONS, 256; over it, 503 rather than a malformed-request error, so a proxy can tell load from a bad client). **It binds 127.0.0.1 unless told otherwise**, which also turns on the SDK's DNS-rebinding protection -- what stops a page in someone's browser from driving a server on their own loopback. The default is deliberate, not a placeholder: the Reactome origin has already been taken down once by crawlers on the public /ContentService/exporter/* URLs, which is why the sibling render service on that box is loopback-only and reached through the site's origin. An MCP endpoint is the same shape of risk and worse per request, because analyze_identifiers submits a real job to the Analysis Service. Nine tests drive a real server on an ephemeral port: session issue and refusal, the full tool list over HTTP, per-session isolation, DELETE teardown, and host validation. The forged-Host test sends through node:http rather than fetch -- Host is a forbidden header name, so fetch drops an override silently and the first version of that test was passing a value it never sent. It would have passed with the protection turned off. Spec 002 records the transport as settled and carries the brief for the website repository, which already runs this exact pattern in its render service. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A reverse proxy cannot front a process that talks over stdin/stdout, so there was nothing to host. This is the blocker for putting an instance behind the Reactome site, and it is why
createServer()was split out of the stdio entrypoint in #28.Settles D1 in
specs/002-transport-and-hosting.stdio is unchanged
Every existing user has a client configured to spawn
dist/index.js, and that keeps working exactly as before. The live sweep — which drives the server over stdio — still calls all 53 tools with no suspicious output.MCP_HTTP_PORT=4320 node dist/http-server.js # or: npm run start:httpPOST /mcpto initialize and send requests,GET /mcpfor the server stream,DELETE /mcpto end a session,GET /healthfor whatever fronts it.One server per session
Sharing an instance would let two clients' in-flight requests interleave on shared state.
createServer()exists so building one per session is cheap.Sessions cannot accumulate without bound
A client that never sends
DELETEwould otherwise leave a server behind for the life of the process. Idle sessions are reaped (MCP_SESSION_TTL_MS, 30 min) and concurrency is capped (MCP_MAX_SESSIONS, 256). Over the cap it answers 503, not a malformed-request error, so a proxy can tell load from a bad client.It binds 127.0.0.1 unless told otherwise
This is a deliberate default, not a placeholder. The Reactome origin has already been taken down once — the comments on this repo's sibling
renderservice in the website repo record it:An MCP endpoint is the same shape of risk and worse per request, because
reactome_analyze_identifierssubmits a real job to the Analysis Service. Loopback binding also turns on the SDK's DNS-rebinding protection, which is what stops a page in someone's browser driving a server on their own machine.Tests
Nine, driving a real server on an ephemeral port — session issue and refusal, the full tool list over HTTP, per-session isolation,
DELETEteardown, host validation.One worth calling out. The forged-
Hosttest sends throughnode:httprather thanfetch:Hostis a forbidden header name, sofetchdrops an override silently. The first version of that test was asserting on a header it never sent, and would have passed with the protection turned off. There is now a matching test proving a realHostgets 200, so the 403 is about the forged value rather than aboutnode:httprequests generally.What this unblocks
specs/002-transport-and-hostingnow carries a concrete brief for the website repository, which already runs this exact pattern:renderis a sibling Node service in the same compose file, bound loopback-only, reached through the site's own origin;serve-prod.jsreads the proxy table fromproxy.conf.js;deploy/apache/beta-chat-proxy.confshows how a service gets a path on beta.So that work is three small things rather than a design exercise — a compose service, a
proxy.conf.jsentry, an Apache stanza.Verification
npm run checkgreeninitializeissues a session,tools/listreturns 53 tools,reactome_searchreturns results, no-session → 400, unknown session → 400, forged Host → 403,DELETEdrops the session count🤖 Generated with Claude Code