Fall back to Legacy on an inconclusive vMCP revision probe - #6001
Conversation
ListCapabilities is both the vMCP health-check probe and the startup aggregation call, and since the dual-protocol merge it routes through dispatch, which probes the backend's MCP revision first and treated any probe error as fatal. An inconclusive probe -- a 401/403 blip, an HTTP 408/429/5xx, a timeout, or a connection error -- says nothing about the backend's revision, so failing the whole call marked backends that were up and serving Legacy as unhealthy. That in turn delayed vMCP readiness and emptied the aggregated tool list. Fall back to Legacy, the pre-dual-era default, without caching the revision. A transient outage can no longer pin a backend to Legacy, and a genuinely Modern backend is corrected by the next call's re-probe. A truly-down backend still fails fast, because the Legacy initialize hits the same dead socket. The fallback lives in dispatch rather than probeRevision on purpose: probeRevision must keep returning inconclusive errors uncached so the revision cache is never poisoned. Closes #6000 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6001 +/- ##
==========================================
- Coverage 72.06% 72.04% -0.02%
==========================================
Files 718 718
Lines 74462 74465 +3
==========================================
- Hits 53662 53652 -10
- Misses 16965 16977 +12
- Partials 3835 3836 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
LGTM. Tight, well-scoped fix for the last of the 885e8b1 fallout.
The core change is correct: on any probeRevision error in the uncached path, dispatch falls back to RevisionLegacy uncached rather than treating the probe as fatal. Keeping the fallback in dispatch and not in probeRevision is the right call — probeRevision stays free to return inconclusive errors uncached so the revision cache is never poisoned, and the next call re-probes once the outage clears.
Verified the safety reasoning holds:
- No double execution — the uncached branch still hits
if !cached { return err }before the retry machinery, sofnruns at most once; a genuinely Modern backend rejects the Legacyinitializeearly. - No protocol downgrade — both eras build transport through the same
buildBackendRoundTripper/newBackendHTTPClientchoke point, so auth/TLS/SSRF-dial handling is identical. - Truly-down backend still fails fast — the Legacy
initializehits the same dead socket.
The regression test is exactly right: it drives the probe (via newProbeClient, not setRevision) so the Modern 503 → errModernTransient path actually runs, asserts the tool is returned, and asserts the revision cache stays unpinned. Negative-control verification with the production hunk reverted is a nice touch, and using newProbeClient sidesteps the nil-registry trap from #5990.
Appreciate the honest scoping note that this doesn't claim to eliminate the connection refused e2e signature — only the 5xx/408/429/auth probe window. Approving; will hold on merge for green CI + your call.
Summary
ListCapabilitiesis both the vMCP health-check probe (pkg/vmcp/health/checker.go:99) and the startup aggregation call (pkg/vmcp/aggregator/default_aggregator.go:95). Since 885e8b1 it routes throughdispatch, which resolves the backend's MCP revision by probingserver/discoverfirst and treated any probe error as fatal.probeRevisiondeliberately returns its error uncached for the two inconclusive classes —errModernAuth(401/403/407) anderrModernTransient(408/429/5xx, mid-stream read failure, or any transport error) — precisely because those outcomes say nothing about the backend's revision. Aborting on them meant a backend that was up and serving Legacy got reported unhealthy, which the existing readiness machinery amplified into delayed vMCPReadyand an empty aggregated tool list.This restores the pre-885e8b14f outcome: fall back to Legacy, the pre-dual-era default, without caching the revision.
initializehits the same dead socket.The fallback lives in
dispatch, notprobeRevision, on purpose:probeRevisionmust keep returning inconclusive errors uncached so the revision cache is never poisoned. Everything aftererr := fn(ctx, rev)— the reclassify-on-mismatch block — is unchanged.Closes #6000
Type of change
Changes
pkg/vmcp/client/client.godispatchfalls back toRevisionLegacyuncached on a probe error instead of returningwrapBackendError(..., "probe revision"); doc comments ondispatchandprobeRevisionupdated to matchpkg/vmcp/client/dispatch_legacy_fallback_test.goTest plan
task test)TestDispatch_LegacyFallbackOnInconclusiveProbedrives onehttptest.Serverthat discriminates on theMcp-Methodheader: the Modern probe gets HTTP 503 (→errModernTransient), while the Legacyinitialize+tools/listpath succeeds. It assertsListCapabilitiesreturns the tool, and that the revision cache is not pinned afterwards.Verified as a negative control — with the production hunk reverted, the new test fails with exactly the bug's fingerprint:
Also confirmed still green:
TestProbeRevision_TransientLeavesUnprobed,TestDispatch_TransientDoesNotReclassify,TestProbeRevision_TruthTable,TestRegression_401_*,TestRegression_403OnInitialize_*,TestRegression_ToolSchemaFidelity_PreservesCompositors,TestListCapabilities_*.Special notes for reviewers
Scope — please read. This does not fix the
dial tcp …: connect: connection refusedsignature seen in some failing e2e runs. There the socket is genuinely dead and the Legacyinitializefails identically; the health monitor's retry interval absorbs it. What this removes is the 5xx/408/429/auth window that 885e8b1 made fatal — concretely, a ToolHive proxy that is listening while its MCP container is still starting answers the probe with 502/503, and the probe consumes that window and aborts before the Legacy path is ever attempted. I'd rather state that precisely than claim it eliminates every vMCP e2e flake.Why the fallback is unconditional. It triggers on any
probeRevisionerror, including the "failed to build transport" misconfiguration class.defaultClientFactory(client.go:659) calls the samebuildBackendRoundTripper, so that class fails identically on the Legacy path — only the operation label in the error changes ("create client" vs "probe revision"). Narrowing it to the two sentinels would add a branch that distinguishes nothing observable.Safety checks done:
dispatchis shared withCallTool, but in the uncached branchif !cached { return err }short-circuits before the retry machinery, sofnruns at most once.legacyCallToolalso runslegacyInitfirst (client.go:1572) and returns early on failure, so a genuinely Modern backend rejects the Legacyinitializebeforec.CallTool(client.go:1590) is reachable.buildBackendRoundTripper(client.go:560) and the samenewBackendHTTPClientchoke point (client.go:1010), so auth injection, TLS/CA,SameHostRedirectPolicyand the SSRF dial hook are identical. There is no weaker Legacy path to downgrade to.BackendHealthymapping incategorizeError/authErrorStatusis untouched.Known cost: against a permanently-misconfigured or down backend, the fallback adds one extra attempt per call (transport construction is CPU-only and repeated, since the failure is deliberately never cached). The fallback shares the probe's context, so it inherits the remaining health-check budget rather than a fresh one — harmless for the case being fixed, where the probe returns immediately.
Out of scope and deliberately untouched: the readiness/health-filtering amplifiers in
pkg/vmcp/server/status_reporting.go,pkg/vmcp/health/monitor.goandpkg/vmcp/core/core_vmcp.go(ListTools) — pre-existing, tracked in #3100.This is the third and last fix for 885e8b1 fallout, after #5999.
cc @JAORMX (owner of the related migration PR #5993)
Generated with Claude Code