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

Aberta
#3,503 1 comentário 0 reações 0 responsáveis Ver no GitHub

Ninguém assumiu esta issue ainda.

v1 v2
Linguagem predominante
Python
Estrelas
24.3k
Forks
4k
Merge médio
1d 1h
PRs com merge (30d)
31

Descrição

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

Guia de contribuição

Abrir o guia de contribuição

Primeiros passos

  1. Leia a issue inteira e depois o guia de contribuição do projeto.
  2. Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
  3. Faça um fork do repositório e trabalhe em uma branch.
  4. Abra um pull request que referencie o número da issue.

Direção de pesquisa

Comece em mcp/client/streamable_http.py, em StreamableHTTPTransport._prepare_headers e nos pontos de chamada de handle_get_stream, resumption, reconnection e termination. Execute a reprodução fornecida com o servidor e o cliente e, em seguida, verifique se as requisições SSE GET anunciam apenas text/event-stream, se as requisições GET e DELETE sem corpo omitem Content-Type e se o stream não falha mais silenciosamente quando a resposta é application/json.

Escrita pelo modelo de indexação a partir do texto da issue.

Avaliação

Stack de tecnologia
python
Domínio
api, networking
Tipo de issue
Bug
Dificuldade
3/5
Tempo estimado
1-2 dias
Status de atividade
Ativa
Clareza
Claramente especificada
Facilidade para iniciantes
73/100

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.