Skip to content

Add RFC-0083: Stateless vMCP Support (MCP 2026-07-28)#83

Open
jhrozek wants to merge 1 commit into
mainfrom
stateless-vmcp
Open

Add RFC-0083: Stateless vMCP Support (MCP 2026-07-28)#83
jhrozek wants to merge 1 commit into
mainfrom
stateless-vmcp

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds RFC-0083: Stateless vMCP Support (MCP 2026-07-28) — the decision-level counterpart to the vMCP stateless design work (toolhive#5756), and the vMCP sibling of RFC-0081 (transport proxies), which explicitly defers the era-mismatched bridge cells here.

The 2026-07-28 revision removes protocol sessions, the initialize handshake, and all server-initiated requests. This RFC proposes making the Virtual MCP Server serve both the current (2025-11-25, "Legacy") and stateless (2026-07-28, "Modern") revisions on the same endpoint, discriminating per request, and — because vMCP terminates and re-originates on both edges — acting as the cross-generation bridge so a client on either revision can reach backends on either revision.

Load-bearing decisions

  • Bypass, don't rebuild. The vMCP core is already stateless; the Modern path invokes none of the session machinery (two-phase SessionIdManager, MultiSession, the session cache, Redis metadata, identity binding, cross-pod rehydration). Legacy is untouched.
  • Hand-rolled Modern dispatcher at a known seam, not gated on go-sdk v1.7. Most of it is assembly of existing parts (classifier, the standalone authzCallGate() closure, pkg/vmcp/conversion); the one new piece — the JSON-RPC resultType envelope — is framed as a temporary bridge to be retired once v1.7's stateless server mode ships.
  • Per-backend dual protocol with probe-Modern-first era detection cached in the health subsystem, and re-classification on backend-edge rejection as the correctness guarantee.
  • vMCP owns the off-diagonal bridge (both era-mismatched cells), with synthesized Legacy-backend sessions partitioned by (principal, backend) and initialized with the calling principal's existing outgoing auth.
  • Server-minted, owner-bound workflow handles — designed now (plaintext (iss, sub) reusing the existing pkg/vmcp/session/binding encoding, per #5306), enforcement sequenced with MRTR since the MVP exposes no client-visible handle.

Notes

  • Every codebase claim the RFC rests on was cross-checked against toolhive origin/main.
  • No user-facing or CRD API changes for the MVP; fully backward compatible (all traffic classifies Legacy unless a request carries a Modern signal).
  • Open Questions: none — all prior questions are resolved in the draft.

🤖 Generated with Claude Code

Decision-level counterpart to the vMCP stateless design work (toolhive#5756),
capturing the design decisions for vMCP dual-protocol operation and its role as
the cross-generation bridge that RFC-0081 defers here.

The 2026-07-28 revision removes protocol sessions, the initialize handshake, and
server-initiated requests. This RFC proposes serving both the current (Legacy)
and stateless (Modern) revisions on the same vMCP endpoint, discriminating per
request, and bridging all four client x backend era combinations.

Load-bearing decisions and rationale: bypass the session bridge on the Modern
path rather than rebuild it (the core is already stateless); a hand-rolled Modern
dispatcher over direct core calls that does not hard-gate on go-sdk v1.7, with
its JSON-RPC envelope framed as a temporary SDK bridge; per-backend dual
protocol with probe-Modern-first era detection; server-minted, owner-bound
workflow handles designed now but enforced with MRTR; and the alternatives
considered, with why each was rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jhrozek jhrozek changed the title Add RFC-0082: Stateless vMCP Support (MCP 2026-07-28) Add RFC-0083: Stateless vMCP Support (MCP 2026-07-28) Jul 21, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I pulled this RFC, the companion design doc (toolhive@worktree-vmcp-stateless-design), verified every load-bearing codebase claim against toolhive@main, and cross-checked every protocol claim against the authoritative MCP 2026-07-28 draft spec (changelog, lifecycle, server/discover, MRTR, caching, and the raw schema.json).

Bottom line: well-researched and accurate. The claims check out. Recommend approval, with a few minor corrections and one design point worth probing.


1. Protocol claims vs. the draft spec — accurate

Every spec claim I could verify is correct, including the subtle ones:

Claim Verdict
Removes sessions, Mcp-Session-Id, initialize, GET SSE stream, resumability ✅ changelog major 1, 2, 4, 9
Removes ping, logging/setLevel, notifications/roots/list_changed; server-initiated → MRTR ✅ changes 5, 7
resultType required; only "complete"/"input_required"; ` string` = extension point
Error codes -32020/-32021/-32022 (renumbered; SEP-2243 still cites -32001) ✅ changelog minor 12 — exact match; the doc's caveat about the SEP is correct
ttlMs/cacheScope required on CacheableResult (no ?) → omitting = schema-invalid ✅ schema required:["cacheScope","resultType","ttlMs"]. The doc is right; the caching prose about "absent → default 0" is legacy-tolerance, not permission for modern servers
server/discover = capability flags only, no item arrays → topology leak, not enumeration DiscoverResult.capabilities = $ref ServerCapabilities; ServerCapabilities.properties=[completions,experimental,extensions,logging,prompts,resources,tools] — flags, not items
_meta: protocolVersion+clientCapabilities required, clientInfo optional ✅ schema RequestMetaObject
MRTR requestState: integrity MUST, principal/TTL/scope SHOULD ✅ MRTR page server-reqs 4 & 5, verbatim
Era is "a property of the server, not of an individual request" ✅ lifecycle Backward-Compat
Server-minted handles = SEP-2567 ✅ changelog change 1
agentgateway stateless_send_and_initialize prior art ✅ real, role as described

2. Codebase claims vs. toolhive@main — accurate (trivial line drift only)

The "cross-checked against origin/main" claim is justified:

  • coreVMCP stateless + "core filters, Serve caches" — core_vmcp.go:36-38; FilterTools :279; NewInMemoryStateStore :147
  • var mcpHandler http.Handler = streamableServer at server.go:607; vmcpratelimit.NewDecorator :466; WriteTimeout :655; lazyInjectSessionTools :1064
  • ✅ Call-gate: authzCallGate() call_gate.go:44; WithCallGate gated on authzGateEnabled server.go:543-544
  • binding.Format(iss,sub)iss + "\x00" + sub verbatim (binding.go:47)
  • ✅ Composer store keyed on WorkflowID; WorkflowStatus has no owner field; ExecuteWorkflow synchronous, defer DeleteContext, WorkflowID never persisted/returned → D5's "no migration needed" holds
  • pkg/audit only whole-body switches + event exclusion — no field-level redaction (confirms "redaction is a build item")
  • defaultCacheCapacity = 1000; both .claude/rules/security.md rules present
  • ✅ Both backend Initialize calls fire with LATEST_PROTOCOL_VERSION (client.go:677, mcp_session.go:415)

3. Referenced issues/PRs — all exist; three minor mischaracterizations

Won't affect the technical argument, but worth fixing:

  1. PR #5839 — RFC says ClassifyRevision/revision.go was "added in #5839." It was added in #5834 and modified by #5839.
  2. PR #5808 — design doc says "pending, not yet merged." It's actually closed without merge (2026-07-17), so the transport-proxy design doc isn't currently on a path to docs/arch/ via that PR. "Pending" is misleading.
  3. Issue #5306 — cited as the basis for the Format(iss,sub) encoding. It's actually about session churn on token refresh (the binding breaking when a refreshed token changes). Related, but not about the encoding.

Points worth probing before merge

A. Kill-switch mechanism unspecified. Defaulting Modern to off until "proven" is defensible (the asymmetry with RFC-0081 is well-argued). But the RFC says "without a redeploy" — implying a runtime toggle — without specifying the mechanism (config reload? dynamic flag?). Phase 1 is already safe-by-construction, so a flag that also defaults off means the feature ships dark and needs operator action to ever activate. One clarifying sentence on runtime-vs-startup would help.

B. The one new attack surface is honestly flagged but thin. The hand-rolled JSON-RPC envelope + re-homed call-gate is correctly identified as the main risk. "Lift the SDK's marshaling where possible, retire into v1.7" is the right direction, but the RFC is light on how much can actually be lifted from mcpcompat today before v1.7. Since mcpcompat lives in toolhive-core (Alpha), it's worth confirming whether the envelope marshaling is reusable as-is or needs extraction — a concrete pre-implementation question the RFC could answer.

C. server/discover post-admission cost is understated. Computing capability flags from the post-admission projection is the right security call. But "flag appears only when the identity is admitted to ≥1 item behind it" means discover now runs the same aggregation+admission fan-out as tools/list — on what the spec intends as a cheap, cacheable probe that clients MAY call before every session. Combined with the doc's own "Modern path pays a backend fan-out on every request" warning, discover risks becoming the most frequent expensive call. Caching is deferred to #5761 but not reconciled here. Not a blocker — worth an explicit note.


Recommendation

Approve. The central thesis — vMCP's core is already stateless, so the work is bypassing the session bridge on the Modern path rather than rebuilding it — is verified true in the code and is the correct architectural read. Owning the off-diagonal bridge (D3) and not gating on go-sdk v1.7 (D2) are both sound. The security invariants (owner-bound handles, (principal, backend) partitioning, zero-credential pooling) are correctly designed and the spec's security requirements are quoted accurately.

Request the three reference corrections (#5839→#5834, #5808 closed-not-pending, #5306 reframing) and a clarifying sentence on (A) before merge. (B) and (C) can be tracked in #5756 rather than blocking.

@JAORMX

JAORMX commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Follow-up to my review — I dug into decision D2 (the hand-rolled Modern envelope) to confirm how much JSON-RPC marshaling can actually be lifted from mcpcompat before go-sdk v1.7, since we own toolhive-core. Traced every layer against the code and both go-sdk versions in our module cache.

Short answer: the "lift the SDK's battle-tested marshaling wherever possible" framing is more optimistic than the code supports. The conclusion (hand-roll, don't gate on v1.7) is still correct — but it's a from-scratch implementation, not a lift-then-retire.

What produces the envelope today, and what's liftable

Layer Liftable for Modern?
mcpcompat/mcp/jsonrpc.goJSONRPCResponse/JSONRPCError shells ⚠️ Only the generic {jsonrpc,id,result}/{jsonrpc,id,error} shells. No resultType field exists anywhereResult (jsonrpc.go:131) has only Meta. No CacheableResult/ttlMs/cacheScope/server/discover.
mcpcompat/server/request_handler.goHandleMessage/dispatch/successResponse/errorResponse ⚠️ A real, already-reusable method dispatcher — but Legacy-shaped and coupled to the session-ful MCPServer registration state. Its dispatch switch is the shape a Modern dispatcher needs, minus resultType.
mcpcompat/server/transports.goServeHTTP ❌ Entirely session-ful (session-id validation, rehydration, nonce bridge, DELETE). The actual JSON bytes are written inside gosdk.NewStreamableHTTPHandler — a black box the shim doesn't control.
go-sdk v1.6.1 (current dep) ❌ Has a Stateless option (streamable.go:136) but it only means "close session after request." Zero resultType/MRTR/server/discover — still 2025-11-25 wire format.
go-sdk v1.7.0-pre.1 (already in module cache) ✅ the real thing — see below.

The decisive finding

v1.6.1's Stateless mode is a trap. It exists, but it's a session-lifecycle knob (close-after-request), not the 2026-07-28 protocol — no resultType, no server/discover. So "use the SDK's existing stateless mode" is not available at any current version.

v1.7.0-pre.1 is where the real support lands: a dedicated serveStateless path (POST-only, ephemeral session, 405 on GET/DELETE), mrtr.go, DiscoverResult (protocol.go:1130), CacheableResult (protocol.go:1153), methodDiscover = "server/discover" (protocol.go:2290), and conformance testdata for server/discover.

But the catch that matters for "lifting": in v1.7, resultType is an unexported type with unexported constants (resultTypeComplete = "complete", protocol.go:23), set only via internal setResultType or the SDK's own MarshalJSON. External code cannot hand-construct a resultType field — you must let the SDK's result structs marshal themselves. DiscoverResult.ServerInfo is a top-level Go field the SDK maps to _meta internally.

So the honest framing: the hand-rolled envelope is not a "bridge to lift then retire" — it's a from-scratch implementation of a wire shape only v1.7 provides, and v1.7 provides it as adopt-wholesale, not lift-piecemeal. "Retire into v1.7" is real, but it's a replacement, not a migration of lifted code.

What we can actually do (we own toolhive-core)

  1. Hand-roll thin, mirror v1.7's wire shape byte-for-byte (recommended). Build the Modern envelope as minimal result types serializing to the identical shape v1.7 produces (resultType, ttlMs, cacheScope, supportedVersions, flags-only capabilities). Reuse what is liftable: the dispatch/successResponse/errorResponse skeleton and error-code mapping from request_handler.go (already battle-tested against mcp-go), plus the JSONRPCResponse/JSONRPCError shells. Then the v1.7 swap is a delete-and-repoint, validated against the v1.7.0-pre.1 conformance testdata (testdata/conformance/server/discover.txtar — already in the module cache, we can diff against it).

  2. Bump mcpcompat to v1.7.0-pre.1 now and use serveStateless. Viable because we own mcpcompat, but re-couples the two things D2 correctly decoupled (the whole session-ful transports.go path must keep working on v1.7 = #5754, explicitly out of scope). Not recommended for the MVP.

  3. Extract v1.7's result-type definitions into a small internal package (Apache-2.0, copyable since they can't be imported unexported). Highest fidelity, most new code, a maintenance shadow over the SDK. Only if (1) proves fragile.

Suggested amendment to D2

The SDK's marshaling is not liftable today: v1.6.1 has no Modern wire shapes, and v1.7's are unexported. The hand-rolled envelope is therefore a from-scratch implementation, kept thin by (a) reusing mcpcompat's existing dispatch/successResponse/errorResponse skeleton and (b) matching v1.7's wire shape byte-for-byte so the eventual v1.7 adoption is a delete-and-repoint, validated against the v1.7.0-pre.1 conformance testdata. "Lift the SDK's marshaling" is not an available option until v1.7 lands, and even then it's adoption, not lifting.

This is a correction of emphasis, not a rebuke — D2's conclusion stands. Given the reusable surface is small (~100 lines of dispatch + envelope shells in request_handler.go), the risk is low either way.

@jhrozek

jhrozek commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the two-pass review — the spec-by-spec and code-by-code verification is genuinely appreciated, and it caught real things. Here's how I've addressed the points:

Reference corrections:

  • revision.go now reads "added in #5834, modified in #5839" — you're right, #5834 added it and #5839 only touched it.
  • #5808 no longer says "pending". It's now noted as closed-without-merge (2026-07-17), with RFC-0081 called out as the decision-level home for that content instead.
  • #5306 reframed. You're correct that it's about session churn on token refresh, not the encoding. The encoding now just points at binding.Format directly, and #5306 is cited only as the token-refresh caveat the handle owner-binding inherits (a refreshed token that changes sub breaks the bound identity) — which actually earns its place once the resume path lands.

On (A), the kill switch: I spelled out the mechanism. For the MVP it's a startup-time flag — flipping it is a config change plus a rolling restart, not an image rebuild; a runtime/hot reload is noted as a possible later refinement. It stays Modern-off by default, so the path ships dark and needs a deliberate opt-in, and nothing regresses while it's off (Phase 1 is safe-by-construction anyway).

(B) and (C) I folded in as explicit notes rather than leaving them implicit, both tagged as tracked in #5756 rather than blocking:

  • (B) The "how much marshaling lifts from mcpcompat as-is vs needs extraction" question is now called out as a pre-Phase-2 thing to settle, since it bounds how much of the envelope is genuinely new code.
  • (C) The discover fan-out cost is flagged in the server/discover section: computing flags post-admission makes discover run the same aggregation as tools/list on a probe the spec expects to be cheap, so it risks being the most frequent expensive call. Short-TTL per-identity capability cache is the measure-first mitigation; caching metadata stays deferred to #5761.

Happy to pull B/C back out of the RFC body and keep them purely as #5756 notes if you'd rather the RFC stay lean.

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