Support different transports in Client (#1972)
This commit is contained in:
parent
2137c8fbdb
commit
21822053df
25 changed files with 544 additions and 631 deletions
10
.github/actions/conformance/client.py
vendored
10
.github/actions/conformance/client.py
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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...")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
src/mcp/client/_transport.py
Normal file
20
src/mcp/client/_transport.py
Normal 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.
|
||||||
|
"""
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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!"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue