Support different transports in Client (#1972)

This commit is contained in:
Marcelo Trylesinski 2026-01-30 13:11:27 +01:00 • committed by GitHub
commit 21822053df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 544 additions and 631 deletions

View file

@ -156,7 +156,7 @@ class ConformanceOAuthCallbackHandler:
@register("initialize") @register("initialize")
async def run_initialize(server_url: str) -> None: async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close.""" """Connect, initialize, list tools, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): async with streamable_http_client(url=server_url) as (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()
logger.debug("Initialized successfully") logger.debug("Initialized successfully")
@ -167,7 +167,7 @@ async def run_initialize(server_url: str) -> None:
@register("tools_call") @register("tools_call")
async def run_tools_call(server_url: str) -> None: async def run_tools_call(server_url: str) -> None:
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" """Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): async with streamable_http_client(url=server_url) as (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()
await session.list_tools() await session.list_tools()
@ -178,7 +178,7 @@ async def run_tools_call(server_url: str) -> None:
@register("sse-retry") @register("sse-retry")
async def run_sse_retry(server_url: str) -> None: async def run_sse_retry(server_url: str) -> None:
"""Connect, initialize, list tools, call test_reconnection, close.""" """Connect, initialize, list tools, call test_reconnection, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): async with streamable_http_client(url=server_url) as (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()
await session.list_tools() await session.list_tools()
@ -209,7 +209,7 @@ async def default_elicitation_callback(
@register("elicitation-sep1034-client-defaults") @register("elicitation-sep1034-client-defaults")
async def run_elicitation_defaults(server_url: str) -> None: async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults.""" """Connect with elicitation callback that applies schema defaults."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession( async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session: ) as session:
@ -296,7 +296,7 @@ async def run_auth_code_client(server_url: str) -> None:
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
"""Common session logic for all OAuth flows.""" """Common session logic for all OAuth flows."""
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream, _): async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream):
async with ClientSession( async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session: ) as session:

View file

@ -17,7 +17,7 @@ This document contains critical information about working with this codebase. Fo
- Functions must be focused and small - Functions must be focused and small
- Follow existing patterns exactly - Follow existing patterns exactly
- Line length: 120 chars maximum - Line length: 120 chars maximum
- FORBIDDEN: imports inside functions - FORBIDDEN: imports inside functions. THEY SHOULD BE AT THE TOP OF THE FILE.
3. Testing Requirements 3. Testing Requirements
- Framework: `uv run --frozen pytest` - Framework: `uv run --frozen pytest`

407
README.md

File diff suppressed because it is too large Load diff

View file

@ -2215,11 +2215,7 @@ from mcp.client.streamable_http import streamable_http_client
async def main(): async def main():
# Connect to a streamable HTTP server # Connect to a streamable HTTP server
async with streamable_http_client("http://localhost:8000/mcp") as ( async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream):
read_stream,
write_stream,
_,
):
# Create a session using the client streams # Create a session using the client streams
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection # Initialize the connection
@ -2397,7 +2393,7 @@ async def main():
) )
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session: async with ClientSession(read, write) as session:
await session.initialize() await session.initialize()

View file

@ -44,10 +44,58 @@ async with http_client:
async with streamable_http_client( async with streamable_http_client(
url="http://localhost:8000/mcp", url="http://localhost:8000/mcp",
http_client=http_client, http_client=http_client,
) as (read_stream, write_stream, get_session_id): ) as (read_stream, write_stream):
... ...
``` ```
### `get_session_id` callback removed from `streamable_http_client`
The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers:
**Before (v1):**
```python
from mcp.client.streamable_http import streamable_http_client
async with streamable_http_client(url) as (read_stream, write_stream, get_session_id):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_id = get_session_id() # Get session ID via callback
```
**After (v2):**
```python
import httpx
from mcp.client.streamable_http import streamable_http_client
# Option 1: Simply ignore if you don't need the session ID
async with streamable_http_client(url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Option 2: Capture session ID via httpx event hooks if needed
captured_session_ids: list[str] = []
async def capture_session_id(response: httpx.Response) -> None:
session_id = response.headers.get("mcp-session-id")
if session_id:
captured_session_ids.append(session_id)
http_client = httpx.AsyncClient(
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_id = captured_session_ids[0] if captured_session_ids else None
```
### `StreamableHTTPTransport` parameters removed ### `StreamableHTTPTransport` parameters removed
The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above).

View file

@ -14,7 +14,7 @@ import threading
import time import time
import webbrowser import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Callable from typing import Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import httpx import httpx
@ -223,15 +223,15 @@ class SimpleAuthClient:
auth=oauth_auth, auth=oauth_auth,
timeout=60.0, timeout=60.0,
) as (read_stream, write_stream): ) as (read_stream, write_stream):
await self._run_session(read_stream, write_stream, None) await self._run_session(read_stream, write_stream)
else: else:
print("📡 Opening StreamableHTTP transport connection with auth...") print("📡 Opening StreamableHTTP transport connection with auth...")
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with streamable_http_client( async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
url=self.server_url, read_stream,
http_client=custom_client, write_stream,
) as (read_stream, write_stream, get_session_id): ):
await self._run_session(read_stream, write_stream, get_session_id) await self._run_session(read_stream, write_stream)
except Exception as e: except Exception as e:
print(f"❌ Failed to connect: {e}") print(f"❌ Failed to connect: {e}")
@ -243,7 +243,6 @@ class SimpleAuthClient:
self, self,
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
write_stream: MemoryObjectSendStream[SessionMessage], write_stream: MemoryObjectSendStream[SessionMessage],
get_session_id: Callable[[], str | None] | None = None,
): ):
"""Run the MCP session with the given streams.""" """Run the MCP session with the given streams."""
print("🤝 Initializing MCP session...") print("🤝 Initializing MCP session...")
@ -254,10 +253,6 @@ class SimpleAuthClient:
print("✨ Session initialization complete!") print("✨ Session initialization complete!")
print(f"\n✅ Connected to MCP server at {self.server_url}") print(f"\n✅ Connected to MCP server at {self.server_url}")
if get_session_id:
session_id = get_session_id()
if session_id:
print(f"Session ID: {session_id}")
# Run interactive loop # Run interactive loop
await self.interactive_loop() await self.interactive_loop()

View file

@ -9,7 +9,7 @@ from mcp.types import CallToolResult, TextContent
async def run(url: str) -> None: async def run(url: str) -> None:
async with streamable_http_client(url) as (read, write, _): async with streamable_http_client(url) as (read, write):
async with ClientSession(read, write) as session: async with ClientSession(read, write) as session:
await session.initialize() await session.initialize()

View file

@ -73,7 +73,7 @@ def get_text(result: CallToolResult) -> str:
async def run(url: str) -> None: async def run(url: str) -> None:
async with streamable_http_client(url) as (read, write, _): async with streamable_http_client(url) as (read, write):
async with ClientSession( async with ClientSession(
read, read,
write, write,

View file

@ -31,7 +31,7 @@ async def run_demo(url: str, items: int, checkpoint_every: int) -> None:
print(f"Processing {items} items with checkpoints every {checkpoint_every}") print(f"Processing {items} items with checkpoints every {checkpoint_every}")
print(f"{'=' * 60}\n") print(f"{'=' * 60}\n")
async with streamable_http_client(url) as (read_stream, write_stream, _): async with streamable_http_client(url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection # Initialize the connection
print("Initializing connection...") print("Initializing connection...")

View file

@ -69,7 +69,7 @@ async def main():
) )
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session: async with ClientSession(read, write) as session:
await session.initialize() await session.initialize()

View file

@ -10,11 +10,7 @@ from mcp.client.streamable_http import streamable_http_client
async def main(): async def main():
# Connect to a streamable HTTP server # Connect to a streamable HTTP server
async with streamable_http_client("http://localhost:8000/mcp") as ( async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream):
read_stream,
write_stream,
_,
):
# Create a session using the client streams # Create a session using the client streams
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection # Initialize the connection

View file

@ -1,6 +1,7 @@
"""MCP Client module.""" """MCP Client module."""
from mcp.client._transport import Transport
from mcp.client.client import Client from mcp.client.client import Client
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
__all__ = ["Client", "ClientSession"] __all__ = ["Client", "ClientSession", "Transport"]

View file

@ -2,17 +2,17 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import AbstractAsyncContextManager, asynccontextmanager
from types import TracebackType
from typing import Any from typing import Any
import anyio import anyio
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.client._transport import TransportStreams
from mcp.server import Server from mcp.server import Server
from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver import MCPServer
from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.memory import create_client_server_memory_streams
from mcp.shared.message import SessionMessage
class InMemoryTransport: class InMemoryTransport:
@ -21,19 +21,6 @@ class InMemoryTransport:
This transport starts the server in a background task and provides This transport starts the server in a background task and provides
streams for client-side communication. The server is automatically streams for client-side communication. The server is automatically
stopped when the context manager exits. stopped when the context manager exits.
Example:
server = MCPServer("test")
transport = InMemoryTransport(server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Use the session...
Or more commonly, use with Client:
async with Client(server) as client:
result = await client.call_tool("my_tool", {...})
""" """
def __init__(self, server: Server[Any] | MCPServer, *, raise_exceptions: bool = False) -> None: def __init__(self, server: Server[Any] | MCPServer, *, raise_exceptions: bool = False) -> None:
@ -45,26 +32,15 @@ class InMemoryTransport:
""" """
self._server = server self._server = server
self._raise_exceptions = raise_exceptions self._raise_exceptions = raise_exceptions
self._cm: AbstractAsyncContextManager[TransportStreams] | None = None
@asynccontextmanager @asynccontextmanager
async def connect( async def _connect(self) -> AsyncIterator[TransportStreams]:
self, """Connect to the server and yield streams for communication."""
) -> AsyncGenerator[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
],
None,
]:
"""Connect to the server and return streams for communication.
Yields:
A tuple of (read_stream, write_stream) for bidirectional communication
"""
# Unwrap MCPServer to get underlying Server # Unwrap MCPServer to get underlying Server
actual_server: Server[Any]
if isinstance(self._server, MCPServer): if isinstance(self._server, MCPServer):
actual_server = self._server._lowlevel_server # type: ignore[reportPrivateUsage] # TODO(Marcelo): Make `lowlevel_server` public.
actual_server: Server[Any] = self._server._lowlevel_server # type: ignore[reportPrivateUsage]
else: else:
actual_server = self._server actual_server = self._server
@ -87,3 +63,16 @@ class InMemoryTransport:
yield client_read, client_write yield client_read, client_write
finally: finally:
tg.cancel_scope.cancel() tg.cancel_scope.cancel()
async def __aenter__(self) -> TransportStreams:
"""Connect to the server and return streams for communication."""
self._cm = self._connect()
return await self._cm.__aenter__()
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> None:
"""Close the transport and stop the server."""
if self._cm is not None: # pragma: no branch
await self._cm.__aexit__(exc_type, exc_val, exc_tb)
self._cm = None

View file

@ -0,0 +1,20 @@
"""Transport protocol for MCP clients."""
from __future__ import annotations
from contextlib import AbstractAsyncContextManager
from typing import Protocol
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.shared.message import SessionMessage
TransportStreams = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
class Transport(AbstractAsyncContextManager[TransportStreams], Protocol):
"""Protocol for MCP transports.
A transport is an async context manager that yields read and write streams
for bidirectional communication with an MCP server.
"""

View file

@ -3,10 +3,13 @@
from __future__ import annotations from __future__ import annotations
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any from typing import Any
from mcp.client._memory import InMemoryTransport from mcp.client._memory import InMemoryTransport
from mcp.client._transport import Transport
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server from mcp.server import Server
from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver import MCPServer
from mcp.shared.session import ProgressFnT from mcp.shared.session import ProgressFnT
@ -30,6 +33,7 @@ from mcp.types import (
) )
@dataclass
class Client: class Client:
"""A high-level MCP client for connecting to MCP servers. """A high-level MCP client for connecting to MCP servers.
@ -55,52 +59,53 @@ class Client:
``` ```
""" """
# TODO(felixweinberger): Expand to support all transport types: server: Server[Any] | MCPServer | Transport | str
# - Add ClientTransport base class with connect_session() method """The MCP server to connect to.
# - Add StreamableHttpTransport, SSETransport, StdioTransport
# - Add infer_transport() to auto-detect transport from input type
# - Accept URL strings, Path objects, config dicts in constructor
# - Add auth support (OAuth, bearer tokens)
def __init__( If the server is a `Server` or `MCPServer` instance, it will be wrapped in an `InMemoryTransport`.
self, If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport.
server: Server[Any] | MCPServer, If the server is a `Transport` instance, it will be used directly.
*, """
# TODO(Marcelo): When do `raise_exceptions=True` actually raises?
raise_exceptions: bool = False,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> None:
"""Initialize the client with a server.
Args: _: KW_ONLY
server: The MCP server to connect to (Server or MCPServer instance)
raise_exceptions: Whether to raise exceptions from the server
read_timeout_seconds: Timeout for read operations
sampling_callback: Callback for handling sampling requests
list_roots_callback: Callback for handling list roots requests
logging_callback: Callback for handling logging notifications
message_handler: Callback for handling raw messages
client_info: Client implementation info to send to server
elicitation_callback: Callback for handling elicitation requests
"""
self._server = server
self._raise_exceptions = raise_exceptions
self._read_timeout_seconds = read_timeout_seconds
self._sampling_callback = sampling_callback
self._list_roots_callback = list_roots_callback
self._logging_callback = logging_callback
self._message_handler = message_handler
self._client_info = client_info
self._elicitation_callback = elicitation_callback
self._session: ClientSession | None = None # TODO(Marcelo): When do `raise_exceptions=True` actually raises?
self._exit_stack: AsyncExitStack | None = None raise_exceptions: bool = False
"""Whether to raise exceptions from the server."""
read_timeout_seconds: float | None = None
"""Timeout for read operations."""
sampling_callback: SamplingFnT | None = None
"""Callback for handling sampling requests."""
list_roots_callback: ListRootsFnT | None = None
"""Callback for handling list roots requests."""
logging_callback: LoggingFnT | None = None
"""Callback for handling logging notifications."""
# TODO(Marcelo): Why do we have both "callback" and "handler"?
message_handler: MessageHandlerFnT | None = None
"""Callback for handling raw messages."""
client_info: Implementation | None = None
"""Client implementation info to send to server."""
elicitation_callback: ElicitationFnT | None = None
"""Callback for handling elicitation requests."""
_session: ClientSession | None = field(init=False, default=None)
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
_transport: Transport = field(init=False)
def __post_init__(self) -> None:
if isinstance(self.server, Server | MCPServer):
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
elif isinstance(self.server, str):
self._transport = streamable_http_client(self.server)
else:
self._transport = self.server
async def __aenter__(self) -> Client: async def __aenter__(self) -> Client:
"""Enter the async context manager.""" """Enter the async context manager."""
@ -108,26 +113,22 @@ class Client:
raise RuntimeError("Client is already entered; cannot reenter") raise RuntimeError("Client is already entered; cannot reenter")
async with AsyncExitStack() as exit_stack: async with AsyncExitStack() as exit_stack:
# Create transport and connect read_stream, write_stream = await exit_stack.enter_async_context(self._transport)
transport = InMemoryTransport(self._server, raise_exceptions=self._raise_exceptions)
read_stream, write_stream = await exit_stack.enter_async_context(transport.connect())
# Create session
self._session = await exit_stack.enter_async_context( self._session = await exit_stack.enter_async_context(
ClientSession( ClientSession(
read_stream=read_stream, read_stream=read_stream,
write_stream=write_stream, write_stream=write_stream,
read_timeout_seconds=self._read_timeout_seconds, read_timeout_seconds=self.read_timeout_seconds,
sampling_callback=self._sampling_callback, sampling_callback=self.sampling_callback,
list_roots_callback=self._list_roots_callback, list_roots_callback=self.list_roots_callback,
logging_callback=self._logging_callback, logging_callback=self.logging_callback,
message_handler=self._message_handler, message_handler=self.message_handler,
client_info=self._client_info, client_info=self.client_info,
elicitation_callback=self._elicitation_callback, elicitation_callback=self.elicitation_callback,
) )
) )
# Initialize the session
await self._session.initialize() await self._session.initialize()
# Transfer ownership to self for __aexit__ to handle # Transfer ownership to self for __aexit__ to handle

View file

@ -296,7 +296,7 @@ class ClientSessionGroup:
http_client=httpx_client, http_client=httpx_client,
terminate_on_close=server_params.terminate_on_close, terminate_on_close=server_params.terminate_on_close,
) )
read, write, _ = await session_stack.enter_async_context(client) read, write = await session_stack.enter_async_context(client)
session = await session_stack.enter_async_context( session = await session_stack.enter_async_context(
mcp.ClientSession( mcp.ClientSession(

View file

@ -14,6 +14,7 @@ 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 mcp.client._transport import TransportStreams
from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared._httpx_utils import 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 (
@ -31,10 +32,10 @@ from mcp.types import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# TODO(Marcelo): Put the TransportStreams in a module under shared, so we can import here.
SessionMessageOrError = SessionMessage | Exception SessionMessageOrError = SessionMessage | Exception
StreamWriter = MemoryObjectSendStream[SessionMessageOrError] StreamWriter = MemoryObjectSendStream[SessionMessageOrError]
StreamReader = MemoryObjectReceiveStream[SessionMessage] StreamReader = MemoryObjectReceiveStream[SessionMessage]
GetSessionIdCallback = Callable[[], str | None]
MCP_SESSION_ID = "mcp-session-id" MCP_SESSION_ID = "mcp-session-id"
MCP_PROTOCOL_VERSION = "mcp-protocol-version" MCP_PROTOCOL_VERSION = "mcp-protocol-version"
@ -200,8 +201,8 @@ class StreamableHTTPTransport:
# Stream ended normally (server closed) - reset attempt counter # Stream ended normally (server closed) - reset attempt counter
attempt = 0 attempt = 0
except Exception as exc: # pragma: lax no cover except Exception: # pragma: lax no cover
logger.debug(f"GET stream error: {exc}") logger.debug("GET stream error", exc_info=True)
attempt += 1 attempt += 1
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
@ -488,25 +489,22 @@ class StreamableHTTPTransport:
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.warning(f"Session termination failed: {exc}") logger.warning(f"Session termination failed: {exc}")
# TODO(Marcelo): Check the TODO below, and cover this with tests if necessary.
def get_session_id(self) -> str | None: def get_session_id(self) -> str | None:
"""Get the current session ID.""" """Get the current session ID."""
return self.session_id return self.session_id # pragma: no cover
# TODO(Marcelo): I've dropped the `get_session_id` callback because it breaks the Transport protocol. Is that needed?
# It's a completely wrong abstraction, so removal is a good idea. But if we need the client to find the session ID,
# we should think about a better way to do it. I believe we can achieve it with other means.
@asynccontextmanager @asynccontextmanager
async def streamable_http_client( async def streamable_http_client(
url: str, url: str,
*, *,
http_client: httpx.AsyncClient | None = None, http_client: httpx.AsyncClient | None = None,
terminate_on_close: bool = True, terminate_on_close: bool = True,
) -> AsyncGenerator[ ) -> AsyncGenerator[TransportStreams, None]:
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
GetSessionIdCallback,
],
None,
]:
"""Client transport for StreamableHTTP. """Client transport for StreamableHTTP.
Args: Args:
@ -520,7 +518,6 @@ async def streamable_http_client(
Tuple containing: Tuple containing:
- read_stream: Stream for reading messages from the server - read_stream: Stream for reading messages from the server
- write_stream: Stream for sending messages to the server - write_stream: Stream for sending messages to the server
- get_session_id_callback: Function to retrieve the current session ID
Example: Example:
See examples/snippets/clients/ for usage patterns. See examples/snippets/clients/ for usage patterns.
@ -561,7 +558,7 @@ async def streamable_http_client(
) )
try: try:
yield (read_stream, write_stream, transport.get_session_id) yield read_stream, write_stream
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)

View file

@ -66,9 +66,7 @@ def create_mcp_http_client(
response = await client.get("/protected-endpoint") response = await client.get("/protected-endpoint")
""" """
# Set MCP defaults # Set MCP defaults
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {"follow_redirects": True}
"follow_redirects": True,
}
# Handle timeout # Handle timeout
if timeout is None: if timeout is None:

View file

@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch
import anyio import anyio
import pytest import pytest
from inline_snapshot import snapshot from inline_snapshot import snapshot
import mcp.types as types import mcp.types as types
from mcp.client._memory import InMemoryTransport
from mcp.client.client import Client from mcp.client.client import Client
from mcp.server import Server from mcp.server import Server
from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver import MCPServer
@ -285,3 +288,21 @@ async def test_complete_with_prompt_reference(simple_server: Server):
ref = types.PromptReference(type="ref/prompt", name="test_prompt") ref = types.PromptReference(type="ref/prompt", name="test_prompt")
result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"}) result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[]))) assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
def test_client_with_url_initializes_streamable_http_transport():
with patch("mcp.client.client.streamable_http_client") as mock:
_ = Client("http://localhost:8000/mcp")
mock.assert_called_once_with("http://localhost:8000/mcp")
async def test_client_uses_transport_directly(app: MCPServer):
transport = InMemoryTransport(app)
async with Client(transport) as client:
result = await client.call_tool("greet", {"name": "Transport"})
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Hello, Transport!")],
structured_content={"result": "Hello, Transport!"},
)
)

View file

@ -173,7 +173,7 @@ async def test_streamable_http_client_unicode_tool_call(running_unicode_server:
base_url = running_unicode_server base_url = running_unicode_server
endpoint_url = f"{base_url}/mcp" endpoint_url = f"{base_url}/mcp"
async with streamable_http_client(endpoint_url) as (read_stream, write_stream, _get_session_id): async with streamable_http_client(endpoint_url) as (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()
@ -205,7 +205,7 @@ async def test_streamable_http_client_unicode_prompts(running_unicode_server: st
base_url = running_unicode_server base_url = running_unicode_server
endpoint_url = f"{base_url}/mcp" endpoint_url = f"{base_url}/mcp"
async with streamable_http_client(endpoint_url) as (read_stream, write_stream, _get_session_id): async with streamable_http_client(endpoint_url) as (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()

Some files were not shown because too many files have changed in this diff Show more