Skip to content

feat(mcp-server): add opt-in HTTP transport with bearer auth#27

Merged
marmar9615-cloud merged 1 commit intomainfrom
feature/v040-http-transport-auth
Apr 28, 2026
Merged

feat(mcp-server): add opt-in HTTP transport with bearer auth#27
marmar9615-cloud merged 1 commit intomainfrom
feature/v040-http-transport-auth

Conversation

@marmar9615-cloud
Copy link
Copy Markdown
Owner

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

  • Wraps StreamableHTTPServerTransport from @modelcontextprotocol/sdk behind:
    • bearer-token auth (constant-time compare; Authorization: Bearer <token> only; query-string tokens rejected with 400),
    • Origin header allowlist (exact URL.origin match; no wildcard, no prefix),
    • loopback-by-default bind (127.0.0.1); public bind requires both auth and a non-empty Origin allowlist or fails closed at startup,
    • request-body size cap (reuses AGENTBRIDGE_MAX_RESPONSE_BYTES),
    • OPTIONS preflight (only for allowed origins; Access-Control-Allow-Origin always echoes the exact origin — never wildcard with credentials).
  • Reuses the v0.4.0 PR refactor(mcp-server): prepare transport abstraction for HTTP #24 createMcpServer() factory. Auth/Origin/bind checks sit in front of transport.handleRequest(); the dispatcher in server.ts and the safety code in safety.ts stay transport-agnostic.
  • Endpoint: POST /mcp. JSON responses (no SSE in v0.4.0). Stateless mode.

Env vars added

Env var Default Purpose
AGENTBRIDGE_TRANSPORT stdio stdio | http. Unknown values fall back to stdio with stderr warning.
AGENTBRIDGE_HTTP_HOST 127.0.0.1 Bind interface. Loopback default; non-loopback is "public bind" with extra validation.
AGENTBRIDGE_HTTP_PORT 3333 TCP port. 0 = ephemeral (used by tests). Range 065535.
AGENTBRIDGE_HTTP_AUTH_TOKEN unset Required for http mode. Static bearer token. ≥ 16 chars. Never logged, never echoed.
AGENTBRIDGE_HTTP_ALLOWED_ORIGINS unset Comma-separated inbound Origin allowlist; required for non-loopback bind.

The inbound AGENTBRIDGE_HTTP_ALLOWED_ORIGINS is independent from the outbound AGENTBRIDGE_ALLOWED_TARGET_ORIGINS. Documented in docs/security-configuration.md and the design doc.

Auth behavior

  • Missing Authorization401 (with WWW-Authenticate: Bearer realm="agentbridge-mcp").
  • Malformed Authorization (e.g. Basic) → 401.
  • Wrong bearer token → 401. Constant-time compare; tokens length-padded so timing leaks don't reveal length. Response body never echoes either token.
  • Token in URL query string (?token=, ?access_token=, ?bearer=, ?auth=, ?authorization=) → 400 token_in_query_string. Rejected before any tool runs.
  • Valid bearer + initialize → 200 with the same serverInfo {agentbridge, 0.3.0} and tools/list as stdio.
  • The bearer token never appears in stderr, response bodies, or thrown error messages.

Origin / CORS behavior

  • Request with Origin not in allowlist → 403 forbidden_origin.
  • Request with no Origin (CLI/server clients) → allowed if bearer is valid; no Access-Control-* headers in the response.
  • Allowed Origin → response includes Access-Control-Allow-Origin: <exact origin> + Access-Control-Allow-Credentials: true + Vary: Origin. Never * with credentials.
  • OPTIONS preflight from unknown Origin → 403.
  • OPTIONS preflight from allowed Origin → 204 with safe CORS headers.
  • Prefix-attack and port-mismatch Origins are rejected (covered by tests).

Host binding behavior

  • AGENTBRIDGE_HTTP_HOST defaults to 127.0.0.1 (loopback). No special checks beyond auth.
  • Non-loopback host (0.0.0.0, 10.0.0.5, etc.) is "public bind". validateHttpStartup:
    • throws AGENTBRIDGE_HTTP_AUTH_TOKEN is required … when token missing,
    • throws Public bind to <host> requires AGENTBRIDGE_HTTP_ALLOWED_ORIGINS to be set … when origins missing.
  • Successful non-loopback start emits a one-line stderr notice: [agentbridge-mcp-http] non-loopback bind to <host> — auth and Origin allowlist are required and have been verified.

Tests added

File Cases Covers
http-config.test.ts 23 AGENTBRIDGE_TRANSPORT default + parsing; HTTP env-var defaults, ranges, clamping; origin normalization; malformed origins; inbound-vs-outbound separation.
http-transport.test.ts 26 Real HTTP server on ephemeral port. Auth (missing/malformed/wrong/valid + query-string + token-never-leaks). Origin (unknown / prefix attack / port mismatch / allowed / no-Origin / OPTIONS). Host binding (loopback default; non-loopback warning; public bind fail-closed). Endpoint routing (404 / 400 / 413).

Existing 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

npm run typecheck:clean   # green
npm test                  # 174/174 green (125 prior + 23 http-config + 26 http-transport)
npm run build             # six packages OK; mcp-server bundle 40.3 KB ESM
npm run pack:dry-run      # six tarballs OK at 0.3.0; file counts unchanged
node apps/mcp-server/dist/index.js < /dev/null   # stdio: clean exit 0
# stdio JSON-RPC smoke (initialize + tools/list) — serverInfo + tool list
#   byte-identical to pre-PR2 binary; stderr empty.
# HTTP smoke (port 0 ephemeral; tests live in /tmp/http-smoke.mjs locally):
#   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}; stderr never contained the token.
# HTTP startup safety:
#   AGENTBRIDGE_TRANSPORT=http (no token) → exit 1, fatal: "AGENTBRIDGE_HTTP_AUTH_TOKEN is required"
#   public bind without origins           → exit 1, fatal: "Public bind ... requires AGENTBRIDGE_HTTP_ALLOWED_ORIGINS"

Confirmations

  • ✅ stdio remains the default. No HTTP runs unless AGENTBRIDGE_TRANSPORT=http.
  • ✅ HTTP requires auth; public bind requires both auth and Origin allowlist.
  • ✅ Tokens in URL query strings rejected with 400.
  • ✅ Bearer token never logged, never echoed, never in error messages.
  • ✅ Constant-time bearer comparison via crypto.timingSafeEqual.
  • ✅ Confirmation gate, origin pinning (outbound), target-origin allowlist, idempotency, audit redaction, simulated destructive demo actions — all unchanged.
  • ✅ Stdout hygiene preserved in stdio mode (verified by stdio-hygiene.test.ts 3/3 + a fresh JSON-RPC subprocess smoke).
  • 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.
  • Codex PR docs: add adopter quickstart and manifest patterns #25 untouched. This branch was rebased cleanly onto 4929f14.

Test plan

  • npm run typecheck:clean green 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.
  • stdio smoke against built dist — bit-identical to pre-PR2.
  • HTTP smoke against built dist — auth, origin, query-token, public-bind invariants verified end-to-end.
  • CI green on Node 20.x and 22.x.
  • Main CI green after merge.
  • release-check green on main.

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

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>
@marmar9615-cloud marmar9615-cloud merged commit 21488a5 into main Apr 28, 2026
2 checks passed
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +194 to +198
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 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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