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

Abierto
#3,503 1 comentario 0 reacciones 0 asignados Ver en GitHub

Nadie ha tomado este issue todavía.

v1 v2
Lenguaje dominante
Python
Estrellas
24.3k
Forks
4k
Merge medio
1 d 1 h
PR fusionados (30 d)
31

Descripción

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

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Línea de trabajo

Comienza en mcp/client/streamable_http.py, en StreamableHTTPTransport._prepare_headers y en los puntos de llamada de handle_get_stream, resumption, reconnection y termination. Ejecuta la reproducción proporcionada del servidor y el cliente, y verifica después que las solicitudes SSE GET anuncien únicamente text/event-stream, que las solicitudes GET y DELETE sin cuerpo omitan Content-Type y que el stream ya no falle silenciosamente cuando la respuesta sea application/json.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
python
Área
api, networking
Tipo de issue
Error
Dificultad
3/5
Tiempo estimado
1-2 días
Estado de actividad
Activo
Claridad
Bien especificado
Aptitud para principiantes
73/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.