Skip to content

feat: add Streamable HTTP transport alongside stdio - #18

Open
jethro-hall wants to merge 1 commit into
Continuum-AI-Corp:mainfrom
jethro-hall:feat/streamable-http-transport
Open

jethro-hall wants to merge 1 commit into
Continuum-AI-Corp:mainfrom
jethro-hall:feat/streamable-http-transport

Conversation

@jethro-hall

Copy link
Copy Markdown

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 createOrcaRouterMcpServer is already exported and
transport-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 (or MCP_TRANSPORT=http) serves the
Streamable HTTP
transport on one endpoint handling POST, GET and DELETE.
stdio remains the default, so no existing client config changes.

Flag Env Default
--transport <stdio|http> MCP_TRANSPORT stdio
--port <n> PORT 3000
--host <addr> HOST 127.0.0.1
--path <path> MCP_HTTP_PATH /mcp
--stateless MCP_HTTP_STATELESS off
--allowed-hosts <a,b> MCP_HTTP_ALLOWED_HOSTS bound host
--allowed-origins <a,b> MCP_HTTP_ALLOWED_ORIGINS bound host
--no-dns-rebinding-protection — off
— MCP_HTTP_AUTH_TOKEN unset

Also adds --help, an examples/http.json, and a Dockerfile note that the
same image serves either transport.

Security defaults

The spec's transport section is explicit, and the defaults follow it:

  • Binds 127.0.0.1, not 0.0.0.0 — "servers SHOULD bind only to localhost
    … rather than all network interfaces". Exposing it is a deliberate act.
  • Validates Host and Origin on every request, 403 otherwise —
    "servers MUST validate the Origin header on all incoming connections to
    prevent DNS rebinding attacks".
  • MCP_HTTP_AUTH_TOKEN is an optional shared secret (Authorization: Bearer or X-API-Key, constant-time compare). OAuth 2.1 is the spec's
    answer for public endpoints and is not implemented here; the README says
    so and says to put a proxy in front.
  • GET /healthz is unauthenticated and makes no upstream call, so a
    liveness probe never spends the operator's OrcaRouter quota.

Sessions

Stateful by default: Mcp-Session-Id at initialize, DELETE termination,
404 for an unknown id so the client reinitializes, and removal from the
registry on close (via both onsessionclosed and transport.onclose) so it
cannot grow unbounded.

--stateless builds one server per request and issues no session id. Every
tool 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

  • CLI parsing is in a new src/cli.ts rather than src/index.ts, because
    index.ts runs main() on import and could not otherwise be unit-tested.
  • Unknown arguments throw instead of being ignored, so a typo in a launcher
    config fails at start rather than leaving a server listening somewhere
    unintended.
  • The default Host allowlist is resolved on first request, not at startup,
    so port: 0 (ephemeral) works — computing it from the requested port
    produced an allowlist naming port 0 and rejected everything.
  • Only README.md is updated. Happy to do the 11 translations, or leave them
    to whatever process normally keeps them in sync — say which you prefer.
  • The optional auth token is the one piece I could see you wanting out of
    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 build all pass — 155 tests, 39
new
, no existing test modified:

  • handshake, tools/list over an established session, custom path
  • session lifecycle: unknown id → 404, non-initialize first request → 400,
    DELETE → forgotten
  • rebinding: unlisted Host → 403, allowlisted → 200, unlisted Origin →
    403, allowlisted → 200, protection disabled → 200
    (the Host cases go through node:http, since fetch silently drops a
    Host override and a fetch-based test would assert nothing)
  • auth: missing → 401 with WWW-Authenticate, wrong → 401, bearer → 200,
    X-API-Key → 200
  • stateless: no session id issued, independent requests
  • body handling: malformed JSON → -32700, oversize → 413
  • argument parsing: precedence, validation, rejection of unknown flags

Smoke-tested end to end: npx the built binary with --transport http,
connect a client, list tools, call a catalog tool.

🤖 Generated with Claude Code

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