Skip to content

feat!: streamable HTTP transport (hostable) + remove OIDC browser auth#15

Open
souf92i wants to merge 3 commits into
masterfrom
streamable-http-mcp
Open

feat!: streamable HTTP transport (hostable) + remove OIDC browser auth#15
souf92i wants to merge 3 commits into
masterfrom
streamable-http-mcp

Conversation

@souf92i

@souf92i souf92i commented Jun 30, 2026

Copy link
Copy Markdown
Member

Summary

Adds a hostable streamable HTTP transport to horizon-mcp alongside the existing stdio transport, so one MCP instance can be hosted next to its Horizon and shared by multiple client sessions. Single-tenant by design: one MCP per Horizon, HORIZON_URL always from env, never client-supplied (no multi-tenant routing, no SSRF surface).

Transport is selected by HORIZON_TRANSPORT (stdio default, or http). HTTP mode runs an Express server on a single endpoint (default /mcp, POST/GET/DELETE with SSE responses) backed by the SDK StreamableHTTPServerTransport, with full per-caller session isolation.

Authentication (Horizon stays the authority)

The MCP never makes authorization decisions; it forwards a Horizon credential and Horizon applies that principal's RBAC. HORIZON_HTTP_AUTH_MODE fixes one of:

  • service - the MCP holds one env credential (API key or mTLS to Horizon) and acts as a single identity for all callers. No per-caller fingerprint binding (the session id is a bearer), so the front door must be access-controlled by network placement or an authenticating edge; use a least-privileged identity.
  • api-key (per caller) - the client sends its own X-API-ID / X-API-KEY, forwarded to Horizon.
  • mtls (per caller, terminate-and-forward, the focus) - the client presents a TLS client cert; the MCP (or a trusted ingress) terminates the TLS with optional_no_ca semantics (proving possession, not validating the chain) and forwards the cert to Horizon's Play backend in HORIZON_FORWARD_CERT_HEADER (default SSL_CLIENT_CERT). Horizon validates the chain, revocation, and identity. Forwards no long-lived secret. Most MCP clients cannot present a client cert, so a local mTLS proxy is documented.

BREAKING CHANGE

OIDC browser login (Playwright) is removed in all transports, stdio included. Deployments that relied on it must switch to an API key or mTLS. HORIZON_CLIENT_* and HORIZON_API_* are unchanged. A headless OIDC bearer flow is deferred until Horizon supports a forwardable token. The playwright dependency, --external playwright build flags, and HORIZON_LOGIN_TIMEOUT are gone.

Security and isolation

  • One HorizonClient + McpServer per session; ALS-routed per-session logging (fixes the prior module-level sink that would leak logs across concurrent sessions).
  • Per-caller credential fingerprint binding (HMAC-SHA-256, per-process key, timing-safe); a session id replayed with a different credential is rejected.
  • Secret headers scrubbed from BOTH req.headers and req.rawHeaders before the SDK reads them (the SDK builds the Web request from rawHeaders via @hono/node-server), so credentials never surface in tool requestInfo.
  • Teardown closes exactly one MCP primitive (the SDK cascades server<->transport), guarded against double-close; DELETE and idle/absolute TTL paths are idempotent; SIGTERM drains and tears down all sessions.
  • Fail-closed startup: a non-loopback bind without HORIZON_PUBLIC_URL/HORIZON_TRUSTED_HOSTS refuses to start; mTLS topology, header names, public URL, origins, and trusted-proxy CIDR are all validated; HORIZON_ALLOW_PRIVATE_TLS_PROBE=1 is refused in HTTP mode.
  • Host/Origin/CORS middleware, body-size limit, per-session and pre-session rate limits, max sessions, and in-flight tool-call quotas.

Adversarial review

A multi-dimension adversarial review was run against the implementation; all 10 confirmed findings are fixed in this PR, including a HIGH resource leak (an initialize the SDK rejected after the client/auth were built was never tracked or reclaimed), a maxSessions TOCTOU, per-message rate accounting, an unbounded rate-limiter map, and config/credential validation hardening.

Docs

README Transports section + full HTTP config table + auth modes + breaking-change note; docs/authentication.md; docs/client-setup.md walkthrough for Claude Code / Claude Desktop / Codex (stdio + http, plus the mTLS local-proxy note); .env.example; a Dockerfile + .dockerignore. Corrected the stale tool count to 211 tools across 12 domains (the config-CRUD suite was missing from the README counts since the last PR).

Test plan

  • bun run format:check, bun run lint, bun run typecheck
  • bun run build (express stays external; built bundle boots, /healthz -> 200, bad Host -> 421)
  • bun run test - 1006 unit tests pass, including a real-HTTP integration suite (two-session identity isolation, no-credential rejection, session-id-replay rejection, service-mode credential rejection, teardown on close, and the resource-leak fix)
  • bun run verify:truth, bun run test:scenarios (offline LLM eval)
  • bun run test:e2e / bun run test:llm:live against QA (needs .env.local) - recommended before merge
  • Manual smoke of an HTTP deployment in each auth mode

Note: bun run docs:diff reports pre-existing drift in src/generated/docs/*.json (live Horizon doc snapshots), unrelated to this change.

Comment thread src/http/fingerprint.ts Dismissed
Comment thread src/http/server.ts Fixed
Comment thread src/http/server.ts Fixed
Comment thread src/http/server.ts Dismissed
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