Skip to content

A -32601 from a capability probe drops the whole MCP connection on a resumed streamable-http session #2069

Description

@saulmc

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

  1. _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.

  2. 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.)

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions