Address the third review round:
- Broaden the SSE message POST's failure catch to a terminal containment
boundary (except Exception): user-supplied auth flows and hooks raise
arbitrary types from inside client.post(), so an enumerated catch cannot
keep the caller from hanging.
- Extract the status -> JSON-RPC error mapping into
mcp.client._transport.status_error_data and use it from the message POST
handler, the resumption GET, and the SSE POST; the message POST keeps its
pre-session 404 -> METHOD_NOT_FOUND case locally. Wire-identical.
- Contain the SSE error-resolution send against a concurrently closed read
stream (BrokenResourceError/ClosedResourceError -> debug log), mirroring
_resolve_abandoned_request, so the teardown race cannot kill the write
loop.
Tests: the auth-failure test is parametrized over OAuthTokenError and
RuntimeError, and a raw-stream teardown-race test pins that a failing
POST whose error is undeliverable leaves the write loop serving later
messages. Both fail against the previous revision.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AuJi8kEB3bhikW2pzbmhUL
Address the second review round:
- Widen the SSE message POST's failure catch to (httpx.HTTPError,
OAuthFlowError): an OAuthClientProvider re-auth failing inside
client.post() previously took the same swallowed path and hung the
waiting caller forever.
- Map a 404 on the SSE message POST to INVALID_REQUEST / "Session
terminated" when the endpoint URL carries a session id (the SSE
analogue of the streamable transport's session check); keep the
generic error when it does not.
- Document the changed error behavior in docs/migration.md: resumption
GET and SSE message POST outcome tables, and scope the "connect-level
failures still escape" sentence to the streamable message POST, the
one place it still holds.
Three new regression tests; the OAuth and 404-session ones fail against
the previous revision.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AuJi8kEB3bhikW2pzbmhUL
Address the review findings on the previous revision:
- Dispatch resumption on message type as well as metadata: a notification
stamped with a resumption token is POSTed as usual instead of tripping
the resumption path's request-only assertion and killing the write loop.
- Treat any non-2xx as a failure (response.is_success), restoring the
raise_for_status() semantics the checks replaced: an unfollowed redirect
resolves the caller instead of being logged as success.
- Map a 404 on the resumption GET while a session id is held to
INVALID_REQUEST / "Session terminated", the POST path's session-expiry
signal, so reconnect logic keyed on it works across both.
- Contain the resumption read loop like _handle_sse_response: a stream
dying mid-read or ending cleanly without a response resolves the waiter
(CONNECTION_CLOSED) instead of tearing down the transport or hanging.
- Resolve the resumption GET's status errors via _resolve_abandoned_request
for its closed-stream containment instead of hand-building the error.
- Surface network-level errors (httpx.HTTPError) on the SSE message POST
through the same correlated path: on this transport nothing escapes
loudly, so the caller previously hung forever.
- Deduplicate the SSE test app wiring behind make_app(wrap_post=...).
Seven new regression tests pin the above; each fails against the previous
revision (hang into fail_after, transport teardown, or wrong error).
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AuJi8kEB3bhikW2pzbmhUL
Two client-side paths still swallowed non-2xx HTTP responses, leaving the
caller hanging with no way to tell an auth failure from a slow server
(#2110):
- streamable HTTP: a non-2xx on the resumption GET (Last-Event-ID) hit a
bare raise_for_status() inside the request's background task; the
escaping HTTPStatusError tore down the transport's task group and every
stream with it.
- SSE transport: a non-2xx on the message POST raised into post_writer's
catch-all, which logged and dropped it; the waiting caller hung forever
and the write loop died.
Both paths now resolve the waiting request with a JSON-RPC error
correlated to its id, mirroring _handle_post_request's existing non-2xx
handling: the caller gets a prompt INTERNAL_ERROR and the
transport/session stays usable. A non-2xx on a notification POST has no
waiter to resolve, so it is logged and contained.
Regression tests drive both transports in-process (httpx MockTransport /
ASGI) at 401/403/500 and pin that the error is correlated, prompt, and
non-fatal to the session; all fail (hang into fail_after) without the
fix.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AuJi8kEB3bhikW2pzbmhUL
Python's $ with re.match also matches just before a single trailing newline, so tool-name validation accepted "name\n" and UriTemplate.parse accepted varnames like "foo\n". Switch both checks to re.fullmatch.
Closes#3084