modelcontextprotocol / modelcontextprotocol/python-sdk
streamable HTTP client: the standalone GET stream advertises Accept: application/json but can only read text/event-stream, then gives up silently
まだ誰も着手していません。
- 主要言語
- Python
- スター
- 24.3k
- フォーク
- 4k
- 平均マージ
- 1日 1時間
- マージ済み PR(30日)
- 31
説明
AI disclosure: I used Claude Code to read the transport, capture the headers and build the reproduction. I hit the problem myself, ran the repro, and stand behind the report.
Initial Checks
2.2.0(latest 2.x). The two files involved are byte-identical atupstream/main(9972c21a);v1.xhas the same shape.- Searched first. Nearby reports cover the server side of the GET (#3102, #2474, PR #3129), Accept quality factors (#3154), and the reconnect loop (#3257, #3356, #2393). None covers the
Acceptthe client sends.
Release line
2.x (current stable)
Description
StreamableHTTPTransport._prepare_headers is written for the POST — accept: application/json, text/event-stream plus content-type: application/json — but all five outbound requests use it:
| Call site | Request | |
|---|---|---|
_handle_post_request |
POST with a body | ok |
handle_get_stream |
standalone SSE GET | Accept overridden, Content-Type with no body |
_handle_resumption_request |
resumption SSE GET | same |
_handle_reconnection |
reconnect SSE GET | same |
terminate_session |
DELETE, no body | Content-Type with no body |
sse_within_origin sets _SSE_HEADERS = {"Accept": "text/event-stream", ...} and then does merged.update(headers or {}), so the caller's Accept wins. Those GET responses are read only through httpx2.EventSource, which raises SSEError for anything but text/event-stream: the client offers a representation it will then refuse. On the wire:
GET /mcp
accept: application/json, text/event-stream
content-type: application/json
The DELETE carries the same content-type with no body.
What it costs. Answer that GET with application/json and the offer is honoured back at the client:
httpx2.SSEError: Expected response with content type 'text/event-stream', got 'application/json'.
DEBUG mcp.client.streamable_http GET stream max reconnection attempts (2) exceeded
handle_get_stream swallows it in its broad except Exception and returns. Client-to-server requests keep working — the repro still prints its tools/call result — so the session looks healthy while notifications, sampling, elicitation and roots are gone for its lifetime, with only a DEBUG line.
Expected. The SSE GETs advertise only text/event-stream, and the bodyless GET and DELETE carry no Content-Type.
Scope. Under the 2025-11-25 spec the client is within its rights (it "MUST include an Accept header, listing text/event-stream as a supported content type"; listing more is not forbidden) and the server is not (it "MUST either return Content-Type: text/event-stream [...] or else return HTTP 405 Method Not Allowed"). A compliant server never hits this, and the repro's server is deliberately non-compliant. What still looks wrong is offering what the client cannot read, and losing the channel silently when a server takes the offer up; the stray Content-Type is wrong regardless. mcp/client/sse.py is unaffected — it passes no headers.
The repro uses mode="legacy" because notifications/initialized is sent only from ClientSession.initialize(); the modern path adopts a DiscoverResult, so start_get_stream never fires there.
Example Code
# server.py — the official server, answering the GET with the content type the client accepts.
import uvicorn
from mcp.server.mcpserver import MCPServer
server = MCPServer(name="probe", version="0.1.0")
@server.tool()
def echo(text: str) -> str:
return text
class TakeTheClientAtItsWord:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope["method"] == "GET":
body = b'{"jsonrpc":"2.0"}'
await send({
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
uvicorn.run(TakeTheClientAtItsWord(server.streamable_http_app()), host="127.0.0.1", port=8000)
# client.py
import asyncio
import logging
from mcp import Client
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s %(message)s")
async def main() -> None:
async with Client("http://127.0.0.1:8000/mcp", mode="legacy") as client:
print("tools/call still works:", (await client.call_tool("echo", {"text": "hi"})).content[0].text)
await asyncio.sleep(3)
asyncio.run(main())
Drop the GET branch from the wrapper and log scope["headers"] to see the outgoing headers instead of the failure.
Python & MCP Python SDK
Python 3.14.3 (CPython, macOS arm64)
mcp 2.2.0, httpx2 2.12.0, httpcore2 2.12.0, anyio 4.15.1, uvicorn 0.52.4
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
調査の方向性
mcp/client/streamable_http.py の StreamableHTTPTransport._prepare_headers と、handle_get_stream、resumption、reconnection、termination の呼び出し箇所から始めます。提供されているサーバーとクライアントの再現手順を実行し、SSE GET リクエストが text/event-stream のみを通知すること、ボディのない GET および DELETE リクエストが Content-Type を省略すること、レスポンスが application/json の場合にストリームがサイレントに失敗しなくなっていることを確認します。
索引モデルが issue の本文から書いたものです。
評価
- 技術スタック
- python
- 領域
- api, networking
- issue の種類
- バグ
- 難易度
- 3/5
- 見積もり時間
- 1〜2日
- 活発さ
- 活発
- 明瞭さ
- 明確に書かれている
- 初心者へのやさしさ
- 73/100