Skip to content

Commit 864434d

Browse files
feat(mcp-bridge): Streamable HTTP transport (epic #87 item 14, PR1) (#105)
## Summary PR1 of 2 implementing **ADR-0013** — Streamable HTTP transport alongside the existing stdio bridge. `BOJ_TRANSPORT=stdio` (default) is unchanged. `BOJ_TRANSPORT=http` exposes `POST /mcp` + `GET /mcp` (SSE) + `DELETE /mcp` + `GET /healthz`, with per-session UUIDs in `Mcp-Session-Id` and bearer / loopback-only-`none` auth. Same `hardeningGate`, same tool surface, zero new dependencies (built on `Deno.serve` / `node:http`). ## What's in - **`mcp-bridge/lib/http-transport.js`** — listener, session manager (30-min idle expiry, SSE-fan-out seam for ADR-0011), bearer / none auth, refuse-to-start on `auth=none` + non-loopback bind. Deno + Node parity. Zero deps. - **`mcp-bridge/lib/dispatcher.js`** — transport-neutral JSON-RPC dispatch core extracted from `main.js`. Both stdio (`main.js`) and HTTP (`http-transport.js`) call it; the hardening gate runs once, in the dispatcher. - **`mcp-bridge/main.js`** — `BOJ_TRANSPORT=stdio|http|both` selector. Stdio path preserved exactly; HTTP path delegates to the transport module; SIGINT / SIGTERM graceful shutdown. - **`boj://capabilities/deployment` resource** — per-deployment cartridge availability flagging the five host-local-only cartridges (`browser-mcp`, `container-mcp`, `local-coord-mcp`, `sandbox-mcp`, `ffmpeg-mcp`). Clients can detect Worker-incompatible capabilities up front instead of at first failed invoke. (Static list in PR1; PR2 will derive from per-cartridge `requires_local` flags.) - **`glama.json`** env-var declarations: `BOJ_TRANSPORT`, `BOJ_HTTP_PORT` (default `7780`), `BOJ_HTTP_BIND`, `BOJ_HTTP_AUTH`, `BOJ_HTTP_AUTH_TOKENS`. - **`mcp-bridge/tests/http_transport_test.js`** — 26 new tests (session manager, auth modes, parse / size / unknown-session errors, end-to-end POST flow, bearer reject/accept, DELETE teardown, healthz, deployment resource). - **CHANGELOG** entry under Unreleased. ## What's deferred to PR 2 Explicitly owed; tracked here: - **mTLS auth** (`BOJ_HTTP_AUTH=mtls`) — `configFromEnv` rejects this mode with a clear "owed in PR2" message. - **OIDC auth** (`BOJ_HTTP_AUTH=oidc`) — same. - **Cloudflare Workers** deployment guide (`docs/deployment/CLOUDFLARE-WORKERS.md`). - **Durable-Object wrapper** around the session manager. - **Static cartridge-manifest bundling** for cold-start. - **Per-cartridge `requires_local`** flag plumbed from each `cartridge.json` (the deployment resource is static in PR1). - **`wrangler.toml`** example. ## Test plan - [x] `node --test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js` — **41/41 pass** (baseline 15 + 26 new) - [x] `deno test --allow-net --allow-env --allow-read mcp-bridge/tests/` — **41/41 pass** - [x] stdio smoke unchanged on Deno + Node (`echo '{...initialize...}' | deno run ...` / `... | node ...`) - [x] `BOJ_TRANSPORT=http BOJ_HTTP_PORT=7780 ... main.js &` + `curl -X POST http://localhost:7780/mcp ...` returns `Mcp-Session-Id` header on Deno + Node - [x] `BOJ_HTTP_AUTH=bearer BOJ_HTTP_AUTH_TOKENS=...` → 401 with no/bad token, 200 with valid token - [x] `BOJ_HTTP_AUTH=none BOJ_HTTP_BIND=0.0.0.0` refuses to start, logs error, exits 1 - [x] SSE `GET /mcp` returns `event: ready` with session id on connect ## Sequencing notes - **No stdio refactor in user-visible behaviour.** The dispatcher extraction is a pure refactor of how `handleMessage` is structured; every existing test still passes. The only observable change is that an uncaught dispatch exception now yields a `-32603` JSON-RPC error response instead of being silently swallowed by the top-level `.catch(() => {})` — strictly an improvement. - **Single bridge process, two transports** (per ADR-0002 BoJ-only MCP rule). No sub-bridge or sub-server pattern. - **ADR-0013 open questions** — resolved with defensible defaults in this PR: 1. **Session storage** — ephemeral in-memory, 30-min idle expiry. Durable storage owed in PR2. 2. **Cold-start on Workers** — deferred to PR2 (static manifest bundling). 3. **SSE stream lifetime** — keepalive ping every 25 s; clients reconnect on close. Workers-specific stream limits documented in PR2. 4. **Backpressure** — current `hardeningGate` per-process rate limit applies; per-session rate limiting and global queue depth are PR2. 5. **TLS termination** — bridge listens HTTP on loopback by default; reverse-proxy assumed (documented). Direct-TLS opt-in via PR2. 6. **Cowboy listener consolidation** — PR1 keeps the bridge on its own port (`7780`); same-port path-routing to the Cowboy listener (`7700`) tracked as a future consolidation in ADR-0013. Refs #87 (item 14), ADR-0013. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 899cbc8 commit 864434d

7 files changed

Lines changed: 1426 additions & 530 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ All notable changes to Bundle of Joy Server are documented here.
2929

3030
### Added
3131

32+
- **Streamable HTTP transport (ADR-0013, PR1 of 2)** — MCP bridge now selects
33+
between stdio (default), `http`, and `both` via `BOJ_TRANSPORT`. HTTP
34+
endpoints: `POST /mcp` for JSON-RPC, `GET /mcp` for the server-initiated
35+
SSE notifications stream, `DELETE /mcp` for explicit session teardown,
36+
`GET /healthz` for liveness. Sessions are server-issued UUIDs in the
37+
`Mcp-Session-Id` header; the manager expires idle sessions after 30 min
38+
and fans events out across attached SSE streams. Auth: `none` (loopback
39+
only — refuses non-loopback binds) or `bearer` (token list via
40+
`BOJ_HTTP_AUTH_TOKENS`). The same `hardeningGate` runs on every request.
41+
Zero new deps — built on `Deno.serve` and `node:http`. mTLS / OIDC auth
42+
and the Cloudflare Workers / Durable-Objects shim are owed in PR2.
43+
- **`boj://capabilities/deployment` resource** — reports per-deployment
44+
cartridge availability so clients can avoid invoking host-local-only
45+
cartridges (browser-mcp, container-mcp, local-coord-mcp, sandbox-mcp,
46+
ffmpeg-mcp) against a Worker / remote-HTTP deployment.
3247
- **k9iser-mcp cartridge** — reference implementation of the `-iser`
3348
regeneration-cartridge pattern (central K9 contract regeneration), mirroring
3449
ssg-mcp: `cartridge.json`, `mod.js`, Idris2 ABI, Zig FFI, panels.

glama.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,30 @@
7676
"OTEL_EXPORTER_OTLP_HEADERS": {
7777
"type": "string",
7878
"description": "Optional comma-separated key=value headers attached to OTLP export POSTs (e.g. 'authorization=Bearer xyz,x-honeycomb-team=abc'). Used for hosted collectors that require auth."
79+
},
80+
"BOJ_TRANSPORT": {
81+
"type": "string",
82+
"description": "MCP transport selection (ADR-0013). 'stdio' (default) reads JSON-RPC from stdin and writes to stdout — the protocol clients like Claude Code launch the bridge as a subprocess over. 'http' starts an HTTP+SSE listener on BOJ_HTTP_PORT for remote / Workers / browser deployments. 'both' runs both simultaneously.",
83+
"default": "stdio"
84+
},
85+
"BOJ_HTTP_PORT": {
86+
"type": "string",
87+
"description": "TCP port for the HTTP transport (used only when BOJ_TRANSPORT=http or BOJ_TRANSPORT=both).",
88+
"default": "7780"
89+
},
90+
"BOJ_HTTP_BIND": {
91+
"type": "string",
92+
"description": "Bind address for the HTTP transport. Defaults to 127.0.0.1 (loopback only). Set to 0.0.0.0 for remote access — BOJ_HTTP_AUTH=none is refused on non-loopback binds.",
93+
"default": "127.0.0.1"
94+
},
95+
"BOJ_HTTP_AUTH": {
96+
"type": "string",
97+
"description": "Authentication mode for the HTTP transport. 'none' is permitted only on loopback (refused otherwise). 'bearer' requires Authorization: Bearer <token> against the BOJ_HTTP_AUTH_TOKENS list. 'mtls' and 'oidc' are owed in PR2 of ADR-0013 — not yet implemented.",
98+
"default": "none"
99+
},
100+
"BOJ_HTTP_AUTH_TOKENS": {
101+
"type": "string",
102+
"description": "Comma-separated list of accepted bearer tokens when BOJ_HTTP_AUTH=bearer. Whitespace around each token is trimmed; empty entries dropped. Required when BOJ_HTTP_AUTH=bearer."
79103
}
80104
}
81105
}

0 commit comments

Comments
 (0)