Conversation
Motivation: StreamableHTTPTransport._prepare_headers() always built the POST shape (Accept: application/json, text/event-stream + Content-Type: application/json), and every outbound call site reused it verbatim, including the three standalone SSE GET paths (handle_get_stream, _handle_resumption_request, _handle_reconnection) and the bodyless session-termination DELETE. sse_within_origin's header merge lets a caller-supplied Accept override its own text/event-stream-only default, so the SSE GETs advertised "application/json, text/event-stream" even though httpx2.EventSource can only parse text/event-stream. A server that takes that offer at its word and answers the GET with application/json causes EventSource to raise SSEError; handle_get_stream swallows that in a broad except Exception, so the notification/sampling/elicitation/roots channel is silently lost for the rest of the session while client-to-server POSTs keep working (only a DEBUG log line marks it). The bodyless GET and DELETE also carried a stray Content-Type with no body behind it, regardless of server behavior. The 2025-11-25 spec explicitly makes the wrong side the server here (it must answer text/event-stream or 405), so a spec-compliant server never triggers the failure mode above — but the client still offers a representation it cannot read, and loses the channel silently the one time a server takes it at its word. Approach: Parameterize _prepare_headers() with keyword-only `accept` and `content_type` arguments, defaulting to the existing POST shape so _handle_post_request is unaffected. The three SSE GET call sites now pass accept="text/event-stream", content_type=None. terminate_session's DELETE now passes content_type=None, keeping its existing Accept default since only the stray Content-Type was wrong there. Validation: - Added test_prepare_headers_matches_each_call_sites_request_shape in tests/client/test_streamable_http.py, pinning the exact header dict for the POST, SSE-GET, and DELETE shapes. Confirmed it fails against the pre-fix code (TypeError: _prepare_headers() got an unexpected keyword argument 'accept') and passes post-fix. - uv run --frozen pytest tests/client/test_streamable_http.py -q: 37 passed. - ./scripts/test: 5969 passed, 10 skipped, 1 xfailed, 100% coverage, strict-no-cover clean. - uv run --frozen ruff format / ruff check / pyright on the changed files: clean. - Grepped the repo for other _prepare_headers() callers: none outside this file and its test, so no caller depends on the old unconditional Content-Type. This change does not alter any user-visible request/response behavior against a spec-compliant server; the practical benefit is that a non-compliant server answering the GET with JSON no longer desyncs the notification channel, and the DELETE/bodyless-GET requests no longer carry a Content-Type with no body. AI disclosure: implemented with Claude Code, which read the transport code and the linked issue, wrote the fix and test, and ran the validation commands above. I reviewed the diff and the failing-then-passing test result and stand behind the change. Report: modelcontextprotocol#3503 Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Assisted-by: claude-sonnet-5 (via Claude Code)
|
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 you aren't currently assigned to #3503. If a maintainer assigns you to #3503, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take. 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:
Maintainers: reopen, remove |
Fixes #3503
StreamableHTTPTransport._prepare_headers()built the POST header shape (Accept: application/json, text/event-stream+Content-Type: application/json) for every outbound request, including the three standalone SSE GET paths and the bodyless session-termination DELETE. This parameterizes it so the SSE GETs advertise onlytext/event-stream(whathttpx2.EventSourcecan actually parse) and dropContent-Type, and the DELETE dropsContent-Typesince it sends no body.Motivation and Context
sse_within_origin's header merge lets a caller-suppliedAcceptoverride its owntext/event-stream-only default, so the SSE GETs advertisedapplication/json, text/event-streameven thoughhttpx2.EventSourcecan only readtext/event-stream. If a server takes that offer at its word and answers the GET withapplication/json,EventSourceraisesSSEError, whichhandle_get_streamswallows in a broadexcept Exception— the notification/sampling/elicitation/roots channel is then silently lost for the rest of the session (only a DEBUG log line marks it) while client-to-server POSTs keep working. The bodyless GET and DELETE also carried a strayContent-Typewith no body behind it.The 2025-11-25 spec puts the compliance burden on the server here (it must answer
text/event-streamor 405), so a spec-compliant server never triggers the failure mode — but the client still offers a representation it cannot read, and the channel is lost silently the one time a server takes it at its word.How Has This Been Tested?
test_prepare_headers_matches_each_call_sites_request_shapeintests/client/test_streamable_http.py, pinning the exact header dict for the POST, SSE-GET, and DELETE shapes. Confirmed it fails against the pre-fix code (TypeError: _prepare_headers() got an unexpected keyword argument 'accept') and passes post-fix.uv run --frozen pytest tests/client/test_streamable_http.py -q: 37 passed../scripts/test: 5969 passed, 10 skipped, 1 xfailed, 100% coverage,strict-no-coverclean.uv run --frozen ruff format/ruff check/pyrighton the changed files: clean._prepare_headers()callers: none outside this file and its test.This is a defect provable with a unit test (a header-shape bug, not a timing/live-server-dependent one), so no live e2e reproduction was run against a real non-compliant server; the targeted failing-then-passing test plus the full suite is the validation for this class of change.
Breaking Changes
None. This does not alter any user-visible request/response behavior against a spec-compliant server. The practical benefit is that a non-compliant server answering the GET with JSON no longer desyncs the notification channel, and the DELETE/bodyless-GET requests no longer carry a
Content-Typewith no body.Types of changes
Checklist
help wanted, or I'm a maintainer)Additional context
A previous PR (#3515) proposed essentially the same fix but was auto-closed by the repo's
require-linked-issuebot because its author wasn't assigned to #3503 — not because the approach was wrong. This change independently reimplements the same minimal, parameterize-_prepare_headers()approach after reviewing that closed PR's diff for correctness.AI disclosure: implemented with Claude Code, which read the transport code and the linked issue, wrote the fix and test, and ran the validation commands listed above. I reviewed the diff and the failing-then-passing test result and stand behind the change.
AI assistance: this change was drafted with Claude Code.