feat(mcp-server): add opt-in HTTP transport with bearer auth#27
feat(mcp-server): add opt-in HTTP transport with bearer auth#27marmar9615-cloud merged 1 commit intomainfrom
Conversation
v0.4.0 implementation PR 2. **stdio remains the default and only runtime transport unless an operator explicitly sets AGENTBRIDGE_TRANSPORT=http.** No HTTP listener is opened in stdio mode. The HTTP transport reuses createMcpServer() — every safety check, confirmation gate, origin pin, idempotency record, and audit redaction is shared with stdio. Refs #22. What lands ---------- - New apps/mcp-server/src/transports/http.ts: - Wraps StreamableHTTPServerTransport from @modelcontextprotocol/sdk behind bearer-token auth (Authorization: Bearer <token>), Origin allowlist (exact URL.origin match, no wildcard, no prefix), loopback-by-default bind, query-string-token rejection, and a request-body size cap. - Endpoint /mcp, JSON responses (no SSE), stateless mode. - Constant-time bearer comparison via crypto.timingSafeEqual with length padding. - Public bind (0.0.0.0 etc.) requires both auth and a non-empty AGENTBRIDGE_HTTP_ALLOWED_ORIGINS or fails closed at startup. - Bearer token never written to stdout, stderr, or any HTTP error body — verified by tests. - Exports createHttpServer(opts) (testable factory) and runHttpServer() (env-driven runtime entry). - apps/mcp-server/src/config.ts extended with: - resolveTransport() — returns "stdio" | "http"; defaults stdio; unknown values fall back to stdio with a stderr warning. - resolveHttpConfig() — parses AGENTBRIDGE_HTTP_HOST (default 127.0.0.1), AGENTBRIDGE_HTTP_PORT (default 3333; range 0–65535; out-of-range clamped with warn), AGENTBRIDGE_HTTP_AUTH_TOKEN (returned as opaque string; never logged), and AGENTBRIDGE_HTTP_ALLOWED_ORIGINS (comma-separated; malformed origins ignored with warn). - LOOPBACK_HOSTS export so the HTTP adapter can detect public bind without duplicating the set. - apps/mcp-server/src/index.ts dispatches on resolveTransport(): http → runHttpServer(); stdio (default) → runStdioServer(). Top-level error handling identical to PR 1. Tests added (174 → 174; +49 cases) ---------------------------------- - apps/mcp-server/src/tests/http-config.test.ts (23 cases) — pins AGENTBRIDGE_TRANSPORT default + HTTP env-var parsing, origin normalization, malformed-origin handling, and the separation between inbound AGENTBRIDGE_HTTP_ALLOWED_ORIGINS and outbound AGENTBRIDGE_ALLOWED_TARGET_ORIGINS. - apps/mcp-server/src/tests/http-transport.test.ts (26 cases) — spins up the real Node http.Server on an ephemeral port and drives auth (missing/malformed/wrong/valid bearer; query string), Origin allowlist (unknown / prefix attack / port mismatch / allowed / no Origin / OPTIONS preflight), host binding (loopback default; non-loopback warning; public bind fail-closed for missing auth or missing origins), endpoint routing (404 for non-/mcp paths; 400 for malformed JSON; 413 for oversized body), and pins that the bearer token NEVER appears in stderr or response bodies. - All existing tests continue to pass: stdio-hygiene 3/3, call-action 10/10, safety 20/20, server-factory 4/4, config 11/11, plus the 92 cross-package suites. Verification ------------ - npm run typecheck:clean — green - npm test — 174/174 green - npm run build — six packages OK; mcp-server bundle 40.3 KB ESM (was 29.7 KB pre-PR2; growth is the new HTTP code path) - npm run pack:dry-run — six tarballs OK at 0.3.0; file counts unchanged - node apps/mcp-server/dist/index.js < /dev/null — clean exit 0 in stdio default mode - node apps/mcp-server/dist/index.js with initialize + tools/list — serverInfo + tool list byte-identical to pre-PR2 stdio binary; stderr empty - HTTP smoke against built dist (port 0 ephemeral): no-auth → 401 unauthorized (missing Authorization) wrong-token → 401 unauthorized (invalid bearer token) query-token → 400 token_in_query_string bad-origin → 403 forbidden_origin auth-init → 200 + serverInfo {agentbridge, 0.3.0} stdout → empty stderr → token never appeared - HTTP startup smoke against built dist: AGENTBRIDGE_TRANSPORT=http (no token) → exit 1, stderr fatal public bind (host=0.0.0.0, no origins, token) → exit 1, stderr fatal Docs ---- - docs/designs/http-mcp-transport-auth.md migration plan §13 marks PR 2 as landed; PR 3 is now a docs/examples/version- bump pass. - docs/security-configuration.md splits universal vs HTTP-only env vars; adds inbound-vs-outbound origin allowlist callout; adds local-dev and non-loopback recipes; replaces the forthcoming-section with implemented-section. - docs/mcp-client-setup.md gains an experimental HTTP-transport section with the local-dev recipe and a fail-closed reminder. - apps/mcp-server/README.md gains an HTTP-transport opt-in block under "Custom or other MCP clients". - apps/mcp-server/src/transports/README.md updated to list stdio + http adapters and to mark PR 1 + PR 2 as landed. Codex PR #25 — avoided ---------------------- Codex's parallel docs/examples PR (#25) merged at 4929f14 while this work was in progress. This branch was rebased cleanly onto the new main; no Codex PR #25 file was modified by this PR. Confirmations ------------- - stdio remains default; no HTTP runs unless AGENTBRIDGE_TRANSPORT=http. - HTTP requires auth; public bind requires both auth and Origin allowlist or fails hard at startup. - Tokens in URL query strings are rejected with 400. - Bearer token is never logged. - Confirmation gate, origin pinning, target-origin allowlist, idempotency, audit redaction, simulated destructive demo actions — all unchanged. - No package versions changed (still 0.3.0). Lockstep bump to 0.4.0 lands with implementation PR 3 per the design's migration plan. - No npm publish, no git tag, no GitHub release. - Dependabot PRs untouched. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb030b5843
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }; | ||
| server.once("error", onError); | ||
| server.once("listening", onListening); | ||
| server.listen(opts.port, opts.host); |
There was a problem hiding this comment.
Normalize bracketed IPv6 bind host before listen
This call passes opts.host straight into server.listen, but resolveHttpConfig/LOOPBACK_HOSTS treats [::1] as a valid loopback host. In Node, bracketed IPv6 is URL syntax, not a valid listen host, so AGENTBRIDGE_HTTP_HOST=[::1] fails startup with ENOTFOUND instead of binding loopback. Either strip brackets before listen or reject bracketed hosts with a clear validation error.
Useful? React with 👍 / 👎.
| if (!authResult.ok) { | ||
| // 401 with WWW-Authenticate; we never echo the supplied or | ||
| // expected token in the body. | ||
| res.setHeader("WWW-Authenticate", 'Bearer realm="agentbridge-mcp"'); | ||
| sendJson(res, 401, { error: "unauthorized", message: authResult.reason }); |
There was a problem hiding this comment.
Include CORS headers on unauthorized responses
For requests from an allowed Origin, this branch returns 401 before any Access-Control-Allow-Origin headers are written (those are only added later on the success path). In browser-based MCP clients, that turns auth failures into generic CORS errors, so callers cannot read the JSON error/status to handle token refresh or diagnose bad credentials. Mirror the allowed-origin CORS headers on 401/other error responses after origin validation passes.
Useful? React with 👍 / 👎.
v0.4.0 implementation PR 2 — opt-in Streamable HTTP MCP transport
with static bearer-token auth, exact-origin allowlist, and
loopback-by-default bind. stdio remains the default and only
runtime transport unless an operator explicitly sets
AGENTBRIDGE_TRANSPORT=http.Refs #22. Design:
docs/designs/http-mcp-transport-auth.md.Predecessor PRs: #23 (design), #24 (transport abstraction).
Codex parallel context: #25 (docs/examples PR) merged while this work was in progress. This branch was rebased cleanly onto the new main; no Codex PR #25 file was modified.
Summary
StreamableHTTPServerTransportfrom@modelcontextprotocol/sdkbehind:Authorization: Bearer <token>only; query-string tokens rejected with400),Originheader allowlist (exactURL.originmatch; no wildcard, no prefix),127.0.0.1); public bind requires both auth and a non-empty Origin allowlist or fails closed at startup,AGENTBRIDGE_MAX_RESPONSE_BYTES),OPTIONSpreflight (only for allowed origins;Access-Control-Allow-Originalways echoes the exact origin — never wildcard with credentials).createMcpServer()factory. Auth/Origin/bind checks sit in front oftransport.handleRequest(); the dispatcher inserver.tsand the safety code insafety.tsstay transport-agnostic.POST /mcp. JSON responses (no SSE in v0.4.0). Stateless mode.Env vars added
AGENTBRIDGE_TRANSPORTstdiostdio|http. Unknown values fall back to stdio with stderr warning.AGENTBRIDGE_HTTP_HOST127.0.0.1AGENTBRIDGE_HTTP_PORT33330= ephemeral (used by tests). Range0–65535.AGENTBRIDGE_HTTP_AUTH_TOKENAGENTBRIDGE_HTTP_ALLOWED_ORIGINSOriginallowlist; required for non-loopback bind.The inbound
AGENTBRIDGE_HTTP_ALLOWED_ORIGINSis independent from the outboundAGENTBRIDGE_ALLOWED_TARGET_ORIGINS. Documented indocs/security-configuration.mdand the design doc.Auth behavior
Authorization→401(withWWW-Authenticate: Bearer realm="agentbridge-mcp").Authorization(e.g.Basic) →401.401. Constant-time compare; tokens length-padded so timing leaks don't reveal length. Response body never echoes either token.?token=,?access_token=,?bearer=,?auth=,?authorization=) →400 token_in_query_string. Rejected before any tool runs.200with the sameserverInfo {agentbridge, 0.3.0}and tools/list as stdio.Origin / CORS behavior
Originnot in allowlist →403 forbidden_origin.Origin(CLI/server clients) → allowed if bearer is valid; noAccess-Control-*headers in the response.Origin→ response includesAccess-Control-Allow-Origin: <exact origin>+Access-Control-Allow-Credentials: true+Vary: Origin. Never*with credentials.OPTIONSpreflight from unknown Origin →403.OPTIONSpreflight from allowed Origin →204with safe CORS headers.Host binding behavior
AGENTBRIDGE_HTTP_HOSTdefaults to127.0.0.1(loopback). No special checks beyond auth.0.0.0.0,10.0.0.5, etc.) is "public bind".validateHttpStartup:AGENTBRIDGE_HTTP_AUTH_TOKEN is required …when token missing,Public bind to <host> requires AGENTBRIDGE_HTTP_ALLOWED_ORIGINS to be set …when origins missing.[agentbridge-mcp-http] non-loopback bind to <host> — auth and Origin allowlist are required and have been verified.Tests added
http-config.test.tsAGENTBRIDGE_TRANSPORTdefault + parsing; HTTP env-var defaults, ranges, clamping; origin normalization; malformed origins; inbound-vs-outbound separation.http-transport.test.tsExisting 125 tests continue to pass: stdio-hygiene 3/3, call-action 10/10, safety 20/20, server-factory 4/4, config 11/11, plus 92 cross-package suites. 174/174 green.
Verification
Confirmations
AGENTBRIDGE_TRANSPORT=http.400.crypto.timingSafeEqual.stdio-hygiene.test.ts3/3 + a fresh JSON-RPC subprocess smoke).0.3.0). Lockstep bump to0.4.0lands with implementation PR 3 per the design's migration plan.4929f14.Test plan
npm run typecheck:cleangreen locally.npm test— 174/174 green locally.npm run build— six packages OK.npm run pack:dry-run— six tarballs OK at 0.3.0; file counts unchanged.Recommended next PR
v0.4.0 implementation PR 3 — HTTP docs/examples/smoke polish + lockstep version bump to 0.4.0, then release readiness.
🤖 Generated with Claude Code