Skip to content

Add live e2e tests for dual-era stateless proxy behavior - #5981

Merged
jhrozek merged 18 commits into
mainfrom
dual-era-e2e-tests
Jul 27, 2026
Merged

Add live e2e tests for dual-era stateless proxy behavior#5981
jhrozek merged 18 commits into
mainfrom
dual-era-e2e-tests

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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:

  • Harness — a hand-rolled, stdlib-only raw MCP HTTP client (byte-level control to emit the spoofed/malformed/colliding traffic a conforming SDK never would), plus a golden go-sdk v1.7 request fixture as an independent encoding oracle. Backend is yardstick v1.2.0, whose BACKEND_MODE=echo|barrier|hang|crash + --stateless upstreamed exactly what these tests need — so no bespoke backend image.
  • Tier 2 — adversarial/concurrency: crown-jewel cross-delivery stress (barrier-forced collisions, conservation assertions + a positive control that the barrier actually engaged), hostile-input suite (-32020/-32021/-32022/-32602, foreign-session 404, oversized/truncated), dual-era concurrent mixing, and a mutation-verified guard that the streamable Modern path never reuses a client Mcp-Session-Id as its routing token.
  • Tier 1 — functional: a real independent SDK client (yardstick-client) driving Modern and Legacy round-trips through both proxies (fidelity leg), the server/discover authz behavior, and OTLP span-attribute assertions (mcp.protocol.version, mcp.client.name).
  • Tier 3 — resilience: no-state-accumulation under sustained Modern load (integration test, mutation-verified) + fault injection (backend hang/crash; Legacy recovers, Modern degrades cleanly).
  • Kubernetes multi-replica: operator-deployed dual-era over Redis with SessionAffinity: None, pod-eviction self-heal, and Redis-unavailable → 503 (authored to run in test-e2e-lifecycle.yml).

Closes #5837.

Type of change

  • Other (describe): Test-only — live e2e/integration coverage. No production behavior change; the only non-test edits are a 9-line doc comment + a guard test in pkg/transport/proxy/streamable, and CI/go.mod/images.go wiring for the yardstick 1.2.0 test dependency.

Test plan

  • E2E tests (task test-e2e) — ran LABEL_FILTER=dual-era task test-e2e locally on the current main: 11/11 pass.
  • Manual testing (described below)

The streamable-package tests (session-id-reuse guard, no-accumulation) pass under go test -race; golangci-lint is clean on every touched file. The Kubernetes tier is authored for CI (verified by compile + go vet + kubernetes-expert review; it runs in test-e2e-lifecycle.yml, not locally — a local kind run would have required deleting a shared cluster with unrelated namespaces). Note for local runs: thv run needs TOOLHIVE_SKIP_DESKTOP_CHECK=1 if ToolHive Desktop is installed.

API Compatibility

  • This PR does not break the v1beta1 API. (Test-only; it adds a spec under test/e2e/thv-operator and bumps a workflow env — no CRD/API surface change.)

Changes

File(s) Change
test/e2e/mcp_raw_client.go, mcp_raw_client_test.go Raw, stdlib-only adversarial MCP HTTP client
test/e2e/testdata/golden_modern_request.json, mcp_raw_client_golden_test.go Independent go-sdk v1.7 wire oracle for the client
test/e2e/dual_era_proxy_test.go Tier-2 crown-jewel cross-delivery stress
test/e2e/hostile_input_proxy_test.go Tier-2 hostile-input suite
test/e2e/dual_era_mixing_test.go Tier-2 dual-era concurrent mixing
pkg/transport/proxy/streamable/streamable_proxy_modern_test.go (+9-line comment in streamable_proxy.go) Modern session-id-reuse guard
test/e2e/dual_era_functional_test.go Tier-1 functional round-trips (SDK client) + server/discover authz
test/e2e/dual_era_observability_test.go Tier-1 OTLP span-attribute assertions
pkg/transport/proxy/streamable/streamable_proxy_integration_test.go Tier-3 no-state-accumulation
test/e2e/dual_era_resilience_test.go Tier-3 fault injection
test/e2e/thv-operator/acceptance_tests/dual_era_k8s_test.go k8s multi-replica dual-era
.github/workflows/e2e-tests.yml, test-e2e-lifecycle.yml, go.mod, test/e2e/images/images.go Wire the yardstick 1.2.0 test dependency (pre-pull, kind-load, image tag)

Does 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 the MCP-Protocol-Version header + _meta; Modern signal = header 2026-07-28 OR a reserved io.modelcontextprotocol/* _meta key (clientInfo optional; clientCapabilities must be an object); -32020 covers only header/body version mismatch; GET/DELETE 405 is stateless-mode-driven.

  • Tier 2 (Commit group 1): raw client → golden oracle → crown-jewel cross-delivery (barrier backend forces the collision; conservation + false-green guard) → streamable session-reuse guard → hostile inputs → dual-era mixing.
  • Tier 1 (Commit group 2): yardstick-client Modern+Legacy round-trips + server/discover authz + OTLP observability (in-process OTLP receiver).
  • Tier 3 (Commit group 3): no-accumulation (white-box integration) + fault injection.
  • k8s (Commit group 4): multi-replica MCPServer + Redis + SessionAffinity:None, eviction, Redis-503.

Every tier passed an adversarial multi-reviewer gate before landing.

Special notes for reviewers

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Jul 24, 2026
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.06%. Comparing base (cfa2b40) to head (a415ac9).

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

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

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, handlePosthandleSingleRequestSSE), 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's gotNonce == nonce fails 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" — which 01dea165 silently invalidated.
  • dual_era_resilience_test.go: the hang context expects 504 and the crash context >= 500, but the SSE path returns an in-band 200 with 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):

  1. Teach populateEnvelope to unwrap a single SSE data: frame when Content-Type: text/event-stream, or
  2. Have the streamable-proxy specs send Accept: application/json only.

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.

@jhrozek
jhrozek force-pushed the dual-era-e2e-tests branch from 5fa6712 to f9d8baa Compare July 25, 2026 16:12
@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 25, 2026
@jhrozek

jhrozek commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, and thanks for the precise pointer to streamable_proxy.go:610 — that's exactly it. The default Accept in 01dea165 walked right over the deliberate no-Accept decision the mixing test's own comment documents.

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 504 (hang) and >= 500 (crash), and the SSE path answers 200 with an in-band error frame — so teaching populateEnvelope to unwrap SSE would fix the nonce parsing but still leave resilience red, and I'd rather not weaken those to in-band assertions. Keeping the proxy tiers on the plain-JSON path preserves what all four specs were designed around.

Concretely (f9d8baa8):

  • Reverted the global Accept default in SendRaw, so proxy requests get a plain JSON body again. This also makes the dual_era_mixing_test.go comment accurate again, so no rewrite needed there.
  • Added RawRequest.WithStreamableAccept() and call it only on the two k8s Modern requests — that tier's real go-sdk backend 400s without the header and only checks status, so it's the one place that genuinely needs it.

Verified locally this time (which I should have done on the header commit): LABEL_FILTER=dual-era task test-e2e → 11/11 pass, including cross-delivery, mixing, and both resilience specs. Also rebased onto main to clear the go.mod conflict. CI re-running now — happy to get your re-review once it's green.

@jhrozek
jhrozek force-pushed the dual-era-e2e-tests branch from f9d8baa to 14d6d17 Compare July 25, 2026 17:55
@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 25, 2026
@jhrozek
jhrozek force-pushed the dual-era-e2e-tests branch from 14d6d17 to d800074 Compare July 25, 2026 18:20
@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 25, 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.

The blocker is resolved by d800074 ("Revert global Accept default; opt in only for real backends") — thanks for the quick turnaround. Verified:

  • SendRaw no longer sets a default Accept, so the streamable proxy returns a plain-JSON body again and populateEnvelope parses it — the cross-delivery crown jewel, mixing, and resilience specs get real Result/nonce values back (and the resilience 504/>=500 assertions 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 sets Accept.
  • The dual_era_mixing_test.go comment is accurate again (it documents exactly why Accept must 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.

@jhrozek
jhrozek force-pushed the dual-era-e2e-tests branch from d800074 to 637c0c9 Compare July 26, 2026 11:41
@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 26, 2026
JAORMX
JAORMX previously approved these changes Jul 26, 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.

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.

@JAORMX

JAORMX commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

CI note after re-running the failed jobs twice on the current head (637c0c9d): the two red checks are pre-existing vMCP e2e suites flaking on server/backend bring-up — not this PR's dual-era specs.

  • E2E Tests / E2E Tests Core (vmcp) — fails in vmcp_cli_test.go / vmcp_cli_features_test.go / vmcp_infra_features_test.go / vmcp_optimizer_test.go (starts vMCP and exposes backend tools, binds only to 127.0.0.1, serve --config, prefix-conflict, ExcludeAllTools, JWT/OIDC, Redis session, Tier-1 optimizer). The failing-spec count swung 1 → 12 → 12 across three runs of the identical commit (first run: only the composite-tool 60s timeout; both re-runs: 12/16), i.e. nondeterministic cascading vMCP-server startup failures, not a deterministic defect.
  • E2E Test Lifecycle (all 3 kind nodes) — the operator test/e2e/thv-operator/virtualmcp suite: Global ExcludeAllTools … empty tools list from all backends (helpers.go:87, BeforeAll) failed and interrupted the Redis-session / horizontal-scaling / circuit-breaker specs.

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 vmcp + operator virtualmcp). cc @jhrozek

jhrozek and others added 12 commits July 26, 2026 22:31
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>
@jhrozek
jhrozek force-pushed the dual-era-e2e-tests branch from 637c0c9 to aca00b6 Compare July 26, 2026 20:31
@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 26, 2026
JAORMX added a commit that referenced this pull request Jul 27, 2026
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>
@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 27, 2026
@jhrozek
jhrozek merged commit 9c78c3d into main Jul 27, 2026
85 checks passed
@jhrozek
jhrozek deleted the dual-era-e2e-tests branch July 27, 2026 08:41
JAORMX pushed a commit that referenced this pull request Jul 27, 2026
#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>
jhrozek added a commit that referenced this pull request Jul 28, 2026
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>
JAORMX pushed a commit that referenced this pull request Jul 28, 2026
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>
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.

Live e2e tests for dual-era stateless proxy behavior (local, k8s, CI)

2 participants