Version: v1.7.0-pre.3
The streamable client maps HTTP status to transport health without distinguishing a transport-level rejection from an application-level error that a handler produced. Two spec-mandated server responses therefore kill the whole client session rather than failing one call.
Case 1 — -32021 at HTTP 400 permanently fails the session
SEP-2575 "Missing Required Capabilities" requires that if processing a request needs a capability the client did not declare in clientCapabilities, the server return a JSON-RPC error, and "For HTTP, the response status code MUST be 400 Bad Request". That is execution-time language, and the SDK's own server agrees: extractErrorStatus (streamable.go:1033-1061) maps -32021 → 400 for protocol ≥ 2026-07-28, including for a handler-produced response to tools/call.
On the client side that response is fatal to the connection:
isTransientHTTPStatus (streamable.go:2750-2760) covers only 500, 502, 503, 504 and 429 — 400 is not transient.
checkResponse (streamable.go:2525-2537) sees a non-404, non-transient status, decodes the body, surfaces the JSON-RPC error, and returns a plain error — not wrapped in jsonrpc2.ErrRejected.
write (streamable.go:2306-2321) has requestMethod == "tools/call" (not methodDiscover) and a non-ErrRejected error, so it falls through to c.fail(err).
fail (streamable.go:2176-2183) does failOnce.Do + close(c.failed) — one-shot and irreversible.
So a conformant server answering one tools/call with the spec's mandated status tears down the session. Every later request fails with the stored failure. A server author's only way to keep the reference client alive is to deviate from the MUST and return HTTP 200 with the same body.
Case 2 — the same shape via 404, affecting any unimplemented Modern method
SEP-2575 requires HTTP 404 for method-not-found. The 2025-11-25 streamable transport also requires 404 for a terminated session — quoted at streamable.go:2510-2512. The client cannot distinguish the two and takes the session reading: ErrSessionMissing, which is not wrapped in ErrRejected, so write again reaches c.fail() (streamable.go:2317-2321).
Consequence: a server that does not implement any one Modern method drops the session at Connect time rather than returning a per-call error. We hit this concretely with subscriptions/listen — a server without it cannot complete Connect at all when the client has notification handlers registered — but the mechanism is general, so it applies to every method a server has not implemented yet.
Why these belong together
Both are the same root cause: HTTP status is being read as connection state, while SEP-2575 assigns status codes by error code — an application-level classification. Any status the spec attaches to a per-request error condition will be misread as transport death.
Suggested direction
Distinguish transport-level failures from responses that carry a decodable JSON-RPC error. Concretely: if checkResponse successfully decodes a JSON-RPC error body, treat it as a per-call rejection (ErrRejected) regardless of status, and reserve c.fail() for genuine transport faults. For case 2, -32601 in a decodable body would then be distinguishable from a bare 404 with no JSON-RPC payload, which is what a terminated session actually produces.
Context
Found while implementing the 2026-07-28 client-facing surface in a stateless aggregating proxy built on this SDK. It is currently shipping HTTP 200 with a -32021 body — a deliberate, documented deviation from SEP-2575's status MUST — specifically because 400 would kill the session. We would rather be conformant.
Related: #1112, #1113, #1116, all from adopting v1.7.0-pre.3 in the same shim. Happy to supply a standalone repro (a server returning -32021/400 on tools/call, plus a client issuing a second request afterwards) or to send a PR if you'd like a particular shape.
Version:
v1.7.0-pre.3The streamable client maps HTTP status to transport health without distinguishing a transport-level rejection from an application-level error that a handler produced. Two spec-mandated server responses therefore kill the whole client session rather than failing one call.
Case 1 —
-32021at HTTP 400 permanently fails the sessionSEP-2575 "Missing Required Capabilities" requires that if processing a request needs a capability the client did not declare in
clientCapabilities, the server return a JSON-RPC error, and "For HTTP, the response status code MUST be400 Bad Request". That is execution-time language, and the SDK's own server agrees:extractErrorStatus(streamable.go:1033-1061) maps-32021→ 400 for protocol ≥ 2026-07-28, including for a handler-produced response totools/call.On the client side that response is fatal to the connection:
isTransientHTTPStatus(streamable.go:2750-2760) covers only 500, 502, 503, 504 and 429 — 400 is not transient.checkResponse(streamable.go:2525-2537) sees a non-404, non-transient status, decodes the body, surfaces the JSON-RPC error, and returns a plain error — not wrapped injsonrpc2.ErrRejected.write(streamable.go:2306-2321) hasrequestMethod == "tools/call"(notmethodDiscover) and a non-ErrRejectederror, so it falls through toc.fail(err).fail(streamable.go:2176-2183) doesfailOnce.Do+close(c.failed)— one-shot and irreversible.So a conformant server answering one
tools/callwith the spec's mandated status tears down the session. Every later request fails with the stored failure. A server author's only way to keep the reference client alive is to deviate from the MUST and return HTTP 200 with the same body.Case 2 — the same shape via 404, affecting any unimplemented Modern method
SEP-2575 requires HTTP 404 for method-not-found. The 2025-11-25 streamable transport also requires 404 for a terminated session — quoted at
streamable.go:2510-2512. The client cannot distinguish the two and takes the session reading:ErrSessionMissing, which is not wrapped inErrRejected, sowriteagain reachesc.fail()(streamable.go:2317-2321).Consequence: a server that does not implement any one Modern method drops the session at
Connecttime rather than returning a per-call error. We hit this concretely withsubscriptions/listen— a server without it cannot completeConnectat all when the client has notification handlers registered — but the mechanism is general, so it applies to every method a server has not implemented yet.Why these belong together
Both are the same root cause: HTTP status is being read as connection state, while SEP-2575 assigns status codes by error code — an application-level classification. Any status the spec attaches to a per-request error condition will be misread as transport death.
Suggested direction
Distinguish transport-level failures from responses that carry a decodable JSON-RPC error. Concretely: if
checkResponsesuccessfully decodes a JSON-RPC error body, treat it as a per-call rejection (ErrRejected) regardless of status, and reservec.fail()for genuine transport faults. For case 2,-32601in a decodable body would then be distinguishable from a bare 404 with no JSON-RPC payload, which is what a terminated session actually produces.Context
Found while implementing the 2026-07-28 client-facing surface in a stateless aggregating proxy built on this SDK. It is currently shipping HTTP 200 with a
-32021body — a deliberate, documented deviation from SEP-2575's status MUST — specifically because 400 would kill the session. We would rather be conformant.Related: #1112, #1113, #1116, all from adopting
v1.7.0-pre.3in the same shim. Happy to supply a standalone repro (a server returning-32021/400 ontools/call, plus a client issuing a second request afterwards) or to send a PR if you'd like a particular shape.