Skip to content

fix(mcp): keep the Streamable HTTP session usable after a 5xx - #5061

Open
hyeonsang010716 wants to merge 1 commit into
openai:mainfrom
hyeonsang010716:fix/mcp-v2-streamable-http-5xx-teardown
Open

hyeonsang010716 wants to merge 1 commit into
openai:mainfrom
hyeonsang010716:fix/mcp-v2-streamable-http-5xx-teardown

Conversation

@hyeonsang010716

@hyeonsang010716 hyeonsang010716 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

With MCP Python SDK v2, a single transient HTTP 5xx permanently broke an MCPServerStreamableHttp
connection. The response hook that the SDK installs on the v2 HTTP client called raise_for_status()
for every 5xx. The hook runs inside the MCP transport task group, so one 5xx on any request, for example
tools/call, tore down the whole transport. Every later call_tool() and list_tools() on the same
server object then failed with MCPError: Connection closed until the application called cleanup()
and connect().

With the default max_retry_attempts=0 nothing recovered. With retries enabled, call_tool() recovered
only by opening a new isolated HTTP client for every later call, because each call first failed on the
dead shared session. list_tools() has no isolated-session path, so it kept failing. The same teardown
happened when a legacy-protocol server answered the notifications/initialized notification with a
5xx: notifications are sent inline by the transport writer, so connect() reported success on a
session that was already dead.

MCP v2 already handles those messages without the hook. It turns a 5xx for a post-handshake request
into a JSON-RPC error for that request alone, and it ignores a 5xx for a notification, so the transport
stays alive. The hook cannot simply stop raising, though. A 5xx on the server/discover probe would
then look like a legacy server, and MCP v2 would silently fall back to initialize and the older
protocol.

The hook now skips the raise only for a post-handshake MCP transport message, that is, a request whose
body is a JSON-RPC message other than the server/discover and initialize handshake requests:

  • A handshake 5xx still fails connect() with UserError and the HTTP status, and a failed discovery
    probe is not mistaken for a legacy server. Isolated-session setup keeps the same mapping.
  • A post-handshake transport message follows MCP v2's handling. The request fails with MCPError, the
    session stays usable, and max_retry_attempts retries on the same session. This matches how 4xx
    responses already behaved.
  • Every other response on that client keeps the existing HTTP error mapping. OAuth auth-flow
    sub-requests, such as token and dynamic client registration requests, are not transport messages, so
    their failures do not reach MCP's OAuth exceptions, which carry the authorization server's response
    body.

Behavior changes worth knowing about, both limited to MCP Python SDK v2:

  • A retried post-handshake 5xx runs on the shared session instead of opening an isolated HTTP client.
    The retry budget and backoff are unchanged.
  • When retries are exhausted, the exception is still MCPError. Only the message changes, from
    Connection closed to the message MCP provides. The default model-visible tool error changes only in
    that message, and failure_error_function=None still raises MCPError.

The MCP Python SDK v1 path is unchanged. It does not install this hook, and its transport raises on
error statuses inside the mcp package itself.

Test plan

  • tests/mcp/test_mcp_v2_http.py, each exercising MCPServerStreamableHttp through a mock httpx2
    transport:
    • test_v2_streamable_http_5xx_fails_only_that_request answers the first tools/call with 503 and
      asserts that call raises MCPError, the next call_tool() and list_tools() succeed, and only one
      HTTP client is created. It fails on main.
    • test_v2_streamable_http_retries_5xx_on_shared_session asserts max_retry_attempts=1 recovers from
      one 503 on the same client. It replaces test_v2_streamable_http_retries_5xx_on_isolated_session,
      which asserted the isolated-client mechanism; the classifier branch that test covered is still
      exercised by tests/mcp/test_client_session_retries.py. It fails on main.
    • test_v2_streamable_http_initialized_notification_5xx_keeps_session_usable answers
      notifications/initialized from a legacy server with 503 and asserts later calls succeed. It fails
      on main.
    • test_v2_streamable_http_handshake_5xx_fails_connect_without_legacy_fallback answers
      server/discover with 503 and asserts connect() raises UserError with HTTP error 503 and sends
      no initialize. It passes on main and fails if the hook stops raising for handshake requests.
    • test_v2_streamable_http_oauth_subrequest_5xx_keeps_http_error_mapping drives connect() with an
      OAuth client-credentials provider whose token endpoint answers 503, and asserts UserError with
      HTTP error 503 plus the absence of the authorization server's response body from the rendered
      exception chain. It passes on main and fails if the exemption covers non-transport requests.
    • test_v2_response_hook_raises_5xx_for_a_request_that_is_not_a_transport_message posts a dynamic
      client registration body, which is JSON but not a JSON-RPC message, and asserts the hook still
      raises. It fails if the JSON-RPC discriminator is removed.
  • A local MCPServer served by uvicorn, with one 503 injected on the first tools/call, now recovers on
    the next call with default settings. On main, every later call and list_tools() failed with
    Connection closed.
  • .agents/skills/code-change-verification/scripts/run.sh passed: format, lint, mypy src (312 files),
    pyright, then the parallel suite (9645 passed, 29 skipped) and the serial suite (77 passed, 4
    skipped).
  • With mcp==1.28.1 installed, tests/mcp shows the same 4 failures in tests/mcp/test_caching.py on
    main and on this branch, so the v1 path is unaffected. The packaged mcp-v1-compat profile needs a
    network install and was not run locally; it passes in CI on this branch.

Issue number

None.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution. The session-recovery issue is worth fixing, and retaining handshake failure handling is the right direction.

Please scope the 5xx exemption to MCP transport messages and preserve the existing handling of OAuth subrequests. MCP v2 includes the complete token or registration error response body in its OAuth exceptions, which bypass our HTTP error mapping under this change. That expands the patch beyond session recovery and changes what appears in rendered exceptions.

Please add a regression test through MCPServerStreamableHttp.connect() with a synthetic OAuth endpoint 503, asserting the existing UserError mapping and absence of the response-body marker from the rendered exception chain.

@jbeckwith-oai jbeckwith-oai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed f163363fee25e43bec516c69f66db486dfe73da9, including MCP 2.0.0 and the HTTP client's authentication/response-hook paths. The shared-session recovery issue is real, but the existing OAuth concern is still unresolved.

Please limit the 5xx exemption to identified post-handshake MCP transport messages and retain the existing HTTP error handling for OAuth subrequests. Token and registration requests do not carry a handshake JSON-RPC method; this condition lets their 5xx responses reach MCP's body-bearing OAuth exceptions, which bypass the SDK's mapped HTTP UserError path. Add focused coverage through connect() verifying the existing error mapping and that a synthetic response-body marker is absent from the rendered error.

This is a focused correction to the current change, consistent with the existing review request. Validation was static source review and recorded CI; no local runtime probe was executed.

With MCP Python SDK v2, the response hook that `MCPServerStreamableHttp`
installs on its HTTP client called `raise_for_status()` for every 5xx.
The hook runs inside the MCP transport task group, so a single transient
5xx on any request tore down the shared transport. Every later
`call_tool()` and `list_tools()` then failed with `MCPError: Connection
closed` until `cleanup()` and `connect()`. A 5xx on the
`notifications/initialized` notification to a legacy-protocol server did
the same while `connect()` still reported success.

The hook now skips the raise only for a post-handshake MCP transport
message, meaning a request body that is a JSON-RPC message other than
`server/discover` and `initialize`. MCP v2 fails such a message on its
own, so the session stays usable and `max_retry_attempts` retries on it.

Every other response keeps the existing HTTP error mapping. A handshake
5xx still fails `connect()` with the HTTP status, so a failed discovery
probe is not mistaken for a legacy server. OAuth auth-flow sub-requests
such as token and registration requests are not transport messages, so
their failures also stay on that path instead of reaching MCP's OAuth
exceptions, which carry the authorization server's response body.
@hyeonsang010716
hyeonsang010716 force-pushed the fix/mcp-v2-streamable-http-5xx-teardown branch from f163363 to 0bf4ff3 Compare September 18, 2026 00:06
@hyeonsang010716

Copy link
Copy Markdown
Contributor Author

Updated. The exemption now covers only a post-handshake MCP transport message, that is a request whose
body is a JSON-RPC message other than server/discover and initialize. Everything else on that client
keeps the previous handling, so token, dynamic registration and metadata sub-requests still raise and
connect() still reports UserError with the status rather than MCP's body-bearing OAuth errors.

test_v2_streamable_http_oauth_subrequest_5xx_keeps_http_error_mapping drives connect() with a
client-credentials provider whose token endpoint answers 503, and asserts that mapping plus the absence
of the authorization server's response body from the rendered exception chain. It passes on main and
fails on the previous revision here with OAuthTokenError: Token exchange failed (503): <body>.
Registration bodies are JSON but not JSON-RPC, so the JSON-RPC check is the only thing keeping them on
the raise path; test_v2_response_hook_raises_5xx_for_a_request_that_is_not_a_transport_message pins
that at the hook level and fails if the check is removed.

The session recovery tests are unchanged and still fail on main. Format, lint, mypy, pyright and both
suites pass locally. The workflow run for the new commit is waiting for approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants