Skip to content

Serve MCP 2026-07-28 stateless requests through the transparent proxy#5884

Merged
jhrozek merged 7 commits into
mainfrom
stateless-transparent-proxy-5831
Jul 20, 2026
Merged

Serve MCP 2026-07-28 stateless requests through the transparent proxy#5884
jhrozek merged 7 commits into
mainfrom
stateless-transparent-proxy-5831

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

The transparent proxy (pkg/transport/proxy/transparent) reverse-proxies to MCP
backends, but every request path assumed the Legacy (≤2025-11-25),
initialize-handshake, session-based model. A pure-Modern (2026-07-28,
stateless) backend was therefore mishandled: serverInitialized never flipped,
so the background monitorHealth loop never began active failure-monitoring
for it; GET/DELETE were only rejected in --stateless mode; and malformed
Modern requests were forwarded to the backend instead of being rejected at the
edge. This mirrors the classification the sibling streamable proxy already does.

What changed:

  • Centralize classification-error rendering into pkg/mcp
    (WriteClassificationError for the http.ResponseWriter seam,
    ClassificationErrorResponse for the RoundTripper seam), replacing the
    streamable proxy's local copy — one source of truth for the 400 JSON-RPC body.
  • Revision-aware method gate: GET/HEAD/DELETE now get a 405 when the proxy
    is --stateless or the request carries MCP-Protocol-Version: 2026-07-28.
    Header-only and deliberately pre-auth (a method rejection needs no identity
    and reads no body).
  • Classify POST requests in RoundTrip: parse the body once
    (parseRPCRequest); only a single JSON-RPC request (non-empty method + valid
    id) is classified. A malformed Modern request returns a 400 (echoing the id)
    before the backend is contacted. Batches, notifications, and responses forward
    as Legacy, so a stray Modern header can never trigger a spurious rejection.
  • Health readiness for Modern backends: flip serverInitialized on the
    first successful (HTTP 200) Modern request, so the background health monitor
    engages (parity with Legacy's any-200 flip).

Security invariant: the session-ID guard, backend-SID rewrite, and re-init/
DELETE recovery remain keyed on Mcp-Session-Id presence, never on the
client-forgeable classified revision. Regression tests prove a forged Modern
_meta cannot bypass any of them (the unknown-session case was
mutation-verified).

Closes #5831

Type of change

  • New feature

Test plan

  • Unit tests (task test) — full -race suite green
  • E2E tests (task test-e2e)
  • Linting (task lint-fix) — 0 issues
  • Manual testing (describe below)

End-to-end against a live 2026-07-28 MCP server was not run and is left as
manual verification: point a transparent proxy at a backend that never receives
initialize and always sends Modern _meta + the MCP-Protocol-Version
header; confirm requests succeed, background health-monitoring engages after the
first Modern 200, GET/DELETE return 405 without --stateless, and a malformed
Modern request gets a 400 instead of being forwarded.

API Compatibility

  • This PR does not break the v1beta1 API. (No operator/CRD surface touched.)

Changes

File Change
pkg/mcp/classification_response.go New: shared 400 JSON-RPC classification-error rendering (WriteClassificationError, ClassificationErrorResponse).
pkg/transport/proxy/streamable/streamable_proxy.go Use the shared helper; delete the local writeClassificationError.
pkg/transport/proxy/transparent/transparent_proxy.go Revision-aware header-only method gate; single-pass parseRPCRequest + POST classification in RoundTrip; Modern-200 readiness flip.
pkg/mcp/classification_response_test.go, pkg/transport/proxy/transparent/{revision_classification,revision_guard_regression,method_gate}_test.go Unit + security-regression coverage.

Does this introduce a user-facing change?

Yes. The transparent proxy now serves MCP 2026-07-28 (stateless) backends: it
rejects GET/HEAD/DELETE for Modern requests, rejects malformed Modern requests
with a JSON-RPC 400 at the edge, and reports health readiness for pure-Modern
backends.

Implementation plan

Approved implementation plan (MoE-reviewed)

Implemented in 5 commits, each independently reviewed by two domain experts
(worker: go-expert-developer; reviewers selected by change area):

  1. Centralize MCP classification-error rendering (pkg/mcp) — lift the
    streamable proxy's local writeClassificationError into a shared helper with
    two seams (http.ResponseWriter and *http.Response), mirroring the
    session.NotFoundBody/WriteNotFound/NotFoundResponse precedent.
  2. Revision-aware method gate — replace statelessMethodGate with a
    methodGate method; 405 GET/HEAD/DELETE when p.stateless || header == MCPVersionModern; wired unconditionally, outermost (pre-auth), header-only.
  3. Classify Modern POST requests in RoundTripparseRPCRequest parses
    the body once; classify only a single request (method + valid id); 400 a
    malformed Modern request before the backend; batches/notifications/responses
    forward as Legacy. Session guards left keyed on Mcp-Session-Id presence.
  4. Start health monitoring for Modern backends — flip serverInitialized
    on the first Modern HTTP 200 (parity with Legacy's any-200 flip); gates only
    the background monitor, not the /health response.
  5. Revision-spoofing guard regression tests — prove a forged Modern _meta
    does not bypass the unknown-session guard, backend-SID rewrite, re-init
    recovery, or DELETE cleanup.

Deliberately out of scope (follow-ups): streamable proxy's own GET/DELETE
revision-awareness; POST-side unsupported MCP-Protocol-Version header
validation (tracked by an existing TODO in pkg/mcp/revision.go).

Special notes for reviewers

🤖 Generated with Claude Code

jhrozek and others added 6 commits July 20, 2026 12:57
The streamable proxy rendered ClassifyRevision errors as HTTP 400 JSON-RPC
error bodies via a local helper. The transparent proxy will need the same
rendering (issue #5831), so lift it into pkg/mcp as a single source of truth:

- WriteClassificationError writes to an http.ResponseWriter (streamable).
- ClassificationErrorResponse builds an *http.Response for use from
  tracingTransport.RoundTrip, where no ResponseWriter is available.

Both share one body-computation helper, mirroring the
session.NotFoundBody/WriteNotFound/NotFoundResponse precedent. Wire output is
byte-identical; the streamable local copy is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The transparent proxy only rejected GET/HEAD/DELETE with 405 in --stateless
mode. A Modern (2026-07-28, stateless) MCP request has no protocol use for
those methods either, regardless of proxy mode, so the gate should reject them
whenever the request is tagged Modern.

Replace statelessMethodGate with a p.methodGate method that 405s GET/HEAD/DELETE
when p.stateless OR the MCP-Protocol-Version header is exactly the Modern
version, and wire it unconditionally. The gate stays outermost (pre-auth) and
header-only -- it never reads the body, so it does not call ClassifyRevision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The transparent proxy forwarded every POST unclassified, so a malformed
2026-07-28 (Modern, stateless) request reached the backend instead of being
rejected at the edge, and no revision signal was available for the health
readiness fix.

Parse the JSON-RPC body once (parseRPCRequest, replacing detectInitialize) to
recover method, params, and id. Only a single request (non-empty method and a
valid non-null id) is classified via mcp.ClassifyRevision; batches,
notifications, and responses forward as Legacy so a stray Modern header can
never trigger a spurious rejection. A malformed Modern request returns a 400
JSON-RPC error (echoing the id) before the backend is contacted. A well-formed
request falls through unchanged, so the session-ID guard still keys on
Mcp-Session-Id presence, not on the client-forgeable revision. The computed
revision is consumed by the readiness fix in a following change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The transparent proxy's serverInitialized latch — which gates the background
monitorHealth loop — only flipped on Legacy signals (a response Mcp-Session-Id
or an initialize request). A pure-Modern (2026-07-28, stateless) backend has
neither, so its health monitor never began probing.

Flip the latch on the first successful (HTTP 200) request classified Modern,
mirroring Legacy's existing any-200 flip. This gates only background
monitoring, not the /health response, and starts monitoring on a
proven-live backend, avoiding startup flapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The session-ID guard, backend-SID rewrite, re-init recovery, and DELETE
cleanup deliberately key on Mcp-Session-Id presence, not on the
client-forgeable classified revision. Add RoundTrip-level regression tests
proving a well-formed Modern _meta signal does not let a request bypass any of
them: an unknown session still gets session-not-found, a known session still
gets the backend-SID rewrite and 404/dial re-init recovery, and a DELETE
carrying body _meta (no header) still performs session-keyed cleanup. The
unknown-session case was mutation-verified (re-keying the guard on revision
makes it fail).

Also fix a pre-existing data race in method_gate_test.go, where parallel
subtests shared a single gotBody/inner handler; move them into each subtest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address external review: move the exported WriteClassificationError and
ClassificationErrorResponse above the private helper per go-style file
organization, and document that the revision guard regression suite covers
single-request Modern-signal forgery only (batch initialize guard exemption
is pre-existing Legacy behavior, tracked in #5883).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Jul 20, 2026
JAORMX
JAORMX previously approved these changes Jul 20, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. ✅ A three-lens panel (security · MCP-conformance · Go/reuse) verified this against the real code; it's carefully scoped and the security-critical invariant holds.

Security — anti-spoofing invariant holds (the crux). All four session guards stay keyed on Mcp-Session-Id presence, never on the client-forgeable classified revision: unknown-session (:614), backend-SID rewrite (:634), DELETE cleanup (:696), re-init recovery (:711). I confirmed revision is referenced in exactly one behavioral spot — the monotonic readiness latch (:759) — plus a debug log and the 405 message; none of the guards touch it. A forged MCP-Protocol-Version: 2026-07-28 or reserved _meta therefore can't bypass anything, and initialize is hard-pinned to Legacy in ClassifyRevision. The revision_guard_regression_test.go suite is genuine mutation-testing (each test first asserts the body classifies Modern-nil, then drives real RoundTrip and asserts the Legacy machinery still fires).

MCP conformance. Method gate is spec-correct (GET/HEAD/DELETE→405 with Allow: POST, OPTIONS for modern-only servers; exact 2026-07-28 header keying; no-op for Legacy so the SSE GET stream is preserved). Single-request classification is spec-safe — a conformant Modern client only sends a single request with a valid id (no batching, no client→server notifications/responses over Streamable HTTP), so forwarding those shapes as Legacy can never misclassify. Body restore + json.RawMessage id echo preserve large-int precision; every failure path returns HTTP 400 (exceeds the intermediary minimum).

Go/reuse. The centralized WriteClassificationError/ClassificationErrorResponse in pkg/mcp is exactly the dedup our #5839 review asked for — the streamable proxy's local writer is fully removed, both proxies route through the shared helper (which mirrors session.NotFoundBody/WriteNotFound/NotFoundResponse field-for-field and references the CodeInvalidParams const). Body parse/restore correct; shared helper unit-tested for byte-identical output across both seams.

Real checks (lint, unit tests) are green.

Minor / non-blocking:

  • MissingModernMetadataError maps to -32602 rather than the draft's -32020 for a header-signalled-but-body-missing case — but that's #5834's classifier (with an existing TODO), and the proxy returns 400 either way, so it stays intermediary-compliant. Track with the deferred POST-side header-validation work.
  • Classification fires for streamable-HTTP JSON POSTs on non-/mcp paths too — only ever rejects the client's own malformed Modern request (no leak); confirm that broadening is intended.
  • The marshal-failure fallback hardcodes -32602 (matches the session precedent — a raw string can't interpolate the const; a "keep in sync" comment would suffice).
  • Pre-existing batch-guard gap #5883 confirmed not worsened here (batches set singleRequest=false, never classified).

Nicely done — clean sibling to #5839 and it closes the dedup loop.

🤖 AI-assisted panel review via Claude Code (security · MCP-spec · Go/reuse). Line numbers/verification against c30fd35.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.44%. Comparing base (217b0be) to head (e9ec367).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5884      +/-   ##
==========================================
+ Coverage   71.35%   71.44%   +0.09%     
==========================================
  Files         693      694       +1     
  Lines       70630    70664      +34     
==========================================
+ Hits        50395    50485      +90     
+ Misses      16600    16530      -70     
- Partials     3635     3649      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Add a focused test that drives classificationErrorBody's defensive
marshal-failure branch with an unmarshalable request id, bringing the
helper to full coverage and verifying the fallback returns a valid
JSON-RPC error body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 20, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Still LGTM on e9ec367 — the only change since my approval is a test-only addition (+15 lines in classification_response_test.go) covering the marshal-failure fallback branch, addressing the earlier coverage NIT. Production code is unchanged from the reviewed c30fd35 (which passed all 46 checks), and the security invariant / dedup / conformance findings all still stand. Nice to see the fallback now exercised. Re-approving.

🤖 AI-assisted panel review via Claude Code. Test-only delta since c30fd35.

@jhrozek
jhrozek merged commit db4ecb1 into main Jul 20, 2026
77 of 78 checks passed
@jhrozek
jhrozek deleted the stateless-transparent-proxy-5831 branch July 20, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve MCP 2026-07-28 stateless requests through the transparent proxy

2 participants