Skip to content

fix(client): give SSE GET and DELETE requests their own header shape - #3524

Closed
pujitha24 wants to merge 1 commit into
modelcontextprotocol:mainfrom
pujitha24:auto/issue-3503
Closed

pujitha24 wants to merge 1 commit into
modelcontextprotocol:mainfrom
pujitha24:auto/issue-3503

Conversation

@pujitha24

Copy link
Copy Markdown

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 only text/event-stream (what httpx2.EventSource can actually parse) and drop Content-Type, and the DELETE drops Content-Type since it sends no body.

Motivation and Context

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 read text/event-stream. If a server takes that offer at its word and answers the GET with application/json, EventSource raises SSEError, which handle_get_stream swallows in a broad except 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 stray Content-Type with no body behind it.

The 2025-11-25 spec puts the compliance burden on the server here (it must answer text/event-stream or 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?

  • 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.

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-Type with no body.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I am assigned to the linked issue (or it is labeled help wanted, or I'm a maintainer)
  • I have disclosed any AI assistance and can explain the change in my own words
  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

A previous PR (#3515) proposed essentially the same fix but was auto-closed by the repo's require-linked-issue bot 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.

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)
@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 Sep 17, 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 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:

  • 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.

@github-actions github-actions Bot closed this Sep 17, 2026
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.

streamable HTTP client: the standalone GET stream advertises Accept: application/json but can only read text/event-stream, then gives up silently

1 participant