Add live e2e tests for dual-era stateless proxy behavior - #5981
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5981 +/- ##
==========================================
- Coverage 72.10% 72.06% -0.05%
==========================================
Files 718 718
Lines 74465 74465
==========================================
- Hits 53695 53663 -32
- Misses 16921 16968 +47
+ Partials 3849 3834 -15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
The test design here is genuinely strong — the barrier-backed positive control in the crown jewel, the mutation-verified session-id-reuse guard, the precise hostile-input error codes, and the independent go-sdk golden oracle are exactly the anti-false-green rigor the PR promises. One blocker before merge, though, introduced by the near-final commit 01dea165 ("Send required Accept header on raw MCP requests").
Blocker — the default Accept header pushes streamable-proxy responses onto the SSE path the raw client can't parse.
SendRaw now unconditionally sets Accept: application/json, text/event-stream (mcp_raw_client.go:335). The streamable proxy branches to its SSE response path for any Accept containing text/event-stream (streamable_proxy.go:610, handlePost → handleSingleRequestSSE), emitting data: {…}\n\n. But populateEnvelope does a plain json.Unmarshal of the whole body with no SSE-frame unwrapping (mcp_raw_client.go:382), so on the streamable-proxy specs resp.Result stays nil and extractNonce returns "".
Impact on the --transport stdio (streamable-proxy) tier:
dual_era_proxy_test.go(crown jewel):assertCorrelated'sgotNonce == noncefails for every request — the confidentiality boundary this PR exists to guard is never actually exercised.dual_era_mixing_test.go: same nonce failure. Its own file comment (lines ~51-58) still documents the opposite premise — "Deliberately NOT setting Accept … Without the header the proxy returns a plain JSON response, which is what every other spec in this suite relies on" — which01dea165silently invalidated.dual_era_resilience_test.go: the hang context expects504and the crash context>= 500, but the SSE path returns an in-band200with an error frame.
Everything else is unaffected and sound: the transparent-proxy specs, the hostile-input suite (rejected 400/404 before any streaming), the golden oracle, the routing-token guard, and the unit/integration tests.
Suggested fix (either — you documented both in the mixing-test comment):
- Teach
populateEnvelopeto unwrap a single SSEdata:frame whenContent-Type: text/event-stream, or - Have the streamable-proxy specs send
Accept: application/jsononly.
Note the k8s tier legitimately needs the full Accept (the real go-sdk backend 400s without it), so it can't just be dropped globally — option 1 is probably the cleaner single fix. Please also update the now-stale dual_era_mixing_test.go comment.
(FWIW the earlier "11/11 pass" run predates 01dea165 — the commit that added the default header is second from the tip.)
Happy to re-review as soon as the streamable-proxy specs are parsing real responses again.
5fa6712 to
f9d8baa
Compare
|
Good catch, and thanks for the precise pointer to I went with a variant of your option 2 rather than option 1 (SSE unwrapping), for the reason you flagged yourself: the resilience specs assert HTTP Concretely (
Verified locally this time (which I should have done on the header commit): |
f9d8baa to
14d6d17
Compare
14d6d17 to
d800074
Compare
JAORMX
left a comment
There was a problem hiding this comment.
The blocker is resolved by d800074 ("Revert global Accept default; opt in only for real backends") — thanks for the quick turnaround. Verified:
SendRawno longer sets a defaultAccept, so the streamable proxy returns a plain-JSON body again andpopulateEnvelopeparses it — the cross-delivery crown jewel, mixing, and resilience specs get realResult/nonce values back (and the resilience 504/>=500assertions see real HTTP status, not an in-band SSE 200).- The real-backend need is handled with an explicit opt-in,
RawRequest.WithStreamableAccept(), called only on the two k8s-tier Modern requests (dual_era_k8s_test.go:240,271) — no proxy spec setsAccept. - The
dual_era_mixing_test.gocomment is accurate again (it documents exactly whyAcceptmust not be set here), and the unit test now asserts no-Accept-by-default + opt-in.
Everything I flagged as sound before (barrier positive-control, mutation-verified session-id guard, precise hostile-input codes, independent golden oracle) is unchanged. Approving.
One heads-up unrelated to this PR: main is currently red from a nil-pointer panic in pkg/vmcp/client (a struct-literal test from #5990 hits the new resolveAuthStrategy path added by #5980), so your Go-test check may show that failure until main is fixed — it's not caused by anything in this PR.
d800074 to
637c0c9
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-approving after the rebase onto the now-green main (head 637c0c9d, base b928cc31). This is a clean replay — identical changeset to what I approved earlier (17 commits, +3053/−6, 18 files, no content change), just picking up the #5999 CI fix so this can get a green run. My earlier review stands: the Accept/SSE blocker fix (d800074) is intact and the test design (barrier positive-control, mutation-verified session-id guard, precise hostile-input codes, independent golden oracle) is unchanged. LGTM.
|
CI note after re-running the failed jobs twice on the current head (
Both are the same signature (vMCP server/backend bring-up + tool aggregation) and both are pre-existing suites unrelated to this test-only PR — its dual-era proxy specs and the rest of the e2e matrix (proxy, conformance, operator, core, unit, lint) are green, and the k8s dual-era spec added here is not among the failures. So this blocks a clean green here but isn't caused by the change, and it almost certainly affects other PRs too. Worth a deflake pass on the vMCP e2e bring-up (core |
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
637c0c9 to
aca00b6
Compare
Move ToolHive onto toolhive-core v0.0.34, which embeds go-sdk v1.7.0-pre.3 — the SDK that implements the new MCP 2026-07-28 "stateless" revision. This lets vMCP negotiate and speak both revisions per backend: Legacy 2025-11-25 and Modern 2026-07-28. The build is unchanged (the mcpcompat shim absorbs the go-sdk API change); this fixes the behavioral gaps that only surface once the real v1.7 SDK is imported, without regressing 2025-11-25 (dual-revision is preserved). Supersedes the prior "hand-rolled Modern envelope, do not import go-sdk v1.7 into the root module" approach: importing the SDK is what makes the new spec reachable. CC @jhrozek (probe/classification and #5981 author). - Revision classification (the crux): decide a backend's revision by whether its server/discover result advertises 2026-07-28 in supportedVersions — the SEP-2575 negotiation signal, and exactly what go-sdk's own reference client keys on. The old "any clean discover is Modern" heuristic mis-classified stateful 2025-11-25 backends, which under v1.7 answer discover too (negotiating down). New sentinel errModernNegotiatedDown wires the negotiate-down case through the existing reclassify machinery. - Strip reserved io.modelcontextprotocol/* _meta at the Legacy backend tool-call egresses (httpBackendClient and the session client): these per-hop protocol-control keys must not cross onto the vMCP->backend hop (a stateful backend 400s them under v1.7). No-op for Legacy traffic. - Test adaptations for the real v1.7 client (server/discover-first over SSE; SSE-forwarding fake answers discover with -32601), a nil-registry test-construction fix, and new real-backend pin tests that assert stateful->Legacy / stateless->Modern so a future SDK discover-semantics change fails loudly here. Follow-up: #5992 (revision-cache TTL re-probe). Part of #5743 (#5754). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WG3mSjVGWNc8nfgbkdd79
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>
#5981 temporarily split the yardstick image -- Legacy 1.1.1 for the operator/vMCP suites, Modern 1.2.0 only for the dual-era tests -- because the vMCP classified a session-mode v1.7 backend as Modern (from a server/discover 200) and could not drive it. #5993 fixed that: the vMCP now keys classification on server/discover's supportedVersions, so a session-mode v1.7 backend (which omits 2026-07-28) is correctly classified Legacy and driven via the Legacy data plane. With that in main, the split is no longer needed. Collapse back to a single YardstickServerImage at 1.2.0 for every e2e suite, drop the YardstickServerImageDualEra constant, and unify the workflow pulls/loads. Verified: TestProbeRevision_RealBackends (a real session-mode go-sdk v1.7 backend classifies Legacy) passes on main, and the dual-era e2e suite passes locally on the unified 1.2.0 image. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
PR #5981 removed two specs from the single-server dual-era k8s test because they cannot work on one MCPServer: a single go-sdk backend cannot be both stateless (for Modern per-request clients) and session-issuing (for Legacy clients) at once. PR #6006 then landed the vMCP cross-generation bridge, but neither of its tiers configures Redis, so the session-store dimension of the bridge stayed unproven: nothing exercised a Legacy client's session metadata living in Redis while a Modern backend shared the group, and nothing exercised what a Redis outage does to a mixed-era deployment. Add an operator-tier spec over a mixed Legacy+Modern yardstick backend set behind one vMCP with Redis session storage. It asserts the asymmetry a single-server test could not express: a Redis outage 503s the Legacy session path while the Modern stateless path keeps serving, because vMCP classifies client era in middleware ahead of the SDK session layer and dispatchModern never reads the session store. Coverage: concurrent Legacy-session and Modern-stateless traffic with no cross-delivery; Redis holding a backend-session key for the Legacy backend only; and outage to 503 to recovery, with the wiped pre-outage session correctly reported terminated. Two constraints are documented in the file because both cost a debug cycle. yardstick's echo tool schema-validates input against ^[a-zA-Z0-9]+$ and reports a violation as a successful result with isError set, so resp.Error being nil is not proof a call worked. And a go-sdk/mcpcompat client cannot open a Legacy session against a Modern-dispatch-enabled vMCP at all: Connect is Modern-first, so server/discover routes to dispatchModern, the client negotiates Modern, gets no session, and then 404s on subscriptions/listen. This spec therefore drives all traffic through the raw client, which pins the era per request. Runs at one replica rather than the multiple replicas the issue suggests. WithSessionIdManager is wired unconditionally, so one replica exercises the Redis store just as fully; two would add only the rehydration path, whose transient-error branch returns 404 instead of 503 unless the pod holds a cached rehydration, making the outage assertion nondeterministic. Cross-pod Redis session sharing is already covered by virtualmcp_redis_session_test.go. Closes #6008 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #5981 removed two specs from the single-server dual-era k8s test because they cannot work on one MCPServer: a single go-sdk backend cannot be both stateless (for Modern per-request clients) and session-issuing (for Legacy clients) at once. PR #6006 then landed the vMCP cross-generation bridge, but neither of its tiers configures Redis, so the session-store dimension of the bridge stayed unproven: nothing exercised a Legacy client's session metadata living in Redis while a Modern backend shared the group, and nothing exercised what a Redis outage does to a mixed-era deployment. Add an operator-tier spec over a mixed Legacy+Modern yardstick backend set behind one vMCP with Redis session storage. It asserts the asymmetry a single-server test could not express: a Redis outage 503s the Legacy session path while the Modern stateless path keeps serving, because vMCP classifies client era in middleware ahead of the SDK session layer and dispatchModern never reads the session store. Coverage: concurrent Legacy-session and Modern-stateless traffic with no cross-delivery; Redis holding a backend-session key for the Legacy backend only; and outage to 503 to recovery, with the wiped pre-outage session correctly reported terminated. Two constraints are documented in the file because both cost a debug cycle. yardstick's echo tool schema-validates input against ^[a-zA-Z0-9]+$ and reports a violation as a successful result with isError set, so resp.Error being nil is not proof a call worked. And a go-sdk/mcpcompat client cannot open a Legacy session against a Modern-dispatch-enabled vMCP at all: Connect is Modern-first, so server/discover routes to dispatchModern, the client negotiates Modern, gets no session, and then 404s on subscriptions/listen. This spec therefore drives all traffic through the raw client, which pins the era per request. Runs at one replica rather than the multiple replicas the issue suggests. WithSessionIdManager is wired unconditionally, so one replica exercises the Redis store just as fully; two would add only the rehydration path, whose transient-error branch returns 404 instead of 503 unless the pod holds a cached rehydration, making the outage assertion nondeterministic. Cross-pod Redis session sharing is already covered by virtualmcp_redis_session_test.go. Closes #6008 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
Dual-era operation — serving both Legacy (2025-11-25, session-based) and Modern (2026-07-28, stateless) MCP through the transport proxies — shipped in #5829/#5830/#5831. But that path is a concurrency-sensitive confidentiality boundary: a routing regression can deliver one client's response to another. Single-handler unit tests can't observe that — it only surfaces when real traffic races through a running proxy. This adds the live end-to-end coverage the design calls for (
docs/arch/stateless-transport-proxies-design.md§Test plan), across four tiers:BACKEND_MODE=echo|barrier|hang|crash+--statelessupstreamed exactly what these tests need — so no bespoke backend image.-32020/-32021/-32022/-32602, foreign-session404, oversized/truncated), dual-era concurrent mixing, and a mutation-verified guard that the streamable Modern path never reuses a clientMcp-Session-Idas its routing token.yardstick-client) driving Modern and Legacy round-trips through both proxies (fidelity leg), theserver/discoverauthz behavior, and OTLP span-attribute assertions (mcp.protocol.version,mcp.client.name).SessionAffinity: None, pod-eviction self-heal, and Redis-unavailable →503(authored to run intest-e2e-lifecycle.yml).Closes #5837.
Type of change
pkg/transport/proxy/streamable, and CI/go.mod/images.gowiring for the yardstick 1.2.0 test dependency.Test plan
task test-e2e) — ranLABEL_FILTER=dual-era task test-e2elocally on the currentmain: 11/11 pass.The streamable-package tests (session-id-reuse guard, no-accumulation) pass under
go test -race;golangci-lintis clean on every touched file. The Kubernetes tier is authored for CI (verified by compile +go vet+ kubernetes-expert review; it runs intest-e2e-lifecycle.yml, not locally — a local kind run would have required deleting a shared cluster with unrelated namespaces). Note for local runs:thv runneedsTOOLHIVE_SKIP_DESKTOP_CHECK=1if ToolHive Desktop is installed.API Compatibility
v1beta1API. (Test-only; it adds a spec undertest/e2e/thv-operatorand bumps a workflow env — no CRD/API surface change.)Changes
test/e2e/mcp_raw_client.go,mcp_raw_client_test.gotest/e2e/testdata/golden_modern_request.json,mcp_raw_client_golden_test.gotest/e2e/dual_era_proxy_test.gotest/e2e/hostile_input_proxy_test.gotest/e2e/dual_era_mixing_test.gopkg/transport/proxy/streamable/streamable_proxy_modern_test.go(+9-line comment instreamable_proxy.go)test/e2e/dual_era_functional_test.goserver/discoverauthztest/e2e/dual_era_observability_test.gopkg/transport/proxy/streamable/streamable_proxy_integration_test.gotest/e2e/dual_era_resilience_test.gotest/e2e/thv-operator/acceptance_tests/dual_era_k8s_test.go.github/workflows/e2e-tests.yml,test-e2e-lifecycle.yml,go.mod,test/e2e/images/images.goDoes this introduce a user-facing change?
No.
Implementation plan
Approved implementation plan
Four stacked tiers (test-only; the behavior under test already merged in #5829/#5830/#5831). Modern traffic driven by an in-repo raw client for adversarial/concurrency tiers and by a real out-of-process go-sdk v1.7 peer (
yardstick-client) for the fidelity tier. Hard constraint: never import go-sdk v1.7 into the root module (peers run out-of-process; yardstick is a separate module/image).Ground truths the tests respect: streamable proxy =
--transport stdio; transparent proxy = remote HTTP, Modern driven by theMCP-Protocol-Versionheader +_meta; Modern signal = header2026-07-28OR a reservedio.modelcontextprotocol/*_metakey (clientInfooptional;clientCapabilitiesmust be an object);-32020covers only header/body version mismatch; GET/DELETE 405 is stateless-mode-driven.server/discoverauthz + OTLP observability (in-process OTLP receiver).SessionAffinity:None, eviction, Redis-503.Every tier passed an adversarial multi-reviewer gate before landing.
Special notes for reviewers
404doesn't echo the request id), Authz-denied responses emit non-conformant JSON-RPC (capitalized keys, no jsonrpc field) #5950 (authz-denied responses emit non-conformant JSON-RPC — capitalized keys, nojsonrpcfield), Streamable proxy drops/fails requests under high concurrent load (fixed 100-buffer channels, no backpressure) #5952 (streamable proxy drops/fails requests under >~100 concurrent in-flight — fixed-100-buffer channels, no backpressure).404s a foreignMcp-Session-Idon a Modern request because its session guard keys on header presence, not the client-forgeable revision (a forged-Modern signal must not bypass session validation); andserver/discoveris now always-allowed under authz as of Serve MCP 2026-07-28 Modern stateless requests through vMCP #5953 (the authz spec asserts that — it also means Modern-through-authz no longer downgrades).server/discoverbypass the response-filter (a restricted client can enumerate descriptors), judged "equally safe here." Flagged in case tool-visibility is a boundary you care about on this path.SessionAffinity: Noneis set + Modern works across replicas instead (documented in-file).🤖 Generated with Claude Code