Skip to content

Serve MCP over Streamable HTTP, alongside stdio - #33

Merged
adamjohnwright merged 1 commit into
mainfrom
feat/streamable-http
Sep 14, 2026
Merged

adamjohnwright merged 1 commit into
mainfrom
feat/streamable-http

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

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:http

POST /mcp to initialize and 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. 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 render service in the website repo record it:

crawlers on the old /ContentService/exporter/* URLs are what exhausted Tomcat's heap and took the origin down

An MCP endpoint is the same shape of risk and worse per request, because reactome_analyze_identifiers submits 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, DELETE teardown, host validation.

One worth calling out. The forged-Host test sends through node:http rather than fetch:

× rejects a forged Host header

Host is a forbidden header name, so fetch drops 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 real Host gets 200, so the 403 is about the forged value rather than about node:http requests generally.

What this unblocks

specs/002-transport-and-hosting now carries a concrete brief for the website repository, which already runs this exact pattern: render is a sibling Node service in the same compose file, bound loopback-only, reached through the site's own origin; serve-prod.js reads the proxy table from proxy.conf.js; deploy/apache/beta-chat-proxy.conf shows 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.js entry, an Apache stanza.

Verification

  • 81 → 90 tests, npm run check green
  • stdio sweep: 53 tools called, 16 content expectations checked, no suspicious output
  • verified by hand over real HTTP: initialize issues a session, tools/list returns 53 tools, reactome_search returns results, no-session → 400, unknown session → 400, forged Host → 403, DELETE drops the session count

🤖 Generated with Claude Code

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>
@adamjohnwright
adamjohnwright merged commit 75cc9da into main Sep 14, 2026
4 checks passed
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.

1 participant