Skip to content

fix(client): stop retrying GET stream when server answers 405 - #3420

Closed
lccstc wants to merge 2 commits into
modelcontextprotocol:mainfrom
lccstc:fix/streamable-http-405-get-stream
Closed

fix(client): stop retrying GET stream when server answers 405#3420
lccstc wants to merge 2 commits into
modelcontextprotocol:mainfrom
lccstc:fix/streamable-http-405-get-stream

Conversation

@lccstc

@lccstc lccstc commented Aug 30, 2026

Copy link
Copy Markdown

Summary

handle_get_stream treats an HTTP 405 response to the GET stream like any other transport error and retries. Per the Streamable HTTP spec, a 405 Method Not Allowed on GET is the server's definitive signal that it does not offer a server-initiated SSE stream — the client should accept that and proceed without the GET stream, not retry.

Real-world impact

Observed against a production MCP endpoint that does not implement GET SSE (GitHub Copilot's https://api.githubcopilot.com/mcp/, which is used by several MCP clients, including self-hosted agent platforms such as TencentCloud/Octop). Every new streamable-HTTP session there produces:

INFO mcp.client.streamable_http — Received session ID: d1d45fa5-...
INFO mcp.client.streamable_http — GET stream disconnected, reconnecting in 1000ms...
INFO mcp.client.streamable_http — GET stream disconnected, reconnecting in 1000ms...

That is 2 pointless GET requests + 2 retry backoff sleeps per session, plus misleading "disconnected" log noise suggesting a network problem when the server is behaving exactly per spec. In SDK versions with an unbounded reconnect loop this manifests as an endless ~1s reconnect cycle per session.

Fix

In handle_get_stream, catch httpx2.HTTPStatusError first: if the status is 405, log once ("GET stream disabled: server does not support server-initiated SSE (405)") and return — the session keeps working normally over POST, which is the entire point of the Streamable HTTP transport's optional GET stream. All other errors keep the existing bounded-retry behavior unchanged.

Testing

  • Added tests/client/test_streamable_http_405.py:
    • test_get_stream_405_disables_retry — a 405 produces exactly one GET attempt and no backoff sleep.
    • test_get_stream_other_http_errors_still_retry — a 500 still consumes the bounded retry budget (MAX_RECONNECTION_ATTEMPTS), proving no behavior change for non-405 errors.

lccstc added 2 commits August 31, 2026 06:22
Per the Streamable HTTP spec, a 405 response to GET is the server's
definitive signal that it does not offer a server-initiated SSE stream.
The transport currently treats it like any other disconnect and retries
(MAX_RECONNECTION_ATTEMPTS times per session), producing pointless
requests, reconnect backoff sleeps, and "GET stream disconnected,
reconnecting in ...ms" log noise on every session creation.

Treat HTTP 405 as terminal: log once and disable the GET stream for the
session. POST-based request/response flows are unaffected.
- 405 on GET must stop the reconnect loop immediately (one attempt, no
  backoff sleep) since the server has definitively signaled that it does
  not offer a server-initiated SSE stream.
- Other HTTP errors keep the existing bounded-retry behavior.
@github-actions github-actions Bot added the missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md) label Aug 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been closed automatically. This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue, and this PR doesn't link an open issue yet.

  • If you're already assigned to an issue for this, add Fixes #<n> to the description and the PR will reopen on its own.
  • If there's no issue yet, please open one instead: what you ran into, why it matters for your use case, and a minimal reproduction. That context is super important to us and is what we use to decide what to prioritise.
  • If there's an issue but you're not assigned, add Fixes #<n> anyway so they're linked, then engage on the issue itself by confirming the repro or describing the approach you'd take. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. If you are assigned, this PR reopens automatically.

You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way.

CONTRIBUTING.md has the full reasoning, but in short:

  • We're a small team with very little capacity to review community PRs right now.
  • Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.

Maintainers: reopen, remove missing-issue-link, or add bypass-issue-check to override.

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

Labels

missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant