Skip to content

RFC: Stateless Transport Proxy Support (MCP 2026-07-28)#81

Merged
jhrozek merged 6 commits into
mainfrom
stateless-transport-changes
Jul 16, 2026
Merged

RFC: Stateless Transport Proxy Support (MCP 2026-07-28)#81
jhrozek merged 6 commits into
mainfrom
stateless-transport-changes

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Proposes making ToolHive's transparent (HTTP→HTTP) and streamable (HTTP→stdio) transport proxies serve both the current 2025-11-25 (Legacy, initialize-based) and the upcoming 2026-07-28 (Modern, stateless) MCP revisions on the same endpoint, discriminating per request rather than at a flag-day cutover.

Key points:

  • A pure per-request revision classifier (ClassifyRevision) wired at the proxies' existing JSON-RPC decode points; zero behavior change for existing Legacy traffic.
  • Streamable proxy: Modern requests mint a fresh per-request routing token and ignore any client-supplied Mcp-Session-Id, closing a response cross-delivery leak by construction.
  • Transparent proxy: the initialize-triggered machinery (session guard, pod-pinning, re-init replay, backend-SID rewrite) simply doesn't engage for Modern.
  • Scope is the two diagonal cells of the client × backend matrix; the cross-generation bridge is deferred to vMCP (toolhive#5756).

Related issue: toolhive#5755

Review status

The technical claims in this RFC were verified against the MCP draft spec (incl. schema/draft/schema.ts), the ToolHive source, and agentgateway's implementation before submission. The second commit folds in corrections from that review (agentgateway attribution, matrix reconciliation for Modern-client/Legacy-server, conditional Mcp-Name requirement, health-monitor and forward-compat framing).

🤖 Generated with Claude Code

@JAORMX

JAORMX commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

I compared this against the locked 2026-07-28 draft and the current ToolHive paths. The dual-era/session-routing direction is sound, but I think the RFC currently describes support for stateless unary requests rather than full 2026-07-28 transport support.

blocker: Please include subscriptions/listen and multi-message POST SSE in the design, or explicitly scope the RFC to unary-only partial support. Stateless MCP removes the standalone GET stream, not streaming itself. Request-scoped progress/log notifications and the long-lived subscriptions/listen stream use POST response SSE. Today the stdio proxy drops container notifications in pkg/transport/proxy/streamable/dispatcher.go, treats the waiter as one-shot, and writes one SSE response before returning. This means progress, logging, and subscriptions/listen do not work. This is also explicitly part of #5755's requested design.

blocker: Parser registration alone does not make the new methods usable through protected proxies. server/discover and subscriptions/listen are absent from MCPMethodToFeatureOperation; unknown methods are denied by default in pkg/authz/middleware.go. The design needs an explicit authorization classification for both methods. In particular, subscriptions/listen should account for the requested notification/resource-subscription filter.

blocker: The Modern→Legacy compatibility row is incorrect as written. The draft compatibility matrix says a modern-only client talking to a Legacy server fails. A dual-era client can probe and fall back to initialize. Please rename that row to Dual-era client → Legacy server or leave genuinely Modern-only→Legacy unsupported.

blocker: Revision classification needs to validate a complete supported Modern request, not merely the presence of a protocol-version _meta key. The design should cover:

  • exact supported version, returning UnsupportedProtocolVersionError (-32022) for unsupported/future versions;
  • required clientInfo and clientCapabilities metadata;
  • MCP-Protocol-Version: 2026-07-28 with absent/malformed _meta as an error, not Legacy fallback;
  • required Mcp-Method/conditional Mcp-Name validation;
  • Base64 sentinel decoding for Mcp-Name before body comparison.

I would also avoid saying the body is "authoritative": a header/body mismatch must be rejected with HeaderMismatch (-32020); neither value wins.

blocker: The proposed health-monitor signal still uses a removed method. StatelessMCPPinger currently sends ping, which is removed in 2026-07-28. The Modern path should use server/discover, a dedicated health endpoint, or successful normal backend contact—not the existing POST ping implementation.

suggestion: Add Modern message-shape and method validation before the current dispatch branches. The current streamable proxy accepts JSON-RPC batches and client-sent responses before resolveSessionForRequest; both are invalid on the Modern HTTP transport. The design should also cover Modern GET/DELETE rejection in the stdio-facing streamable proxy, not only refinement of the transparent proxy's method gate.

suggestion: Correct the Kubernetes streaming language. Modern has no GET SSE stream. A POST response stream is attached to one pod for its lifetime, but it does not create session affinity for later requests; after disconnect, clients recreate subscriptions.

MRTR can reasonably remain out of scope because an InputRequiredResult is an ordinary JSON-RPC response with an ID and should traverse the existing dispatcher. I would still add a pass-through test to lock that assumption down.

Tests I would add to the RFC:

  • protected server/discover and subscriptions/listen;
  • progress → progress → final response over POST SSE;
  • subscription acknowledgment, notifications, cancellation, and concurrent subscriptions;
  • unknown version → -32022;
  • missing/mismatched headers → -32020;
  • encoded Mcp-Name;
  • Modern batch/client-response rejection;
  • Modern GET/DELETE rejection;
  • MRTR InputRequiredResult pass-through;
  • dual-era fallback versus modern-only failure.

The sessionless routing-token work and the distinction between protocol sessions and stdio response correlation are strong. The main issue is scope: without the POST streaming path and authz changes, this should not yet claim complete 2026-07-28 transport support.

jhrozek and others added 6 commits July 16, 2026 12:33
Proposes dual-protocol operation in the transparent and streamable transport
proxies so ToolHive serves both the current (2025-11-25) and the stateless
2026-07-28 MCP revision on one endpoint, discriminating per request. Covers the
revision classifier, the client x backend dual-era matrix (diagonal cells in
scope, off-diagonal deferred to vMCP), security (per-request response
correlation), and the relationship to the existing --stateless flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes claims found inaccurate when verified against the MCP draft spec,
ToolHive source, and agentgateway:

- Correct the agentgateway prior-art attribution: it does not assess
  Legacy-client/Modern-server as unsound; cite the spec compatibility
  matrix ("Fails", no client fall-forward) instead.
- Reconcile the client x backend matrix: Modern-client/Legacy-server
  resolves via the client's own probe-then-fallback (no proxy work),
  leaving only Legacy-client/Modern-server unbridged.
- Qualify Mcp-Name as conditionally required (tools/call, resources/read,
  prompts/get) rather than universally mandatory.
- Reframe the health-monitor note: neither existing readiness trigger
  fires on the Modern path, so it must gate off first backend contact.
- Correct the forward-compat note: version negotiation is a Revision
  type/signature change, not drop-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These belong to separate proposals; keep this PR to THV-0081 only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documents why the middleware chain and telemetry need no structural change
under stateless Modern traffic (nothing keys on the MCP protocol session id),
verified against source:

- Cedar authz, rate limiting, and audit all reconstruct identity from the
  token per request; annotation cache is keyed by tool name, not session.
- Telemetry gains richer per-request attribution (protocol version from the
  mandatory header, client name from _meta on every span, W3C trace context
  for correlation in place of mcp.session.id).
- Distinguish the JWT tsid (upstream-token session) from the MCP protocol
  session; note the audit initialize target name still reflects clientInfo.
- Flag vMCP per-session backend connections as the one structural dependency,
  deferred to vMCP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand the RFC to the full in-scope change set (deepened classification,
Modern message-shape enforcement, authz-map registration, ping-removed health
readiness) plus the deferred request-scoped streaming and subscriptions
designs. Restructure the implementation plan into In scope / Deferred, dropping
the tier/phase labeling, and extend Testing with the conformance and
test-inversion notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jhrozek
jhrozek force-pushed the stateless-transport-changes branch from 1e0a75d to fe82edd Compare July 16, 2026 11:34
@jhrozek

jhrozek commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — it reshaped the RFC. The main change is scope: rather than claim complete 2026-07-28 transport support, the RFC now draws a clear line. In scope is the stateless unary path plus the correctness work (classification, message-shape, authz, health). Request-scoped streaming and subscriptions/listen are designed in the RFC but deferred as separate follow-up work. Point by point:

  1. POST SSE streaming — this was the real gap, agreed. There's now a "Request-scoped streaming" design section: forwarding progress/logging notifications on the POST-response stream, the one-shot→streaming dispatcher rework, progressToken demux, and backpressure. It's deferred rather than built now because it only affects the stdio streamable proxy — the transparent proxy already streams N-message POST responses natively for HTTP/remote backends, and that's the path the conformance job exercises. One limitation worth calling out: notifications/message (logging) carries no correlation token, so over a multiplexed stdio pipe it can't be attributed to a specific request. Progress works cleanly; logging is broadcast-or-drop. That's protocol-level, not something the proxy can fix. subscriptions/listen is sketched in its own deferred section, gated on demand.

  2. Authz registration — fixed. The change set now explicitly adds server/discover and subscriptions/listen to MCPMethodToFeatureOperation, and the text calls out that parser labeling alone leaves them default-denied.

  3. Modern→Legacy row — you're right. The matrix now splits the client kinds: Legacy, Modern-only, and Dual-era. Dual-era→Legacy is client-side fallback (no proxy work); Modern-only→Legacy is the "Fails" client-side gap; Legacy→Modern is the genuinely unbridged cell, deferred to vMCP.

  4. Classification depth — rewritten as validation rather than key-presence: unsupported version → -32022, required clientInfo/clientCapabilities (missing capability → -32021), and a Modern header with absent/malformed _meta is an error, not a Legacy downgrade. Dropped the "body authoritative" wording — a header/body mismatch is -32020, neither wins. Also added your two sub-points: decode Mcp-Name's Base64 sentinel before comparing to the body, and an MRTR InputRequiredResult pass-through test (it's an ordinary response with an id, so it should traverse the dispatcher unchanged even though MRTR itself is out of scope).

  5. Health ping — fixed. The RFC now notes StatelessMCPPinger POSTs ping, which is removed in 2026-07-28, so the Modern readiness signal uses server/discover or plain successful backend contact.

  6. Shape validation + k8s wording — Modern message-shape rejection (batches, client-sent responses, GET/DELETE→405) is now in scope, behind a Legacy/Modern mode flag that's a transport property rather than the client-controlled header. The k8s streaming language is corrected: a POST response stream binds to one pod for its lifetime but creates no session affinity for later requests.

Net: scoped to unary + correctness now, streaming and subscriptions designed and deferred, and the stdio logging limitation documented. Does that scoping work for you, or would you rather see the streaming work pulled into this round?

@jhrozek
jhrozek merged commit 4071542 into main Jul 16, 2026
1 check passed
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.

2 participants