cloudflare / cloudflare/agents

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

Open
#2,069 4 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

**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:

```ts
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

```js
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.**

```ts
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: "" }` 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.

Contributor guide

Open the contributing guide

Research direction

Start with src/mcp/client-connection.ts and src/mcp/errors.ts, then run the minimal streamable-http reproduction described in the issue for a resumed session and non-2xx -32601 response. Done means speculative probes tolerate unsupported capabilities, successful tools remain registered, non-404 resumed sessions can recover appropriately, and the reported error-classification paths remain safe.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.