Add RFC-0083: Stateless vMCP Support (MCP 2026-07-28)#83
Conversation
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>
JAORMX
left a comment
There was a problem hiding this comment.
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:
- ✅
coreVMCPstateless + "core filters, Serve caches" —core_vmcp.go:36-38;FilterTools:279;NewInMemoryStateStore:147 - ✅
var mcpHandler http.Handler = streamableServeratserver.go:607;vmcpratelimit.NewDecorator:466;WriteTimeout:655;lazyInjectSessionTools:1064 - ✅ Call-gate:
authzCallGate()call_gate.go:44;WithCallGategated onauthzGateEnabledserver.go:543-544 - ✅
binding.Format(iss,sub)→iss + "\x00" + subverbatim (binding.go:47) - ✅ Composer store keyed on
WorkflowID;WorkflowStatushas no owner field;ExecuteWorkflowsynchronous,defer DeleteContext,WorkflowIDnever persisted/returned → D5's "no migration needed" holds - ✅
pkg/auditonly whole-body switches + event exclusion — no field-level redaction (confirms "redaction is a build item") - ✅
defaultCacheCapacity = 1000; both.claude/rules/security.mdrules present - ✅ Both backend
Initializecalls fire withLATEST_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:
- PR #5839 — RFC says
ClassifyRevision/revision.gowas "added in #5839." It was added in #5834 and modified by #5839. - 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. - 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.
|
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 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
The decisive findingv1.6.1's v1.7.0-pre.1 is where the real support lands: a dedicated But the catch that matters for "lifting": in v1.7, 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)
Suggested amendment to D2
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 |
|
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:
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:
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. |
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
initializehandshake, 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
SessionIdManager,MultiSession, the session cache, Redis metadata, identity binding, cross-pod rehydration). Legacy is untouched.authzCallGate()closure,pkg/vmcp/conversion); the one new piece — the JSON-RPCresultTypeenvelope — is framed as a temporary bridge to be retired once v1.7's stateless server mode ships.(principal, backend)and initialized with the calling principal's existing outgoing auth.(iss, sub)reusing the existingpkg/vmcp/session/bindingencoding, per #5306), enforcement sequenced with MRTR since the MVP exposes no client-visible handle.Notes
toolhiveorigin/main.🤖 Generated with Claude Code