cloudflare / cloudflare/agents
A `-32601` from a capability probe drops the whole MCP connection on a resumed streamable-http session
- 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
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