Skip to content

Serve MCP 2026-07-28 Modern stateless requests through vMCP - #5953

Merged
JAORMX merged 9 commits into
mainfrom
worktree-5910-server-stateless
Jul 24, 2026
Merged

Serve MCP 2026-07-28 Modern stateless requests through vMCP#5953
JAORMX merged 9 commits into
mainfrom
worktree-5910-server-stateless

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

ToolHive is adding support for the MCP 2026-07-28 ("Modern") stateless revision. The transport proxies already classify-and-forward Modern requests (#5884); this change makes the vMCP server serve them itself — bypassing the SDK Serve/session layer and dispatching straight to the already-stateless vMCP core, so a handshake-less client can talk to a virtual MCP server.

  • Hand-rolled Modern wire envelope (resultType, _meta serverInfo, Cacheable with cacheScope:"private" for identity-scoped results) — the imported go-sdk v1.6.1 has no Modern shapes, so the envelope is reproduced against v1.7.0-pre.3 and checked against its conformance fixtures.
  • classifyingHandler routes well-formed Modern requests to the hand-rolled dispatcher; Legacy and malformed-Modern behavior is unchanged.
  • Serves tools/resources/prompts list + call/read/get, server/discover (post-admission capability flags via core.Discover), ping, and completion/complete; notifications → 202, unimplemented → 404/-32601, malformed params → 400/-32602.
  • Security-critical: because it bypasses the SDK server, it re-homes that server's pre-dispatch authorization gate — Check* before dispatch and a dispatch-time ErrAuthorizationFailed both map to HTTP 403 + the matching deny message (so a denial audits as denied, not a 200 tool-error or -32603). Cedar itself fails closed (→ 403); the only "fail-open" is a non-authorization plumbing error from the pre-dispatch CheckToolCall (the sole gated verb that re-aggregates), which admits the request — but CallTool independently re-derives the decision before contacting any backend, so this never yields an unauthorized backend call. Successful backend calls label the audit backend_name.
  • server/discover is allow-listed on the single-server authz path (parity with initialize).

Closes #5910

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Integration tests (modern_realbackend_integration_test.go) drive real Modern JSON-RPC-over-HTTP through the fully-assembled server into a real core + real backend, asserting the wire bytes: no Mcp-Session-Id on Modern, envelope shapes, 202/404/400, server/discover flags through the admission seam, denial → 403. go-sdk v1.7 could not be imported (it would force an MVS bump of the whole module), so a hand-rolled raw HTTP client is used; independent-client + upstream-conformance validation for Modern is deferred to the #5837 harness.

API Compatibility

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

Changes

Four commits:

  1. Add core.Discover and result backend attribution — single-pass, post-admission capability flags for server/discover (one aggregatedView, same admission filters as the List* verbs), plus an audit-only BackendID (json:"-") on the call/read/get results.
  2. Serve MCP 2026-07-28 Modern stateless requests through vMCP — the envelope, the dispatcher, the re-homed authz gate, the classifyingHandler wiring, and discover/completion/ping serving.
  3. Allow-list server/discover on the single-server pathpkg/authz.
  4. Add Modern stateless integration tests.

Does this introduce a user-facing change?

Yes — a vMCP server now serves MCP 2026-07-28 ("Modern") stateless requests directly (no initialize handshake), in addition to the existing Legacy (2025-11-25) path. Modern dispatch is unconditional for well-formed Modern requests.

Special notes for reviewers

  • Size / split. This is ~3k LOC across ~17 non-generated files — well over the repo's ≤400-LOC / ≤10-file norm. It was planned as two PRs: PR(a) = the envelope + dispatcher + wiring + integration tests, PR(b) = server/discover + authz allow-list + completion/complete. Opened as a single PR per request; happy to split for review if preferred.
  • Kill-switch removed (deliberate deviation from the issue). Serve MCP 2026-07-28 stateless requests through vMCP (server-side Modern path) #5910 specified a default-off startup kill-switch. It was dropped after review established zero Legacy impact: ClassifyRevision routes every non-Modern request to the unchanged SDK path before dispatch, and Modern classification requires an exact MCP-Protocol-Version: 2026-07-28 header or a reserved io.modelcontextprotocol/* _meta key that no conformant Legacy client emits (initialize is force-Legacy). Routing tests cover this.
  • The envelope is a durable parallel serializer, not a delete-and-repoint shim. resultType/serverInfo are set by unexported go-sdk functions inside the dispatch path this bypasses, so a future v1.7 bump will not simply delete it.
  • Audit backend_name is labeled on the success path only. On a backend-call failure it is omitted (Legacy includes it because it pre-resolves the backend at session registration) — accepted, because the stateless dispatcher cannot pre-route (the backend is resolved inside the core).
  • server/discover runs one backend fan-out per call (uncached on the stateless path); a cross-request per-identity capability cache is tracked separately (Support MCP caching metadata (ttlMs/cacheScope) in vMCP #5761).

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Jul 23, 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.

Panel review (reviewed at 0016ed50)

I ran this through a multi-lens review — MCP wire-format/JSON-RPC conformance, authorization/security, Go correctness/concurrency, and vMCP architectural fit + test coverage. Overall this is a strong, unusually well-documented change: the re-homed authz gate is substantially correct, the concurrency story holds (the stateless dispatcher genuinely has no shared mutable state, so no keyedMutex needed), response-writer discipline is clean (marshal-before-write, write-once, return after every error write), and core.Discover's single-fan-out reuse of the existing admission filters is a nice consolidation. No blocking correctness or security defects found — in particular, no authorization bypass: every backend-reaching verb is gated, and a dispatch-time ErrAuthorizationFailed reliably maps to 403 + deny message (audited as denied, tested first in writeModernDispatchError).

A few should-fixes below, mostly around strict spec conformance and one test gap. None are merge-blockers on their own, but I'd like to see #1 and #2 addressed (or explicitly deferred with a tracking issue) before I approve.

⚠️ CI: the one red check — TestStandaloneSSE_ListChangedRefiltersThroughExistingMiddleware in pkg/transport/proxy/streamable — is in a package this PR does not touch (all changes are pkg/authz + pkg/vmcp). Looks unrelated/flaky; worth a re-run to confirm it's green before merge.

Should-fix

1. Spec MUST: a Modern POST that omits the MCP-Protocol-Version header is accepted instead of rejected. (classification.go:38-55, pkg/mcp/revision.go:283-299)
Per the draft Streamable HTTP spec, every POST to the MCP endpoint MUST carry MCP-Protocol-Version; a missing header is a validation failure that MUST return 400 + -32020. Today a body with a reserved _meta key + valid protocolVersion + clientCapabilities but no header classifies (RevisionModern, nil) and dispatches → 200. Relatedly, a reserved-key-only body with no version + no header returns -32602 where the spec wants -32020 (MissingModernMetadataError). Your own TODO at revision.go:292-297 calls this out exactly — and it's now resolvable, since classifyingHandler does have HTTP header context and can enforce header presence for Modern before dispatch.

2. No assembled-server test of the authz-denial path — the very reason the gate is re-homed. (session_management_realbackend_integration_test.go:84-97)
newRealTestHandler builds the server with no Authz config, so authzGateEnabled is false in every modern_realbackend_integration_test.go case. The gate is thoroughly unit-tested (TestDispatchModern_AuthzGate covers pre-dispatch 403, TOCTOU 403, fail-open, non-authz -32603, gate-off), but the middleware-ordering assumption you rely on — "audit is outer, so a dispatcher 403 audits as denied" — is never exercised end-to-end. One real-server test with authz enabled asserting a Cedar-denied Modern tools/call → 403 and a denied audit outcome would lock in the security-critical invariant.

3. -32603 returns err.Error() verbatim to the client. (modern_dispatch.go:125,130,141,152,163,192,426)
Two reviewers independently flagged this. The wrapped chains ("capability aggregation failed: %w", "routing tool %q: %w") can carry internal backend IDs / plumbing detail, which security.md says not to leak. I see your comment (modern_dispatch.go:415-420) arguing this is deliberate parity with the SDK/legacy path (conversion.ErrorToToolResult, serve_handlers.go). That's fair for the call/read/get branches — but could you confirm the legacy tools/list path exposed the same raw text? If not, the list-verb -32603 branches (125/130/141/152/163/192) are newly exposing it, and a generic message (with err logged server-side) would be safer there.

Discussion / judgment

4. Kill-switch removal & rollback story. The "zero Legacy impact" justification is correct but answers a different risk than the one #5910's flag mitigated. Legacy is genuinely untouched. The risk the flag covered is that Modern clients get a hand-rolled envelope mirroring go-sdk v1.7.0-pre.3 — a version this module does not import (it's on v1.6.1), so there's no compile-time check the shapes match and the envelope tests assert against hand-written expected JSON, not real SDK bytes. With the flag, disabling Modern let those requests fall through to the SDK's version downgrade — a strictly safer degraded mode than serving a possibly-wrong pre-release envelope, and it's a runtime lever rather than revert-and-redeploy. I'd lean toward restoring a default-off switch for a hand-rolled pre-release protocol, or at minimum getting explicit maintainer sign-off recorded here that unconditional serving is accepted until the #5837 conformance harness lands.

5. PR size / split. ~3.2k LOC / 20 files is well over the repo norm. The proposed (a) envelope+dispatcher+wiring+tests / (b) discover+authz+completion split is sensible and matches your commit boundaries — I'd take it for reviewability. (Note: PR(a) would need the server/discover + completion/complete switch cases stubbed to -32601 so it doesn't advertise methods it can't serve.)

Nits (non-blocking)

  • PR description wording: "fail-open on infra errors" overstates the exposure. Cedar runs in-process and fails closed (→ 403); the only fail-open trigger is a non-authz plumbing error from CheckToolCall, and CallTool independently re-aggregates and fails -32603 before reaching a backend — so fail-open never yields an unauthorized backend call. Worth rewording so a future reader doesn't "fix" it into a real hole. Also: only tools/call can fail open (CheckResourceRead/CheckPromptGet don't aggregate), yet gateDenied's comment reads as if all three do.
  • ping omits resultType (modern_dispatch.go:99-107). Draft schema marks Result.resultType a MUST for a 2026-07-28 server; a strict conformance suite would flag the bare {}. Your SDK-parity rationale is documented and real clients treat absent as "complete", so likely fine — flagging so it's a conscious call.
  • Gate coverage is duplicated across call_gate.go and modern_dispatch.go (two method→gate switches). core.Check* guarantees the decision can't diverge, but coverage could (a verb gated on one path, forgotten on the other). A single method→gate table driving both would remove the drift risk — good follow-up.
  • Dead nil-guards: newModernCallToolResult/ReadResource/GetPrompt guard result == nil, but callers deref result.BackendID first (modern_dispatch.go:227,250,282), so a nil would panic upstream — the guards are unreachable. Either drop them or make the deref defensive.
  • Explicit "id": null decodes to parsed.ID == nil → treated as a notification (202). Spec forbids null id, so low-risk, but worth confirming the parser distinguishes absent from explicit-null.

Nice work overall — the doc comments made this genuinely reviewable at this size. I'll keep watching and approve once the header-validation (#1) and denial-path test (#2) are sorted (or explicitly punted with issues).

🤖 Panel review assisted by Claude Code

@JAORMX

JAORMX commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

@jhrozek I went ahead and pushed two commits addressing the two should-fixes I flagged, to save you a round-trip — please review/squash/drop as you see fit.

1e6672ae — Reject header-less Modern requests with -32020 (should-fix #1)
Fixes the substantive half of the finding: a body that classifies Modern via its reserved _meta keys but omits the mandatory MCP-Protocol-Version header was dispatched and answered 200; it now returns 400/-32020, per the draft Streamable HTTP "Server Validation" rules. I kept it surgical and in-layer — the check lives in classifyingHandler and fires only on the rev == Modern && err == nil && header == "" path (the one that was wrongly accepted), so the existing -32602/-32022/-32020 rejection codes are untouched and no existing test changes. ClassifyRevision stays transport-agnostic; its TODO for generalizing this into the shared classifier (which would also cover the streamable/transparent proxies via transport context) is intentionally left in place as follow-up, since changing the shared signature would pull two other components into this PR.

I did not re-code the minor S2 nuance (reserved-key-only + no version + no header still returns -32602 rather than -32020) — both are 400 rejections, and touching it would require special-casing MissingModernMetadataError. Happy to add it if you'd rather close it fully.

c661d9ae — Test Modern authz denial through assembled server (should-fix #2)
Adds the Modern counterpart of TestIntegration_CedarAuthzDenialIsAudited: a policy-denied Modern tools/call must return 403 + JSON-RPC 403 and audit as denied through the fully assembled server (audit → parsing → classification → dispatchModern). This locks in the middleware-ordering invariant the re-homed gate depends on — previously only covered by dispatchModern unit tests. Reuses your buildCedarAuthzServer + postModern helpers; the shared audit-log helper lost its now-constant eventType param to satisfy unparam.

Verified locally: pkg/vmcp/server, pkg/mcp, pkg/authz pass with -race, and task lint-fix is clean. Watching CI.

On the discussion items (verbatim -32603 error text #3, kill-switch/rollback #4, PR split #5): I left those alone — they're design calls for you, not mechanical fixes. Your documented SDK-parity rationale for #3 stands; the only open question there was whether the legacy tools/list path exposed the same raw text.

🤖 Pushed with Claude Code

@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 24, 2026
JAORMX
JAORMX previously approved these changes Jul 24, 2026
@JAORMX

JAORMX commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

CI note on c661d9ae: everything is green except Tests / Test Go Code, and that red is two unrelated flaky integration tests, not the pushed changes:

  1. TestStandaloneSSE_ListChangedRefiltersThroughExistingMiddleware (pkg/transport/proxy/streamable) — the same expected 400, actual 200 failure that was already red on the pre-push run (0016ed50). This PR touches no code in that package.
  2. TestForwarding_Progress_RealBackend (pkg/vmcp/server) — timed out waiting for notifications/progress notification (a 15s timeout in a real-backend progress-forwarding test). It's a Legacy/session test; the header-presence check I added fires only when a request classifies Modern, so it can't affect this path.

gotestfmt hides passing tests, so those two are the complete failure set — the new tests (TestClassifyingHandler's missing-header case and TestIntegration_CedarAuthzDenial_ModernPath_IsAudited) passed, alongside green Lint, Codegen, E2E Lifecycle (all 3 node versions), CodeQL, and the security scans. Locally pkg/vmcp/server, pkg/mcp, and pkg/authz pass with -race. A re-run of the Test Go Code job should clear both flakes.

🤖 via Claude Code

@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 24, 2026
@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 24, 2026
jhrozek added a commit that referenced this pull request Jul 24, 2026
Per maintainer review (#5953): serving Modern (2026-07-28) requests
unconditionally is risky while the wire envelope is hand-rolled against
go-sdk v1.7.0-pre.3 — a version this module does not import, so the shapes
have no compile-time check and are validated only against hand-written test
JSON, not real SDK bytes. Restore a startup kill-switch so an operator can
fall back to the SDK path (a safe version-downgrade) via config rather than
revert-and-redeploy, until Modern is conformance-validated.

classifyingHandler dispatches a well-formed Modern request to the core only
when Config.ModernDispatchEnabled is set (default false); otherwise it falls
through to the SDK path. Fed once at the composition root from
TOOLHIVE_VMCP_MODERN_STATELESS (unset/invalid -> off, with a warning). Legacy
and malformed-Modern behavior is unchanged (the gate sits after
classification). The Modern dispatch/integration tests enable the switch; a
KillSwitchOff case asserts fall-through.

This kill-switch is temporary and tracked for removal by #5959 (once the
#5837 conformance harness lands or mcpcompat adopts go-sdk v1.7).

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 24, 2026
@jhrozek

jhrozek commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass. All points addressed:

CI: the one red check is the TestStandaloneSSE_ListChangedRefiltersThroughExistingMiddleware streamable flake — unrelated to this diff, matches your note. Left the PR-size split as a follow-up unless you'd rather take it split now.

JAORMX pushed a commit that referenced this pull request Jul 24, 2026
Per maintainer review (#5953): serving Modern (2026-07-28) requests
unconditionally is risky while the wire envelope is hand-rolled against
go-sdk v1.7.0-pre.3 — a version this module does not import, so the shapes
have no compile-time check and are validated only against hand-written test
JSON, not real SDK bytes. Restore a startup kill-switch so an operator can
fall back to the SDK path (a safe version-downgrade) via config rather than
revert-and-redeploy, until Modern is conformance-validated.

classifyingHandler dispatches a well-formed Modern request to the core only
when Config.ModernDispatchEnabled is set (default false); otherwise it falls
through to the SDK path. Fed once at the composition root from
TOOLHIVE_VMCP_MODERN_STATELESS (unset/invalid -> off, with a warning). Legacy
and malformed-Modern behavior is unchanged (the gate sits after
classification). The Modern dispatch/integration tests enable the switch; a
KillSwitchOff case asserts fall-through.

This kill-switch is temporary and tracked for removal by #5959 (once the
#5837 conformance harness lands or mcpcompat adopts go-sdk v1.7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JAORMX
JAORMX force-pushed the worktree-5910-server-stateless branch from 9098754 to 7cce772 Compare July 24, 2026 13:38
@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 24, 2026
@JAORMX

JAORMX commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto latest main + CI fix + fresh panel review

@jhrozek — heads up, I force-pushed a rebase of this branch onto current main (was based on 2da1fcbb; main is now 96eec94b, with #5965's vMCP list_changed merged since). The rebase applied cleanly, no conflicts, and go build, task lint (0 issues), and task test -race are green for the touched packages (pkg/vmcp/..., pkg/authz, pkg/mcp).

What I changed on top of your commits (one new commit)

  • Fixed the Codespell CI failure — the illustrative "ture" in the kill-switch comment (serve.go) tripped codespell. Reworded it rather than adding ture to the repo-wide ignore list (which would mask a common misspelling everywhere).
  • Removed 4 stray comment markers that aren't a ToolHive convention, keeping the explanatory prose.
  • Fixed a stale comment in the list-dispatch helpers that referenced a nextCursor envelope field that doesn't exist (the structs carry no cursor field; omitting the optional PaginatedResult.nextCursor is spec-valid).

The previously-red TestStandaloneSSE_ListChangedRefiltersThroughExistingMiddleware is in pkg/transport/proxy/streamable (untouched here); it passes in isolation and produced no failure post-rebase — unrelated flake.

Panel review (rebased tree) — no blocking defects on any axis

Re-reviewed across four axes with independent Opus agents:

  • Security / authz — CLEAN, no bypass. Every backend-reaching verb is gated pre-dispatch; fail-open is genuine defense-in-depth (CallTool/ReadResource/GetPrompt/Complete re-authorize in core_calls.go before any backend contact); TOCTOU → 403/denied verified end-to-end.
  • MCP wire/JSON-RPC conformance — CLEAN. Envelope shapes verified against the 2026-07-28 draft schema.ts: resultType, Cacheable placement (incl. ListResourceTemplatesResult cacheable, CallTool/GetPrompt/Complete not), cacheScope/ttlMs, io.modelcontextprotocol/serverInfo key, server/discover, and the full error-code/HTTP-status matrix (−32020/−32022/−32601/404, 202 for notifications). Missing-header → −32020/400 confirmed fixed.
  • Go correctness / concurrency — no blocking. No shared mutable state (no keyedMutex needed), writer discipline correct, the prior "dead nil-guard" concern is correctly resolved, rebase composes with Consume backend list_changed in vMCP and propagate to clients #5965.
  • vMCP fit / reuse / tests — clean. No anti-patterns introduced; kill-switch keeps the path inert by default; critical paths tested (authz-denial through the assembled server, TOCTOU 403, header validation).

Non-blocking follow-ups (deliberately NOT expanded here to keep PR scope)

  1. -32603 on tools/call/resources/read/prompts/get reuses err.Error() verbatim — confirmed genuine parity with the Legacy SDK path (not a regression), but worth routing both paths through the list-verb masking helper later, since it can surface upstream addressing.
  2. Extract a shared vmcp.Tool → mcp.Tool helper (modernToolFromDomain vs coreSessionTools) so Legacy/Modern parity is structural, not comment-enforced.
  3. A gate-coverage sync test (or shared method→gate table) across call_gate.go and modern_dispatch.go to remove drift risk.
  4. ping returns bare {} (no resultType) — matches go-sdk v1.7.0-pre.3 parity; strict server-side conformance would prefer resultType:"complete".

Kill-switch removal remains tracked in #5959.

🤖 Rebase + panel review assisted by Claude Code

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.72549% with 46 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.96%. Comparing base (5f6076e) to head (efb7421).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/server/modern_dispatch.go 90.96% 9 Missing and 5 partials ⚠️
pkg/vmcp/core/core_vmcp.go 54.54% 5 Missing and 5 partials ⚠️
pkg/vmcp/server/modern_envelope.go 94.62% 5 Missing and 5 partials ⚠️
pkg/vmcp/cli/serve.go 0.00% 8 Missing ⚠️
pkg/vmcp/core/core_calls.go 73.33% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5953      +/-   ##
==========================================
+ Coverage   71.88%   71.96%   +0.08%     
==========================================
  Files         715      717       +2     
  Lines       73501    73899     +398     
==========================================
+ Hits        52835    53185     +350     
- Misses      16883    16914      +31     
- Partials     3783     3800      +17     

☔ 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.

jhrozek and others added 9 commits July 24, 2026 14:05
Two core additions supporting the Modern (2026-07-28) dispatch path:

- core.VMCP.Discover computes server/discover's capability flags from one
  aggregatedView, deriving each flag through the same admission filters the
  List* verbs use, so the flags stay post-admission-filtered per identity and
  cannot drift. Mirrors the ListBackends(filterUnauthorized) precedent.
- ToolCallResult/ResourceReadResult/PromptGetResult gain a BackendID, set by
  the core from the routed target (empty for composite tools). It is audit-only
  (json:"-", never serialized) and lets the transport layer label which backend
  served a call, matching the Serve path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Serve Modern (2026-07-28) stateless requests by bypassing the SDK Serve/
session layer and dispatching straight to the already-stateless vMCP core.
classifyingHandler routes a well-formed Modern request to a hand-rolled
dispatcher; Legacy and malformed-Modern requests behave exactly as before.

The dispatcher hand-rolls the Modern wire envelope (resultType, _meta
serverInfo, Cacheable with cacheScope "private" for identity-scoped results)
because the imported go-sdk v1.6.1 has no Modern shapes. It serves
tools/resources/prompts list + call/read/get, server/discover, ping and
completion/complete; notifications -> 202, unknown -> 404/-32601, malformed
arguments (and a missing completion argument name) -> 400/-32602.

Because it bypasses the SDK server, it re-homes that server's pre-dispatch
authorization gate: Check* before dispatch and a dispatch-time
ErrAuthorizationFailed both map to HTTP 403 + the matching DenyMessage (so a
denial audits as "denied"), fail-open on infra errors. The incoming request
context is passed through unmodified so forwarded-header backend auth works.
Successful tools/call, resources/read and prompts/get label the audit
backend_name (success path only; see comment). server/discover returns
post-admission capability flags via core.Discover (no descriptor arrays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now that server/discover is served, allow-list it in pkg/authz.Middleware:
DiscoverResult carries the same Capabilities/Instructions shape
InitializeResult does, and initialize is already always-allowed on this path,
so discover adds no new exposure class. This map governs only the single-
server / proxy-runner path; vMCP's Modern dispatcher does not consult it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive real Modern (2026-07-28) JSON-RPC-over-HTTP through the fully assembled
server into a real core aggregating a real backend, asserting the wire bytes
a client sees. A hand-rolled raw HTTP client sends Modern requests (go-sdk
v1.7 cannot be imported without an MVS bump). Covers tools/call round-trip
with no Mcp-Session-Id, tools/list and server/discover through the admission
seam, completion/complete, ping, notification 202, unknown 404/-32601,
malformed arguments 400/-32602.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A request that classifies Modern (2026-07-28) via its reserved
io.modelcontextprotocol/* _meta keys but omits the mandatory
MCP-Protocol-Version HTTP header was dispatched and answered 200. The
draft Streamable HTTP "Server Validation" rules make a missing required
standard header a -32020 rejection, so a header-less Modern POST must be
refused, not served.

ClassifyRevision is transport-agnostic (it also serves header-less
stdio) and deliberately defers the header-presence rule to the HTTP
layer, per its own TODO. Enforce it in classifyingHandler: once a
request classifies Modern with no error, an empty MCP-Protocol-Version
header is rejected as -32020 before dispatch. This touches only the
otherwise-accepted path, so the existing -32602/-32022/-32020 rejection
codes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WG3mSjVGWNc8nfgbkdd79
The Modern stateless dispatcher bypasses the SDK server and its
CallGate, so it re-homes the pre-dispatch authorization gate itself.
That gate was covered only by dispatchModern unit tests; the
assembled-server denial path (audit -> parsing -> classification ->
dispatchModern) had no coverage, unlike the Legacy path.

Add the Modern counterpart of TestIntegration_CedarAuthzDenialIsAudited:
a policy-denied Modern tools/call must return HTTP 403 + JSON-RPC 403
and be audited with outcome "denied", proving the audit middleware wraps
the re-homed gate exactly as it wraps the SDK CallGate. Drop the
now-constant eventType parameter from the shared audit-log helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WG3mSjVGWNc8nfgbkdd79
- List/discover -32603 responses now return a generic "internal error" to
  the client and log the full error server-side, instead of echoing wrapped
  aggregation/routing detail (backend IDs, upstream addressing) — the Legacy
  path never surfaces a list error to the client at all, and security.md
  forbids leaking internal plumbing. tools/call/resources/read/prompts/get and
  completion keep err.Error() (documented SDK/Legacy parity).
- Drop the unreachable result==nil guards in the call/read/get envelope
  builders (callers deref result.BackendID first, so a nil panics upstream);
  newModernComplete keeps its guard, which is live.
- Clarify gateDenied's comment: only tools/call's CheckToolCall re-aggregates
  and can fail open on a non-authz error; CheckResourceRead/CheckPromptGet
  always classify as ErrAuthorizationFailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per maintainer review (#5953): serving Modern (2026-07-28) requests
unconditionally is risky while the wire envelope is hand-rolled against
go-sdk v1.7.0-pre.3 — a version this module does not import, so the shapes
have no compile-time check and are validated only against hand-written test
JSON, not real SDK bytes. Restore a startup kill-switch so an operator can
fall back to the SDK path (a safe version-downgrade) via config rather than
revert-and-redeploy, until Modern is conformance-validated.

classifyingHandler dispatches a well-formed Modern request to the core only
when Config.ModernDispatchEnabled is set (default false); otherwise it falls
through to the SDK path. Fed once at the composition root from
TOOLHIVE_VMCP_MODERN_STATELESS (unset/invalid -> off, with a warning). Legacy
and malformed-Modern behavior is unchanged (the gate sits after
classification). The Modern dispatch/integration tests enable the switch; a
KillSwitchOff case asserts fall-through.

This kill-switch is temporary and tracked for removal by #5959 (once the
#5837 conformance harness lands or mcpcompat adopts go-sdk v1.7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rebased branch tripped the codespell CI check on the illustrative
"ture" typo in the kill-switch comment. Reword it to avoid the flagged
token rather than add "ture" to the repo-wide ignore list, which would
suppress a genuinely common misspelling everywhere.

Also drop the ad-hoc comment markers that are not a ToolHive convention
(keeping the explanatory prose) and correct a stale comment that
referenced a nextCursor envelope field that does not exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WG3mSjVGWNc8nfgbkdd79
@JAORMX
JAORMX force-pushed the worktree-5910-server-stateless branch from 7cce772 to efb7421 Compare July 24, 2026 14:05
@JAORMX

JAORMX commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto latest main (now includes the swagger-dedup fix #5970), so the Verify Swagger Documentation check that was red here — a pre-existing flaky-codegen artifact on main, unrelated to this PR — is resolved. Head is now efb74215; CI re-running.

@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 24, 2026
@JAORMX
JAORMX merged commit 0017afc into main Jul 24, 2026
71 of 73 checks passed
@JAORMX
JAORMX deleted the worktree-5910-server-stateless branch July 24, 2026 15:07
jhrozek added a commit that referenced this pull request Jul 25, 2026
Rebasing onto origin/main picked up #5953 (Serve MCP 2026-07-28 Modern
stateless requests through vMCP), which intentionally flipped
server/discover in pkg/authz/middleware.go from not-allow-listed
(default-deny, 403) to always-allowed. The dual-era authz guard, which
asserted a 403, correctly failed against the new behavior.

Update it to the new contract: server/discover is allowed under authz
(200), which is a positive result -- a real Modern client's discover no
longer gets 403'd into a Legacy downgrade, so Modern-through-authz works.
The policy-permitted echo call is still asserted. Per #5953's own comment,
discover is not covered by the response-filter and passes through
unfiltered; that is a documented, deliberate tradeoff in that PR, not
something this test enforces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jhrozek added a commit that referenced this pull request Jul 25, 2026
Rebasing onto origin/main picked up #5953 (Serve MCP 2026-07-28 Modern
stateless requests through vMCP), which intentionally flipped
server/discover in pkg/authz/middleware.go from not-allow-listed
(default-deny, 403) to always-allowed. The dual-era authz guard, which
asserted a 403, correctly failed against the new behavior.

Update it to the new contract: server/discover is allowed under authz
(200), which is a positive result -- a real Modern client's discover no
longer gets 403'd into a Legacy downgrade, so Modern-through-authz works.
The policy-permitted echo call is still asserted. Per #5953's own comment,
discover is not covered by the response-filter and passes through
unfiltered; that is a documented, deliberate tradeoff in that PR, not
something this test enforces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jhrozek added a commit that referenced this pull request Jul 26, 2026
Rebasing onto origin/main picked up #5953 (Serve MCP 2026-07-28 Modern
stateless requests through vMCP), which intentionally flipped
server/discover in pkg/authz/middleware.go from not-allow-listed
(default-deny, 403) to always-allowed. The dual-era authz guard, which
asserted a 403, correctly failed against the new behavior.

Update it to the new contract: server/discover is allowed under authz
(200), which is a positive result -- a real Modern client's discover no
longer gets 403'd into a Legacy downgrade, so Modern-through-authz works.
The policy-permitted echo call is still asserted. Per #5953's own comment,
discover is not covered by the response-filter and passes through
unfiltered; that is a documented, deliberate tradeoff in that PR, not
something this test enforces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jhrozek added a commit that referenced this pull request Jul 26, 2026
Rebasing onto origin/main picked up #5953 (Serve MCP 2026-07-28 Modern
stateless requests through vMCP), which intentionally flipped
server/discover in pkg/authz/middleware.go from not-allow-listed
(default-deny, 403) to always-allowed. The dual-era authz guard, which
asserted a 403, correctly failed against the new behavior.

Update it to the new contract: server/discover is allowed under authz
(200), which is a positive result -- a real Modern client's discover no
longer gets 403'd into a Legacy downgrade, so Modern-through-authz works.
The policy-permitted echo call is still asserted. Per #5953's own comment,
discover is not covered by the response-filter and passes through
unfiltered; that is a documented, deliberate tradeoff in that PR, not
something this test enforces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jhrozek added a commit that referenced this pull request Jul 27, 2026
* Add raw MCP HTTP client for e2e proxy tests

The dual-era stateless proxy e2e tests must emit traffic a conforming
MCP client never would: spoofed Mcp-Session-Id, colliding JSON-RPC ids,
malformed _meta, and oversized/truncated/batch bodies. A hand-rolled,
stdlib-only client gives the byte-level control those tests need without
importing go-sdk v1.7 into the module (which would force an MVS bump).

Adds a raw client that builds Modern (2026-07-28) and Legacy requests,
allows arbitrary header/id/session/_meta overrides and raw bodies, and
reuses one concurrency-safe http.Client for the crown-jewel fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add golden Modern-request oracle for raw client

The raw client's Modern _meta encoding was written and asserted by the
same author, a self-review blind spot: a wrong reserved key, wrong
nesting, or wrong value type could pass its own tests. This adds an
independent oracle — a 2026-07-28 tools/list request emitted by a real
go-sdk v1.7.0-pre.3 client — and asserts NewModernRequest reproduces its
_meta reserved-key set, nesting, and value kinds. A typo'd key or a
non-object clientCapabilities now fails the test.

The fixture is a committed static artifact; it is regenerated by pointing
a go-sdk v1.7 client at yardstick-server --stateless (>= v1.2.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dual-era cross-delivery e2e test

The streamable proxy's Modern (2026-07-28) routing must give every
request a fresh routing token and never fall through to a client-supplied
Mcp-Session-Id -- otherwise, when concurrent requests share the same
(session, id) pair, one client's response can be delivered to another: a
confidentiality bug. Unit tests can't observe this; it only surfaces when
real traffic races through a running proxy.

Drives yardstick-server v1.2.0 in barrier mode (buffers N requests, then
releases together) behind the streamable proxy, firing N concurrent
Modern requests that share one Mcp-Session-Id and one JSON-RPC id and
differ only by a per-request nonce. Asserts conservation: exactly N
distinct nonces, each response carrying the nonce of the request that
sent it, zero timeouts, zero non-200. A one-shot positive control fires
N-1 and asserts they are withheld until the barrier's safety timeout,
so a barrier that silently degraded to a no-op fails the test rather
than passing while forcing zero collisions.

Bumps yardstick 1.1.1 -> 1.2.0 (adds barrier mode) and pre-pulls it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Guard Modern path from client session-id reuse

The streamable proxy's Modern (2026-07-28) branch mints a fresh routing
token and ignores any client-supplied Mcp-Session-Id. Its safety rests on
an unenforced assumption: the client session id has no downstream
authority here (stateless stdio backend), unlike the transparent proxy,
which maps a session id to a backend and validates it. The existing
Modern tests all use unregistered session ids, so none would catch a
regression that reused a *known* client session id as the routing token
(fall-back-on-lookup-hit) -- silently reopening the validation-bypass
class the transparent proxy already hardened against.

Adds TestModernNeverReusesClientSessionIDAsRoutingToken, which registers
a real session and asserts the Modern branch still mints fresh UUID
tokens distinct from it (mutation-verified: fails if the branch reuses a
known session id, while the pre-existing tests stay green). Strengthens
the resolveSessionForRequest doc comment to tie the safety to the
no-downstream-authority assumption and warn against reintroducing a
lookup on this path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dual-era hostile-input e2e test

The transparent proxy classifies each request's MCP revision and must
reject malformed or hostile Modern (2026-07-28) inputs at the edge,
before contacting the backend. This drives that boundary with a raw
client through a session-aware transparent proxy fronting an in-process
mock.

Covers, each asserting the exact JSON-RPC error code, error.data, echoed
id, and that the backend was not contacted: header/body version mismatch
(-32020), unsupported version (-32022), missing clientCapabilities
(-32021), and reserved _meta with no valid version (-32602). Also asserts
a well-formed Modern request with a foreign Mcp-Session-Id is rejected
404 -32001 (correct-by-design: the session guard keys on header presence,
not the client-forgeable revision) and with none is forwarded once (200);
an oversized body is edge-rejected 413 while a truncated body is forwarded
as Legacy passthrough (the proxy does not validate JSON syntax); and the
proxy still serves a well-formed request afterward. Documents the
deferred/known-gap cases (Mcp-Method/Mcp-Name, batch, GET/DELETE 405).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dual-era concurrent mixing e2e test

Legacy (session-based) and Modern (stateless) clients must be able to
share one streamable-proxy instance concurrently without cross-delivering
responses or losing either era's semantics. This fires several Legacy
sessions and stateless Modern requests together each round (released via
a shared start channel so both eras are genuinely in flight, asserted by
a per-round timestamp-overlap check) and correlates every response by a
unique _meta nonce -- a wrong nonce is a cross-era or cross-client leak.

Era-distinctness is asserted where it is observable: a negative probe
confirms a bogus Mcp-Session-Id is rejected (404 -32001), proving Legacy
enforces its session rather than merely minting one, while Modern
responses must carry no Mcp-Session-Id. Legacy initialize assigning a
session id is the standing Legacy-behavior gate.

Scope: with an echo backend this proves response-body delivery
correctness, not session-metadata isolation; forced-collision is the
crown jewel's job. Accept: text/event-stream is intentionally not set --
it flips this proxy to an SSE response body the raw client does not
parse; the plain-JSON path is what this tier exercises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dual-era Tier-1 functional e2e tests

Tier 1 exercises the proxies with a real, independent MCP SDK client
(yardstick-client, go-sdk v1.7.0-pre.3) so it catches spec misreadings a
hand-rolled client would share with the proxy code. Modern fidelity runs
yardstick-client against the streamable proxy and confirms via -action
info that it genuinely negotiated 2026-07-28 with an empty session id
(server/discover, no initialize) -- not a masked downgrade. Legacy runs
it against a non-stateless backend whose discover omits 2026-07-28,
forcing the SDK's fall-back to a 2025-11-25 initialize handshake that the
transparent proxy must faithfully pass through (session id preserved). A
single raw-client check covers the transparent proxy's own Modern
no-Mcp-Session-Id path.

Also guards server/discover under authz: with a Cedar policy enabled it
must 403 (default-deny, backend not contacted) -- a regression alarm
against allow-listing discover without response-filtering (it would
bypass ResponseFilteringWriter) -- while a permitted echo call still
succeeds. Notes the non-conformant denial envelope (#5950).

yardstick-client is go-installed into a scratch GOBIN (separate module,
does not touch this repo's go.mod), version derived from the yardstick
image tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dual-era observability e2e test

The existing telemetry e2e tests only log-grep for the word span; none
assert span attribute values. This adds a real span-attribute assertion
for the Modern (2026-07-28) path: a Modern request's server span must
carry mcp.protocol.version=2026-07-28 and mcp.client.name (sourced from
_meta.clientInfo, the only client-attribution source in Modern since
there is no initialize handshake).

Stands up an in-process OTLP/HTTP receiver (httptest.Server decoding real
ExportTraceServiceRequest protobuf, race-free span store) and points
thv run --otel-endpoint at it, fires one Modern tools/call carrying
_meta.clientInfo, and Eventually asserts a span with both attributes
(polling since the BatchSpanProcessor flushes on a timer). No new
dependencies: the OTLP proto types and protobuf runtime were already in
the module graph (promoted from indirect to direct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Test Modern load leaves no accumulated state

The stateless Modern (2026-07-28) path must not leak state under load
(the unbounded-growth concern behind toolhive-core#169). This white-box
integration test drives a burst of concurrent Modern requests through the
streamable proxy's real dispatch lifecycle and asserts, after they all
complete, that no per-request state is left behind: the sessionManager
holds zero sessions (Modern registers none), the waiters and idRestore
maps are empty (per-request routing tokens are cleaned up -- cleanup is
deferred in doRequest and runs before any response byte, so the check is
deterministic), and the goroutine count is flat.

Concurrency is capped with a semaphore (well under the proxy's fixed
message-channel buffer) to sidestep an unrelated backpressure gap (#5952)
without weakening the invariant, which is about state left after N
requests, not N being simultaneous. Mutation-verified: blanking the
waiter cleanup makes it fail with the maps holding every request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dual-era resilience e2e tests

Tier-3 resilience: backend faults must degrade gracefully. Uses
yardstick's hang and crash fault modes behind the streamable proxy.

The hang spec proves request isolation: with one request wedged at the
backend (bounded by the proxy request timeout, returning 504), a second
concurrent request still completes promptly -- one slow backend call
does not block unrelated traffic -- and a fresh Legacy initialize +
session-keyed call afterward proves the backend process and Legacy
session semantics survive the hang. The crash spec proves fail-fast: a
backend that exits mid-request surfaces a clean 5xx well before the
recovery window, not a hang.

Redis-unavailable-to-503 is deferred to the k8s tier, where Redis
session storage is actually wired via the operator (no thv run CLI flag
exposes it); full crash-recovery latency is out of scope (documented).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dual-era k8s multi-replica e2e tests

The operator-deployed dual-era proxy over a shared session store is the
production shape the design targets, so it needs its own tier. Deploys a
multi-replica yardstick 1.2.0 MCPServer (Replicas + BackendReplicas,
streamable-http, STATELESS/BACKEND_MODE echo) with Redis SessionStorage
and SessionAffinity: None, in a dedicated namespace so its destructive
Redis ops don't collide with sibling suites under parallel Ginkgo.

Asserts: the proxy Service actually carries SessionAffinity: None (a
regression to the ClientIP default is caught by a direct field read) and
sessionless Modern requests succeed across the multi-replica deployment;
mixed Legacy+Modern over the shared Redis stays isolated; a backend pod
eviction heals back to full replicas with Modern traffic continuing;
Redis-unavailable yields 503 (storage error, not 404), and after restore
a fresh session works while the pre-outage session correctly 404s.

The distinct-backend-pod distribution proof was intentionally NOT
included: kube-proxy is L4/per-connection and both proxy hops pool
connections, so pod distribution is not observable or controllable from
the test -- a count could read 1 under perfectly correct behavior. The
in-file comment documents this so it isn't reintroduced as a flaky/unsound
check. Authored to run in test-e2e-lifecycle.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Adapt server/discover authz test to #5953

Rebasing onto origin/main picked up #5953 (Serve MCP 2026-07-28 Modern
stateless requests through vMCP), which intentionally flipped
server/discover in pkg/authz/middleware.go from not-allow-listed
(default-deny, 403) to always-allowed. The dual-era authz guard, which
asserted a 403, correctly failed against the new behavior.

Update it to the new contract: server/discover is allowed under authz
(200), which is a positive result -- a real Modern client's discover no
longer gets 403'd into a Legacy downgrade, so Modern-through-authz works.
The policy-permitted echo call is still asserted. Per #5953's own comment,
discover is not covered by the response-filter and passes through
unfiltered; that is a documented, deliberate tradeoff in that PR, not
something this test enforces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Bump operator e2e yardstick image to 1.2.0

The operator e2e workflow docker-pulls and kind-loads YARDSTICK_IMAGE
into the cluster. The operator acceptance tests (the dual-era k8s spec
and ratelimit) now reference yardstick 1.2.0 via images.YardstickServerImage,
so the preloaded image must match -- otherwise 1.2.0 is never loaded into
kind and those specs can't find their backend image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix dual-era e2e CI: headers, lint, spelling

The E2E Lifecycle (k8s) tier drove Modern tools/call requests at a real
go-sdk v1.7 streamable-HTTP backend, which rejects any Modern request
missing the Mcp-Method header (and Mcp-Name for name-bearing methods)
with -32020. NewModernRequest omitted both, so every k8s Modern request
got HTTP 400.

- NewModernRequest now emits the full conformant go-sdk v1.7 wire shape:
  Mcp-Method on every Modern request and Mcp-Name (plain target name) for
  tools/call / resources/read / prompts/get. The ToolHive single-server
  proxy never reads these headers, so the transparent/streamable tiers
  (hostile-input, crown-jewel, mixing) are unaffected; only a real Modern
  backend requires them.
- Mark TestModernLoadDoesNotAccumulateState //nolint:paralleltest: it
  reads process-wide runtime.NumGoroutine() and starts an HTTP proxy, so
  it must run serially. Matches the package's existing body_limit_test.go.
- "unparseable" -> "unparsable" (codespell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Send required Accept header on raw MCP requests

The k8s multi-replica tier drives Modern requests at a real go-sdk v1.7
streamable-HTTP backend (yardstick), which rejects any POST whose Accept
header does not list both application/json and text/event-stream with
HTTP 400 -- before any classification. The raw client sent no Accept
header, so every k8s request got 400 on request 0.

This was masked in local reproduction because curl auto-sends
"Accept: */*", which go-sdk's streamableAccepts treats as satisfying
both media types. Verified on the wire: with Accept absent the backend
returns 400 "Accept must contain both ...", with it present, 200.

The other tiers never hit a real go-sdk HTTP backend -- the transparent
proxy's in-process mock and the streamable proxy's stdio backend both
ignore Accept -- which is why they passed and hid this. thv itself is
correct: it transparently forwards the client's headers; a real MCP
client always sends Accept, so this deployment works in production. The
gap is in the test harness, not the proxy.

Default Accept in SendRaw alongside Content-Type (a transport-level
requirement for both eras), overridable by a caller that sets its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Drop Legacy-session k8s specs; single-server can't bridge eras

The k8s tier deploys one STATELESS=true backend (required so Modern
2026-07-28 per-request traffic works) but two specs then asserted Legacy
session semantics against it: "serves mixed Legacy and Modern traffic"
expected a Legacy initialize to mint an Mcp-Session-Id, and the Redis-503
spec built on the same session. A stateless go-sdk backend issues no
sessions, and a single backend cannot be both stateless (for Modern
clients) and session-issuing (for Legacy clients) at once. Verified on
the wire: Legacy initialize against a stateless yardstick returns 200 but
no Mcp-Session-Id, exactly as CI showed.

Serving both eras over one backend is cross-generation bridging, which
the epic (#5743) scopes to vMCP -- the single-server proxy does
per-request version discrimination, not generation bridging. That bridge
is design-only today (#5756, unimplemented). So these two specs tested an
unsupported scenario against the wrong deployment type; this is a test
defect, not a proxy bug.

Remove both specs (and the now-orphaned scaleRedis helper / appsv1
import). The tier keeps the three Modern specs -- pods-ready + Redis
wiring, Modern multi-replica routing, pod-eviction self-heal -- which is
the correct single-server coverage. A file-level note records that when
the vMCP cross-generation bridge (#5743/#5756) is implemented, the
mixed-era and session-store-outage coverage should be re-added as a vMCP
e2e test, not here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Revert global Accept default; opt in only for real backends

The near-final "Send required Accept header" commit made SendRaw set
Accept: application/json, text/event-stream on every request. The
ToolHive streamable proxy switches to its SSE response path for any
Accept containing text/event-stream, emitting `data: {...}` frames, but
the raw client's populateEnvelope only parses a plain JSON body -- so on
the streamable-proxy specs resp.Result stayed nil and nonce extraction
returned "". That silently broke the four specs that parse response
bodies (cross-delivery crown jewel, concurrent mixing, resilience
hang/crash), and directly contradicted dual_era_mixing_test.go's own
comment, which had already documented why Accept must NOT be set here.
The resilience specs also assert HTTP 504 / >=500, which the SSE path
never returns (it answers 200 with an in-band error frame) -- so SSE
unwrapping alone would not have fixed them.

Revert the global default so proxy requests get a plain JSON body again.
The k8s tier legitimately needs the header (its real go-sdk backend 400s
without it and only checks status), so add an explicit opt-in --
RawRequest.WithStreamableAccept() -- and call it on the two k8s Modern
requests. This restores the documented, verified plain-JSON behavior for
every proxy spec while keeping the k8s tier correct.

Verified locally: LABEL_FILTER=dual-era task test-e2e -> 11/11 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Isolate yardstick 1.2.0 to the dual-era tests

Bumping the shared YardstickServerImage to 1.2.0 (go-sdk v1.7) broke the
sibling vMCP/virtualmcp e2e suites, which were written for a Legacy
backend. A v1.7 server in session mode (how those tests deploy it)
answers server/discover with a Modern 200, so the vMCP classifies it
Modern and drives the Modern data-plane -- which that same server then
rejects ("2026-07-28 is only supported on stateless HTTP servers"). The
vMCP has no Legacy fallback after a conclusive Modern probe, so those
backends never aggregate (surfacing as "MCP server connection timed
out" / backend-not-ready). vMCP support for Modern backends is #5993.

Keep the shared YardstickServerImage on Legacy 1.1.1 for the operator
and vMCP suites; add YardstickServerImageDualEra (1.2.0) used only by
the dual-era transport-proxy tests, which handle Modern correctly. Wire
the workflows to pull/load both images (vmcp bucket -> 1.1.1, proxy
bucket -> 1.2.0; lifecycle loads both into kind).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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 vMCP (server-side Modern path)

4 participants