Drop deprecated streamablehttp_client (#1836)

This commit is contained in:
Marcelo Trylesinski 2026-01-15 11:02:24 +01:00 • committed by GitHub
commit 8893b022e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 31 additions and 223 deletions

View file

@ -4,8 +4,7 @@ SessionGroup concurrently manages multiple MCP session connections.
Tools, resources, and prompts are aggregated across servers. Servers may Tools, resources, and prompts are aggregated across servers. Servers may
be connected to or disconnected from at any point after initialization. be connected to or disconnected from at any point after initialization.
This abstractions can handle naming collisions using a custom user-provided This abstractions can handle naming collisions using a custom user-provided hook.
hook.
""" """
import contextlib import contextlib

View file

@ -1,31 +1,20 @@
""" """Implements StreamableHTTP transport for MCP clients."""
StreamableHTTP Client Transport Module
This module implements the StreamableHTTP transport for MCP clients, from __future__ import annotations as _annotations
providing support for HTTP POST requests with optional SSE streaming responses
and session management.
"""
import contextlib import contextlib
import logging import logging
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from typing import Any, overload
from warnings import warn
import anyio import anyio
import httpx import httpx
from anyio.abc import TaskGroup from anyio.abc import TaskGroup
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
from typing_extensions import deprecated
from mcp.shared._httpx_utils import ( from mcp.shared._httpx_utils import create_mcp_http_client
McpHttpClientFactory,
create_mcp_http_client,
)
from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.types import ( from mcp.types import (
ErrorData, ErrorData,
@ -53,15 +42,6 @@ LAST_EVENT_ID = "last-event-id"
# Reconnection defaults # Reconnection defaults
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
CONTENT_TYPE = "content-type"
ACCEPT = "accept"
JSON = "application/json"
SSE = "text/event-stream"
# Sentinel value for detecting unset optional parameters
_UNSET = object()
class StreamableHTTPError(Exception): class StreamableHTTPError(Exception):
@ -81,69 +61,20 @@ class RequestContext:
session_message: SessionMessage session_message: SessionMessage
metadata: ClientMessageMetadata | None metadata: ClientMessageMetadata | None
read_stream_writer: StreamWriter read_stream_writer: StreamWriter
headers: dict[str, str] | None = None # Deprecated - no longer used
sse_read_timeout: float | None = None # Deprecated - no longer used
class StreamableHTTPTransport: class StreamableHTTPTransport:
"""StreamableHTTP client transport implementation.""" """StreamableHTTP client transport implementation."""
@overload def __init__(self, url: str) -> None:
def __init__(self, url: str) -> None: ...
@overload
@deprecated(
"Parameters headers, timeout, sse_read_timeout, and auth are deprecated. "
"Configure these on the httpx.AsyncClient instead."
)
def __init__(
self,
url: str,
headers: dict[str, str] | None = None,
timeout: float = 30.0,
sse_read_timeout: float = 300.0,
auth: httpx.Auth | None = None,
) -> None: ...
def __init__(
self,
url: str,
headers: Any = _UNSET,
timeout: Any = _UNSET,
sse_read_timeout: Any = _UNSET,
auth: Any = _UNSET,
) -> None:
"""Initialize the StreamableHTTP transport. """Initialize the StreamableHTTP transport.
Args: Args:
url: The endpoint URL. url: The endpoint URL.
headers: Optional headers to include in requests.
timeout: HTTP timeout for regular operations (in seconds).
sse_read_timeout: Timeout for SSE read operations (in seconds).
auth: Optional HTTPX authentication handler.
""" """
# Check for deprecated parameters and issue runtime warning
deprecated_params: list[str] = []
if headers is not _UNSET:
deprecated_params.append("headers")
if timeout is not _UNSET:
deprecated_params.append("timeout")
if sse_read_timeout is not _UNSET:
deprecated_params.append("sse_read_timeout")
if auth is not _UNSET:
deprecated_params.append("auth")
if deprecated_params:
warn(
f"Parameters {', '.join(deprecated_params)} are deprecated and will be ignored. "
"Configure these on the httpx.AsyncClient instead.",
DeprecationWarning,
stacklevel=2,
)
self.url = url self.url = url
self.session_id = None self.session_id: str | None = None
self.protocol_version = None self.protocol_version: str | None = None
def _prepare_headers(self) -> dict[str, str]: def _prepare_headers(self) -> dict[str, str]:
"""Build MCP-specific request headers. """Build MCP-specific request headers.
@ -151,10 +82,10 @@ class StreamableHTTPTransport:
These headers will be merged with the httpx.AsyncClient's default headers, These headers will be merged with the httpx.AsyncClient's default headers,
with these MCP-specific headers taking precedence. with these MCP-specific headers taking precedence.
""" """
headers: dict[str, str] = {} headers: dict[str, str] = {
# Add MCP protocol headers "accept": "application/json, text/event-stream",
headers[ACCEPT] = f"{JSON}, {SSE}" "content-type": "application/json",
headers[CONTENT_TYPE] = JSON }
# Add session headers if available # Add session headers if available
if self.session_id: if self.session_id:
headers[MCP_SESSION_ID] = self.session_id headers[MCP_SESSION_ID] = self.session_id
@ -170,20 +101,14 @@ class StreamableHTTPTransport:
"""Check if the message is an initialized notification.""" """Check if the message is an initialized notification."""
return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized" return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized"
def _maybe_extract_session_id_from_response( def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> None:
self,
response: httpx.Response,
) -> None:
"""Extract and store session ID from response headers.""" """Extract and store session ID from response headers."""
new_session_id = response.headers.get(MCP_SESSION_ID) new_session_id = response.headers.get(MCP_SESSION_ID)
if new_session_id: if new_session_id:
self.session_id = new_session_id self.session_id = new_session_id
logger.info(f"Received session ID: {self.session_id}") logger.info(f"Received session ID: {self.session_id}")
def _maybe_extract_protocol_version_from_message( def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
self,
message: JSONRPCMessage,
) -> None:
"""Extract protocol version from initialization response message.""" """Extract protocol version from initialization response message."""
if isinstance(message.root, JSONRPCResponse) and message.root.result: # pragma: no branch if isinstance(message.root, JSONRPCResponse) and message.root.result: # pragma: no branch
try: try:
@ -191,10 +116,8 @@ class StreamableHTTPTransport:
init_result = InitializeResult.model_validate(message.root.result) init_result = InitializeResult.model_validate(message.root.result)
self.protocol_version = str(init_result.protocolVersion) self.protocol_version = str(init_result.protocolVersion)
logger.info(f"Negotiated protocol version: {self.protocol_version}") logger.info(f"Negotiated protocol version: {self.protocol_version}")
except Exception as exc: # pragma: no cover except Exception: # pragma: no cover
logger.warning( logger.warning("Failed to parse initialization response as InitializeResult", exc_info=True)
f"Failed to parse initialization response as InitializeResult: {exc}"
) # pragma: no cover
logger.warning(f"Raw result: {message.root.result}") logger.warning(f"Raw result: {message.root.result}")
async def _handle_sse_event( async def _handle_sse_event(
@ -244,11 +167,7 @@ class StreamableHTTPTransport:
logger.warning(f"Unknown SSE event: {sse.event}") logger.warning(f"Unknown SSE event: {sse.event}")
return False return False
async def handle_get_stream( async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: StreamWriter) -> None:
self,
client: httpx.AsyncClient,
read_stream_writer: StreamWriter,
) -> None:
"""Handle GET stream for server-initiated messages with auto-reconnect.""" """Handle GET stream for server-initiated messages with auto-reconnect."""
last_event_id: str | None = None last_event_id: str | None = None
retry_interval_ms: int | None = None retry_interval_ms: int | None = None
@ -263,12 +182,7 @@ class StreamableHTTPTransport:
if last_event_id: if last_event_id:
headers[LAST_EVENT_ID] = last_event_id # pragma: no cover headers[LAST_EVENT_ID] = last_event_id # pragma: no cover
async with aconnect_sse( async with aconnect_sse(client, "GET", self.url, headers=headers) as event_source:
client,
"GET",
self.url,
headers=headers,
) as event_source:
event_source.response.raise_for_status() event_source.response.raise_for_status()
logger.debug("GET SSE connection established") logger.debug("GET SSE connection established")
@ -311,12 +225,7 @@ class StreamableHTTPTransport:
if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch
original_request_id = ctx.session_message.message.root.id original_request_id = ctx.session_message.message.root.id
async with aconnect_sse( async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source:
ctx.client,
"GET",
self.url,
headers=headers,
) as event_source:
event_source.response.raise_for_status() event_source.response.raise_for_status()
logger.debug("Resumption GET SSE connection established") logger.debug("Resumption GET SSE connection established")
@ -362,10 +271,10 @@ class StreamableHTTPTransport:
# Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications: # Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications:
# The server MUST NOT send a response to notifications. # The server MUST NOT send a response to notifications.
if isinstance(message.root, JSONRPCRequest): if isinstance(message.root, JSONRPCRequest):
content_type = response.headers.get(CONTENT_TYPE, "").lower() content_type = response.headers.get("content-type", "").lower()
if content_type.startswith(JSON): if content_type.startswith("application/json"):
await self._handle_json_response(response, ctx.read_stream_writer, is_initialization) await self._handle_json_response(response, ctx.read_stream_writer, is_initialization)
elif content_type.startswith(SSE): elif content_type.startswith("text/event-stream"):
await self._handle_sse_response(response, ctx, is_initialization) await self._handle_sse_response(response, ctx, is_initialization)
else: else:
await self._handle_unexpected_content_type( # pragma: no cover await self._handle_unexpected_content_type( # pragma: no cover
@ -460,12 +369,7 @@ class StreamableHTTPTransport:
original_request_id = ctx.session_message.message.root.id original_request_id = ctx.session_message.message.root.id
try: try:
async with aconnect_sse( async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source:
ctx.client,
"GET",
self.url,
headers=headers,
) as event_source:
event_source.response.raise_for_status() event_source.response.raise_for_status()
logger.info("Reconnected to SSE stream") logger.info("Reconnected to SSE stream")
@ -498,20 +402,14 @@ class StreamableHTTPTransport:
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1) await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1)
async def _handle_unexpected_content_type( async def _handle_unexpected_content_type(
self, self, content_type: str, read_stream_writer: StreamWriter
content_type: str,
read_stream_writer: StreamWriter,
) -> None: # pragma: no cover ) -> None: # pragma: no cover
"""Handle unexpected content type in response.""" """Handle unexpected content type in response."""
error_msg = f"Unexpected content type: {content_type}" # pragma: no cover error_msg = f"Unexpected content type: {content_type}" # pragma: no cover
logger.error(error_msg) # pragma: no cover logger.error(error_msg) # pragma: no cover
await read_stream_writer.send(ValueError(error_msg)) # pragma: no cover await read_stream_writer.send(ValueError(error_msg)) # pragma: no cover
async def _send_session_terminated_error( async def _send_session_terminated_error(self, read_stream_writer: StreamWriter, request_id: RequestId) -> None:
self,
read_stream_writer: StreamWriter,
request_id: RequestId,
) -> None:
"""Send a session terminated error response.""" """Send a session terminated error response."""
jsonrpc_error = JSONRPCError( jsonrpc_error = JSONRPCError(
jsonrpc="2.0", jsonrpc="2.0",
@ -619,8 +517,7 @@ async def streamable_http_client(
http_client: Optional pre-configured httpx.AsyncClient. If None, a default http_client: Optional pre-configured httpx.AsyncClient. If None, a default
client with recommended MCP timeouts will be created. To configure headers, client with recommended MCP timeouts will be created. To configure headers,
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
terminate_on_close: If True, send a DELETE request to terminate the session terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
when the context exits.
Yields: Yields:
Tuple containing: Tuple containing:
@ -667,11 +564,7 @@ async def streamable_http_client(
) )
try: try:
yield ( yield (read_stream, write_stream, transport.get_session_id)
read_stream,
write_stream,
transport.get_session_id,
)
finally: finally:
if transport.session_id and terminate_on_close: if transport.session_id and terminate_on_close:
await transport.terminate_session(client) await transport.terminate_session(client)
@ -679,44 +572,3 @@ async def streamable_http_client(
finally: finally:
await read_stream_writer.aclose() await read_stream_writer.aclose()
await write_stream.aclose() await write_stream.aclose()
@asynccontextmanager
@deprecated("Use `streamable_http_client` instead.")
async def streamablehttp_client(
url: str,
headers: dict[str, str] | None = None,
timeout: float | timedelta = 30,
sse_read_timeout: float | timedelta = 60 * 5,
terminate_on_close: bool = True,
httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
auth: httpx.Auth | None = None,
) -> AsyncGenerator[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
GetSessionIdCallback,
],
None,
]:
# Convert timeout parameters
timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout
sse_read_timeout_seconds = (
sse_read_timeout.total_seconds() if isinstance(sse_read_timeout, timedelta) else sse_read_timeout
)
# Create httpx client using the factory with old-style parameters
client = httpx_client_factory(
headers=headers,
timeout=httpx.Timeout(timeout_seconds, read=sse_read_timeout_seconds),
auth=auth,
)
# Manage client lifecycle since we created it
async with client:
async with streamable_http_client(
url,
http_client=client,
terminate_on_close=terminate_on_close,
) as streams:
yield streams

View file

@ -4,6 +4,8 @@ Tests for the StreamableHTTP server and client transport.
Contains tests for both server and client sides of the StreamableHTTP transport. Contains tests for both server and client sides of the StreamableHTTP transport.
""" """
from __future__ import annotations as _annotations
import json import json
import multiprocessing import multiprocessing
import socket import socket
@ -25,11 +27,7 @@ from starlette.routing import Mount
import mcp.types as types import mcp.types as types
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from mcp.client.streamable_http import ( from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
StreamableHTTPTransport,
streamable_http_client,
streamablehttp_client, # pyright: ignore[reportDeprecated]
)
from mcp.server import Server from mcp.server import Server
from mcp.server.streamable_http import ( from mcp.server.streamable_http import (
MCP_PROTOCOL_VERSION_HEADER, MCP_PROTOCOL_VERSION_HEADER,
@ -1728,11 +1726,7 @@ async def test_client_crash_handled(basic_server: None, basic_server_url: str):
# Simulate bad client that crashes after init # Simulate bad client that crashes after init
async def bad_client(): async def bad_client():
"""Client that triggers ClosedResourceError""" """Client that triggers ClosedResourceError"""
async with streamable_http_client(f"{basic_server_url}/mcp") as ( async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _):
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
await session.initialize() await session.initialize()
raise Exception("client crash") raise Exception("client crash")
@ -1746,11 +1740,7 @@ async def test_client_crash_handled(basic_server: None, basic_server_url: str):
await anyio.sleep(0.1) await anyio.sleep(0.1)
# Try a good client, it should still be able to connect and list tools # Try a good client, it should still be able to connect and list tools
async with streamable_http_client(f"{basic_server_url}/mcp") as ( async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream, _):
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
result = await session.initialize() result = await session.initialize()
assert isinstance(result, InitializeResult) assert isinstance(result, InitializeResult)
@ -2360,36 +2350,3 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(
assert "content-type" in headers_data assert "content-type" in headers_data
assert headers_data["content-type"] == "application/json" assert headers_data["content-type"] == "application/json"
@pytest.mark.anyio
async def test_streamable_http_transport_deprecated_params_ignored(basic_server: None, basic_server_url: str) -> None:
"""Test that deprecated parameters passed to StreamableHTTPTransport are properly ignored."""
with pytest.warns(DeprecationWarning):
transport = StreamableHTTPTransport( # pyright: ignore[reportDeprecated]
url=f"{basic_server_url}/mcp",
headers={"X-Should-Be-Ignored": "ignored"},
timeout=999.0,
sse_read_timeout=999.0,
auth=None,
)
headers = transport._prepare_headers()
assert "X-Should-Be-Ignored" not in headers
assert headers["accept"] == "application/json, text/event-stream"
assert headers["content-type"] == "application/json"
@pytest.mark.anyio
async def test_streamablehttp_client_deprecation_warning(basic_server: None, basic_server_url: str) -> None:
"""Test that the old streamablehttp_client() function issues a deprecation warning."""
with pytest.warns(DeprecationWarning, match="Use `streamable_http_client` instead"):
async with streamablehttp_client(f"{basic_server_url}/mcp") as ( # pyright: ignore[reportDeprecated]
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
tools = await session.list_tools()
assert len(tools.tools) > 0