Skip to content

feat(mcp): harden HTTP transport (help text, auth logging, timeouts, bind warning) (§2)#2607

Merged
devarismeroxa merged 1 commit into
mainfrom
feat/mcp-http-hardening
Jul 13, 2026
Merged

feat(mcp): harden HTTP transport (help text, auth logging, timeouts, bind warning) (§2)#2607
devarismeroxa merged 1 commit into
mainfrom
feat/mcp-http-hardening

Conversation

@devarismeroxa

Copy link
Copy Markdown
Contributor

Summary

Implements the H-1..H-4 must-fix hardening items and the missing-coverage tests identified in
docs/design-documents/20260712-mcp-http-transport.md
so the already-merged conduit mcp --http transport (#2591) can ship in v0.17 labeled
experimental per that doc's recommendation. This is a hardening pass on existing, already-tested
code (cmd/conduit/root/mcp/http.go, mcp.go) — no re-implementation, no new transport behavior,
no rate limiting/mTLS/per-agent tokens (explicitly deferred to v0.18 by the design doc).

Roadmap: Phase-1 execution plan §2, "HTTP transport security: authn (token) + TLS for the HTTP MCP
surface; documented."

Must-fixes implemented

  • H-1 (AC-11 / SR-1): --http's usage string and Docs().Long claimed HTTP is served "in
    addition to stdio" — false; Execute serves HTTP-only in --http mode (a daemon has no attached
    stdin to co-serve stdio from). Text now says "INSTEAD OF stdio" and marks the transport
    EXPERIMENTAL, pointing at docs/operations/mcp-server.md.
  • H-2 (AC-10 / AC-8): every rejected (401) HTTP MCP request now logs
    method/path/remote_addr/outcome=unauthorized at warn, and a startup line logs the bound
    address + auth mode at info. Verified by test and by a manual smoke run (see below) that neither
    the configured token nor the presented (wrong) one ever appears in log output.
  • H-3: added IdleTimeout: 120s to the --http server. No WriteTimeout — streamable HTTP can
    legitimately hold a response open while streaming a tool result, so a blanket write deadline would
    be wrong.
  • H-4: warn at startup (warn-level, non-blocking) when --http resolves to a non-loopback
    address (including the all-interfaces :port form). TLS + the bearer token already make this
    safe; the warning exists so a typo'd or defaulted-wide bind doesn't go unnoticed.

Missing-coverage tests added (design doc's AC checklist)

  • AC-1 (cmd/conduit/root/mcp/mcp_test.go): no --http takes the stdio path and never
    constructs an http.Server — proven by cancelling ctx before Execute and asserting it returns
    context.Canceled promptly (the HTTP path would instead fail validateHTTPConfig with a distinct
    error).
  • AC-5: an unauthenticated tools/list JSON-RPC call is 401, not an empty (or any) catalog.
  • AC-6: an empty or whitespace-only --token-file is refused.
  • AC-7: --allow-mutations gates apply/etc. identically over HTTP as over stdio — driven
    end-to-end with a real *sdkmcp.Client over httptest.NewServer, not just an internal registry
    check.
  • AC-8 / AC-10: auth rejections are logged and observable; the security-regression anchor test
    scans captured log output and fails if the real or presented token ever appears in it.
  • AC-12: a real bound --http listener drains cleanly via http.Server.Shutdown on cancellation
    (same call Execute's errgroup makes) and returns http.ErrServerClosed.
  • Plus direct tests for the H-3 IdleTimeout and H-4 non-loopback-warning additions (both the
    newHTTPServer call site and the warnIfNonLoopback helper across loopback/IPv6/hostname/
    all-interfaces address shapes).

Docs

docs/operations/mcp-server.md's "HTTP transport" section rewritten: fixes the same
"in addition to stdio" claim, marks the transport EXPERIMENTAL with a link to the design doc,
documents the new startup/warning/auth-failure log lines and timeouts, and lists what's explicitly
not built yet (rate limiting, per-agent tokens, rotation, cipher pinning) so an operator isn't
surprised. markdownlint-cli2 clean.

No CHANGELOG.md exists in this repo — changelog is generated from the conventional-commit title
per docs/releases.md.

Risk tier

Tier 2 (Features/CLI) — this touches a command/transport surface, not the data path. It can
carry Tier-1 apply calls, but the three independent non-agent-passable gates protecting writes
(--allow-mutations, --api.allow-live-restart-apply, the diff-hash check) are unchanged; this PR
only adds logging/timeouts/text around the existing fail-closed auth gate, it does not touch
mutation authorization logic.

Adversarial self-review

  • Auth path: requireBearerToken's constant-time compare and the 401-before-handler ordering
    are untouched — I only added a logger.Warn(...) call inside the existing if !ok || !constantTimeEqual(...) branch, before the 401 is written. Verified the log call references only
    r.Method/r.URL.Path/r.RemoteAddr — never presented or token. Grepped the diff for both
    identifiers to confirm neither reaches a log call.
  • Fail-closed preserved: validateHTTPConfig/loadBearerToken/tls.LoadX509KeyPair calls and
    their ordering in newHTTPServer are unchanged; I only added warnIfNonLoopback (a log call, no
    control flow effect) and the startup logger.Info call after those checks already passed. Manually
    verified --http :0 with no --token-file/--tls-cert/--tls-key still refuses to start
    (go run ./cmd/conduit mcp --http :0--http requires both --token-file and --tls-cert/--tls-key; refusing to serve HTTP with no auth and no TLS, exit 2) before opening this
    PR.
  • No token leakage in logs: manually ran the built binary with --http, hit it with curl
    presenting no token and a wrong token, and inspected the captured log output directly — confirmed
    rejected MCP HTTP request: missing or invalid bearer token lines carry only
    method/outcome/path/remote_addr, never the token file's contents or the Authorization
    header value. TestRequireBearerToken_RejectionsAreLoggedWithoutLeakingToken encodes this as a
    standing regression test (scans captured log output for both the real and the wrong-guessed token
    string).
  • Logger construction (httpLogger) duplicates pkg/conduit/runtime.go's newLogger rather than
    importing it — MCPCommand doesn't build a conduit.Runtime, and the ecdysis config-parsing
    pipeline does not populate Config.Log.NewLogger (verified: ecdysis.ParseConfig unmarshals via
    viper from flag/env/file values, and NewLogger has no long struct tag, so it's never set on the
    parsed config — confirmed by reading ecdysis@v0.6.0/config.go's setDefaults/ParseConfig).
    Duplicating two lines of zerolog.ParseLevel/log.ParseFormat was the smaller coupling; flagging
    this as a judgment call in case a reviewer prefers exporting pkg/conduit.NewLoggerFromConfig or
    similar instead.
  • AC-1's stdio test exercises Execute with a real sdkmcp.StdioTransport{} and an
    already-cancelled context.Context, relying on Server.Run's documented behavior ("blocks until
    ... the provided context is cancelled") to return promptly via the ctx.Done() select branch
    without needing to actually read from os.Stdin. The SDK's own cancellation test
    (cmd_test.go:TestServerRunContextCancel) uses in-memory transports instead of real stdio to avoid
    exactly this hazard; I judged the real-stdio version acceptable here because the test asserts a
    bounded 10s timeout (never hangs CI, worst case is a deterministic failure) and it passed
    repeatedly locally including under -race. Flagging this as the one test in the PR with a
    (low, bounded) environment-dependency risk.
  • Non-loopback warning is advisory only (H-4 was explicitly specified as "warn", not "refuse") —
    it does not change the fail-closed gate; a misconfigured-but-token/TLS-complete --http :8443 still
    starts, just with a warning. This matches the design doc's explicit scope (TLS+token already make
    it safe; the warning is about surprising the operator, not blocking a valid config).

Verification

  • go build ./... — clean.
  • go test -race -count=1 ./cmd/conduit/root/mcp/... ./cmd/conduit/internal/mcp/... — all pass,
    including the new AC-1/5/6/7/8/10/12 tests and the H-3/H-4 tests.
  • golangci-lint run ./cmd/conduit/root/mcp/... (v2.12.2, matching tools/go.mod) — 0 issues.
  • npx --yes markdownlint-cli2@0.18.1 docs/operations/mcp-server.md — 0 errors.
  • Manual smoke test: built binary, ran conduit mcp --http :18443 --token-file ... --tls-cert ... --tls-key ..., confirmed the non-loopback warning + startup log lines, then curl'd it
    unauthenticated and with a wrong token and confirmed 401 + the auth-failure log lines with no
    token leakage; confirmed --http with no token/TLS still refuses to start.

Process-maturity note

Per CLAUDE.md's honesty rule: this PR's own review is the adversarial self-review pass above
(live now), not a second-session human review. Tier-2 review (one reviewer approval, CI green)
is the applicable bar. Not merging this PR — leaving it open for review.

…bind warning)

Closes the H-1..H-4 must-fix gaps identified in
docs/design-documents/20260712-mcp-http-transport.md before the --http
transport ships in v0.17 as experimental:

- H-1 (AC-11): --http's usage string and Docs().Long said HTTP is served
  "in addition to stdio" — false, since Execute serves HTTP-only in --http
  mode (a daemon has no attached stdin). Text now matches behavior and
  marks the transport EXPERIMENTAL.
- H-2 (AC-10/AC-8): every rejected (401) HTTP MCP request now logs
  method/path/remote_addr/outcome at warn level, and a startup line logs
  the bound address + auth mode — without ever logging the configured or
  presented token.
- H-3: added IdleTimeout (120s) to the --http server, bounding idle
  keep-alive connections without a blanket WriteTimeout (which would break
  streamable HTTP's long-lived responses).
- H-4: warn at startup when --http binds a non-loopback address.

Adds the missing-coverage tests the design doc calls out: AC-1 (no --http
selects stdio, never builds an http.Server), AC-5 (unauthenticated
tools/list is 401, not an empty catalog), AC-6 (empty/whitespace token
file refused), AC-7 (--allow-mutations gates write tools identically over
HTTP, driven end-to-end with a real *sdkmcp.Client), AC-8/AC-10 (auth
rejections are logged, token never appears in log output), and AC-12
(graceful shutdown drains via http.Server.Shutdown).

Explicitly out of scope (deferred to v0.18 per the design doc): rate
limiting, per-agent tokens, token rotation, mTLS.

Advances Phase-1 execution plan §2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3cLokwNJYLLBpdyt3cgGr
@devarismeroxa devarismeroxa requested a review from a team as a code owner July 13, 2026 08:03
@devarismeroxa devarismeroxa merged commit 38420dc into main Jul 13, 2026
6 checks passed
@devarismeroxa devarismeroxa deleted the feat/mcp-http-hardening branch July 13, 2026 16:29
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