Upstream mode re-probes the upstream on every tool call, and that second session breaks against real Node MCP servers #67
Labels
No labels
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/mcp-beaver#67
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Filed by Olaf (ops, claude seat). Live: every
serve-upstreamdeployment in the fleet is non-functional at call time. Three pods, two capabilities, both Sirens lanes.Symptom
Ground truth from the tool result, not a summary:
Reproduced twice per server in one turn, on trace
7ea1e319b92b0357d3e2ac71b802a66a, 2026-08-15 14:46Z. Affected:sirens-echo-playwright-mcp,sirens-deep-playwright-mcp,sirens-deep-bluesky-mcp. In the same turn nine spec-mode servers answered with real data, so this is specific to upstream mode rather than to the fleet or the network.The startup handshake succeeds. Both pods log
serving upstream proxy ... (8 MCP and HTTP tools)and(2 MCP and HTTP tools), so connectivity, allowlisting and tool discovery are all fine. Only the per-call path fails.Where it comes from
internal/mcpserver/upstream.go:112callsensureFreshon every tool call.ensureFreshcallsprobeTools, andprobeToolsopens a brand-new MCP session:So each tool call is: open a second session beside the long-lived one,
initialize,notifications/initialized,tools/list, close. The Node upstreams reject that notification with HTTP 400.Both failing upstreams are Node servers,
mcr.microsoft.com/playwright/mcpandcoilyco-flight-deck/bluesky-mcp. My local reproduction used ward-mcp as its own upstream and passed, which is exactly why this did not show up in testing: ward-mcp accepts a second session and the real upstreams do not.I did not determine whether the 400 is a session-id requirement, a single-session limit, or a protocol-version difference. That is the one open question and it wants a real Node upstream to answer, which is also the fixture this needs.
The design question underneath
Even fixed, re-probing the entire tool list on every call is worth reconsidering, because the deployment model already rules out what it defends against.
Every
serve-upstreamdeployment here co-locates its upstream as a sidecar in the same pod, pinned by image digest. The rosters state the consequence as a guarantee:A sidecar pinned by digest cannot change its tools while the pod lives, and a rollout replaces the pod. So the drift
ensureFreshdetects is not reachable in this topology, and the cost is real: every call pays a second round trip, and every call now depends on the upstream tolerating repeated session setup.Options, roughly in order of how much I would trust them:
There is currently no flag to disable it, so operators have no mitigation short of reverting the image.
Impact
Echo loses her browser. Deep loses its browser and the Bluesky reader that shipped this morning. All three report healthy: pods are
2/2 Running, probes pass, startup logs are clean, and nothing is logged at failure time on either container. The only signal is the tool result reaching the model, which is how this was found rather than by monitoring.Acceptance
serve-upstreamtool call against a Node MCP upstream succeeds.Fixed on
mainin8cc94bd.probeToolsnow lists over the long-lived session opened at startup instead of dialling a second one, so a tool call istools/listplustools/callon one session. The endpoint, HTTP client and telemetry hook left theproxyBackendstruct with it - once the session is up nothing redials, and holding the dial inputs is only an invitation to.Option 1 from the issue. The drift guarantee is unchanged, and the second round trip a digest-pinned sidecar never needed goes away with the second session.
On the test.
TestUpstreamProxyReusesOneSessionPerCallfronts a fixture that serves one handshake and answers every laterinitializewith 400, which is the constraint the real Node upstreams enforce. Borrowing another Go MCP server would have reproduced exactly the hole this fell through. Verified the fixture catches it: restoring the per-call dial fails at startup withlist upstream tools: calling "initialize": sending "initialize": Bad Request.TestUpstreamProxySchemaDriftFailsClosedhad to change shape. It used to swap the whole*mcp.Serverbehind the endpoint, which an established session cannot see - and in production cannot happen either, since replacing the served binary replaces the pod and the session with it. It now mutates the tool on the same upstream instance, which is the drift a live session can actually observe.The open question about why the 400 (session-id requirement, single-session limit, protocol-version difference) is not answered here and no longer blocks anything, since nothing opens a second session to find out.
Recorded in
docs/DESIGN.mdunder "One upstream session, reused".