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

オープン
#3,503 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

v1 v2
主要言語
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 at upstream/main (9972c21a); v1.x has 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 Accept the 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

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. 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

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。