feat: add Streamable HTTP transport alongside stdio - #18
Open
jethro-hall wants to merge 1 commit into
Open
jethro-hall wants to merge 1 commit into
jethro-hall wants to merge 1 commit into
Conversation
The only entry point was stdio, so the server could not be hosted behind a reverse proxy: anyone wanting one shared instance had to fork the package or run a third-party stdio-to-HTTP bridge as a subprocess. `createOrcaRouterMcpServer` was already exported and transport-agnostic, so this wires it to the SDK's own `StreamableHTTPServerTransport` — same tools, same API client, no subprocess. stdio stays the default; `--transport http` (or `MCP_TRANSPORT=http`) opts in, so existing client configs are untouched. Defaults follow the transport spec's security guidance: - binds 127.0.0.1 rather than all interfaces, so exposing it is deliberate; - validates Host and Origin on every request and answers 403 otherwise, which is what prevents DNS rebinding from a browser page. `--allowed-hosts` and `--allowed-origins` widen the allowlist for a hosted deployment, and `--no-dns-rebinding-protection` is available where a trusted proxy already does the check. Sessions are on by default, with Mcp-Session-Id, DELETE termination and removal on close so the registry cannot grow unbounded. `--stateless` builds one server per request for replicas behind a load balancer, which costs nothing here because every tool is a single round trip to a stateless API. MCP_HTTP_AUTH_TOKEN optionally requires a shared secret as Authorization: Bearer or X-API-Key, compared in constant time. OAuth 2.1, the spec's answer for public endpoints, is not implemented; the README says so and says to put a proxy in front. GET /healthz reports liveness unauthenticated and without calling the OrcaRouter API, so probes never consume the operator's quota. CLI parsing lives in src/cli.ts rather than src/index.ts because index.ts runs main() on import and could not otherwise be tested. Unknown arguments are an error rather than a silent no-op, so a typo in a launcher config fails at start instead of leaving a server listening somewhere unintended. 39 tests added covering the handshake, session lifecycle, rebinding protection, auth, stateless mode and argument parsing. Co-Authored-By: Claude Opus 5 (1M context) <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.
Why
Today the only entry point is stdio, which suits a client that launches the
server as a subprocess. It rules out the other deployment: hosting one
instance and pointing several clients at it. A reverse proxy cannot sit in
front of a process that talks over a pipe, so anyone wanting that has to fork
the package or run a third-party stdio-to-HTTP bridge (supergateway, mcp-proxy)
as a supervised subprocess.
That seemed a shame given
createOrcaRouterMcpServeris already exported andtransport-agnostic. This wires it to the SDK's own
StreamableHTTPServerTransport— same tools, same API client, no subprocess,no new dependency.
I hit this self-hosting the server behind Caddy and would rather contribute
the fix than keep a patch downstream.
What changed
--transport http(orMCP_TRANSPORT=http) serves theStreamable HTTP
transport on one endpoint handling
POST,GETandDELETE.stdio remains the default, so no existing client config changes.
--transport <stdio|http>MCP_TRANSPORTstdio--port <n>PORT3000--host <addr>HOST127.0.0.1--path <path>MCP_HTTP_PATH/mcp--statelessMCP_HTTP_STATELESS--allowed-hosts <a,b>MCP_HTTP_ALLOWED_HOSTS--allowed-origins <a,b>MCP_HTTP_ALLOWED_ORIGINS--no-dns-rebinding-protectionMCP_HTTP_AUTH_TOKENAlso adds
--help, anexamples/http.json, and a Dockerfile note that thesame image serves either transport.
Security defaults
The spec's transport section is explicit, and the defaults follow it:
127.0.0.1, not0.0.0.0— "servers SHOULD bind only to localhost… rather than all network interfaces". Exposing it is a deliberate act.
HostandOriginon every request,403otherwise —"servers MUST validate the
Originheader on all incoming connections toprevent DNS rebinding attacks".
MCP_HTTP_AUTH_TOKENis an optional shared secret (Authorization: BearerorX-API-Key, constant-time compare). OAuth 2.1 is the spec'sanswer for public endpoints and is not implemented here; the README says
so and says to put a proxy in front.
GET /healthzis unauthenticated and makes no upstream call, so aliveness probe never spends the operator's OrcaRouter quota.
Sessions
Stateful by default:
Mcp-Session-Idat initialize,DELETEtermination,404for an unknown id so the client reinitializes, and removal from theregistry on close (via both
onsessionclosedandtransport.onclose) so itcannot grow unbounded.
--statelessbuilds one server per request and issues no session id. Everytool here is a single round trip to a stateless API, so nothing is lost, and
it allows replicas behind a load balancer with no sticky routing.
Notes for review
src/cli.tsrather thansrc/index.ts, becauseindex.tsrunsmain()on import and could not otherwise be unit-tested.config fails at start rather than leaving a server listening somewhere
unintended.
Hostallowlist is resolved on first request, not at startup,so
port: 0(ephemeral) works — computing it from the requested portproduced an allowlist naming port
0and rejected everything.README.mdis updated. Happy to do the 11 translations, or leave themto whatever process normally keeps them in sync — say which you prefer.
scope. It is ~20 lines and opt-in; I can drop it if you would rather keep
auth entirely to the proxy.
Testing
npm run typecheck,npm test,npm run buildall pass — 155 tests, 39new, no existing test modified:
tools/listover an established session, custom pathDELETE→ forgottenHost→ 403, allowlisted → 200, unlistedOrigin→403, allowlisted → 200, protection disabled → 200
(the
Hostcases go throughnode:http, sincefetchsilently drops aHostoverride and a fetch-based test would assert nothing)WWW-Authenticate, wrong → 401, bearer → 200,X-API-Key→ 200-32700, oversize → 413Smoke-tested end to end:
npxthe built binary with--transport http,connect a client, list tools, call a catalog tool.
🤖 Generated with Claude Code