Package: agents@0.20.1 · @modelcontextprotocol/client@2.0.0 · Workers / Durable Objects
Summary
On a resumed streamable-http session, discoverAndRegister() blind-probes the four list
methods. If the server answers an unsupported one with a JSON-RPC -32601 carried in a
non-2xx body, the intended "treat -32601 as capability absence" tolerance does not
match, Promise.all rejects, and the entire connection is discarded — including a
tools/list that had already returned the full catalog. Every tool on that server then
goes missing for the life of the connection.
We hit this in production: three PostHog MCP connections vanished from an agent's catalog
mid-run, and a scheduled report published "skipped" for every metric that needed them.
Why the tolerance misses
_capabilityErrorHandler (src/mcp/client-connection.ts) tests the code directly:
return (e: { code: number }) => {
if (e.code === -32601) { /* … */ return empty; }
throw e;
};
But since MCP SDK v1.24.0 a non-2xx POST is surfaced by the transport as
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
so .code is the HTTP status and the JSON-RPC code survives only as text inside the
message. src/mcp/errors.ts documents exactly this SDK change and updates
isTransportNotImplemented for it — _capabilityErrorHandler was not updated to match.
Observed error, verbatim:
Failed to discover MCP server capabilities: Error POSTing to endpoint:
{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"Method not found"}}
Request id 3 is resources/templates/list — ids are 0-based and a resume skips
initialize, so the probes are tools/list#0, resources/list#1, prompts/list#2,
resources/templates/list#3. The code comment in _capabilityErrorHandler even predicts
it: "This commonly occurs for resource templates."
Three separable problems
-
_capabilityErrorHandler can't see a -32601 wrapped in a transport error. It
should look through the error's cause chain and at the message body, the way
isUnauthorized / isTransportNotImplemented already do for their codes.
-
Promise.all makes a speculative probe fatal. When shouldProbeCapabilities is
true the client is guessing at capabilities. A wrong guess should not discard a
connection whose tools/list succeeded. Resources / prompts / resource-templates
probes could resolve to empty on rejection, with tools/list left strict. (A 401 or a
discovery cancellation should still propagate — neither is a statement about what the
server serves.)
-
stale-session recovery is gated on HTTP 404 alone.
const staleSession =
this._probingCapabilities && e instanceof SdkHttpError && e.status === 404;
_recoverStaleSession already does the right thing — clear the session, reconnect with
a real initialize, re-discover once, and it's bounded because _probingCapabilities
is false on the retry. But any non-404 failure on a blind-resumed session never reaches
it. Widening the trigger to "probing, and not unauthorized, and not our own
timeout/cancellation" makes a resumed connection self-heal instead of dying.
This all originates in the session-persistence work in #1267; the machinery is right, the
error classification around it is too narrow.
Minimal reproduction
A local server that speaks a 2025-era protocol and returns
HTTP 400
{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"Method not found"}}
for resources/templates/list, connected with transport: { type: "streamable-http", sessionId: "<any>" } and no discoverResult (the resumed-blind state):
|
discover |
state |
tools registered |
| as shipped |
throws |
not ready |
0 |
| with 1+2 applied |
succeeds |
ready |
full catalog |
And with the resumed session rejected by the server at a status other than 404, fix 3 is
what turns a dead connection into one clean re-handshake. Happy to attach the harness.
Note: this is unreachable for 2026-07-28+ servers
On a modern-era connection the persisted DiscoverResult rides the resume as prior, so
capabilities are known and nothing is probed. The bug only affects 2025-era servers, where
connect({ prior }) is rejected outright (EraNegotiationFailed) and blind probing is the
only option available. That's a large share of hosted MCP servers today.
Unrelated but adjacent: cyclic cause chains overflow the stack
isUnauthorized and isTransportNotImplemented (src/mcp/errors.ts) recurse through
cause guarding only cause !== error, so a two-node cycle (a.cause = b; b.cause = a)
throws RangeError: Maximum call stack size exceeded. Confirmed in isolation. These run on
every error path. An iterative walk with a visited set fixes it.
Package:
agents@0.20.1·@modelcontextprotocol/client@2.0.0· Workers / Durable ObjectsSummary
On a resumed streamable-http session,
discoverAndRegister()blind-probes the four listmethods. If the server answers an unsupported one with a JSON-RPC
-32601carried in anon-2xx body, the intended "treat -32601 as capability absence" tolerance does not
match,
Promise.allrejects, and the entire connection is discarded — including atools/listthat had already returned the full catalog. Every tool on that server thengoes missing for the life of the connection.
We hit this in production: three PostHog MCP connections vanished from an agent's catalog
mid-run, and a scheduled report published "skipped" for every metric that needed them.
Why the tolerance misses
_capabilityErrorHandler(src/mcp/client-connection.ts) tests the code directly:But since MCP SDK v1.24.0 a non-2xx POST is surfaced by the transport as
so
.codeis the HTTP status and the JSON-RPC code survives only as text inside themessage.
src/mcp/errors.tsdocuments exactly this SDK change and updatesisTransportNotImplementedfor it —_capabilityErrorHandlerwas not updated to match.Observed error, verbatim:
Request id 3 is
resources/templates/list— ids are 0-based and a resume skipsinitialize, so the probes aretools/list#0,resources/list#1,prompts/list#2,resources/templates/list#3. The code comment in_capabilityErrorHandlereven predictsit: "This commonly occurs for resource templates."
Three separable problems
_capabilityErrorHandlercan't see a-32601wrapped in a transport error. Itshould look through the error's
causechain and at the message body, the wayisUnauthorized/isTransportNotImplementedalready do for their codes.Promise.allmakes a speculative probe fatal. WhenshouldProbeCapabilitiesistrue the client is guessing at capabilities. A wrong guess should not discard a
connection whose
tools/listsucceeded. Resources / prompts / resource-templatesprobes could resolve to empty on rejection, with
tools/listleft strict. (A 401 or adiscovery cancellation should still propagate — neither is a statement about what the
server serves.)
stale-sessionrecovery is gated on HTTP 404 alone._recoverStaleSessionalready does the right thing — clear the session, reconnect witha real
initialize, re-discover once, and it's bounded because_probingCapabilitiesis false on the retry. But any non-404 failure on a blind-resumed session never reaches
it. Widening the trigger to "probing, and not unauthorized, and not our own
timeout/cancellation" makes a resumed connection self-heal instead of dying.
This all originates in the session-persistence work in #1267; the machinery is right, the
error classification around it is too narrow.
Minimal reproduction
A local server that speaks a 2025-era protocol and returns
for
resources/templates/list, connected withtransport: { type: "streamable-http", sessionId: "<any>" }and nodiscoverResult(the resumed-blind state):discoverreadyAnd with the resumed session rejected by the server at a status other than 404, fix 3 is
what turns a dead connection into one clean re-handshake. Happy to attach the harness.
Note: this is unreachable for 2026-07-28+ servers
On a modern-era connection the persisted
DiscoverResultrides the resume asprior, socapabilities are known and nothing is probed. The bug only affects 2025-era servers, where
connect({ prior })is rejected outright (EraNegotiationFailed) and blind probing is theonly option available. That's a large share of hosted MCP servers today.
Unrelated but adjacent: cyclic cause chains overflow the stack
isUnauthorizedandisTransportNotImplemented(src/mcp/errors.ts) recurse throughcauseguarding onlycause !== error, so a two-node cycle (a.cause = b; b.cause = a)throws
RangeError: Maximum call stack size exceeded. Confirmed in isolation. These run onevery error path. An iterative walk with a visited set fixes it.