python-sdk/src/mcp/client/client.py
2026-01-22 14:50:39 +01:00

300 lines
11 KiB
Python

"""Unified MCP Client that wraps ClientSession with transport management."""
from __future__ import annotations
from contextlib import AsyncExitStack
from typing import Any
from mcp.client._memory import InMemoryTransport
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.shared.session import ProgressFnT
from mcp.types import (
CallToolResult,
CompleteResult,
EmptyResult,
GetPromptResult,
Implementation,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
LoggingLevel,
PaginatedRequestParams,
PromptReference,
ReadResourceResult,
RequestParamsMeta,
ResourceTemplateReference,
ServerCapabilities,
)
class Client:
"""A high-level MCP client for connecting to MCP servers.
Currently supports in-memory transport for testing. Pass a Server or
FastMCP instance directly to the constructor.
Example:
```python
from mcp.client import Client
from mcp.server.fastmcp import FastMCP
server = FastMCP("test")
@server.tool()
def add(a: int, b: int) -> int:
return a + b
async def main():
async with Client(server) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
asyncio.run(main())
```
"""
# TODO(felixweinberger): Expand to support all transport types (like FastMCP 2):
# - Add ClientTransport base class with connect_session() method
# - 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__(
self,
server: Server[Any] | FastMCP,
*,
# 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:
server: The MCP server to connect to (Server or FastMCP 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
self._exit_stack: AsyncExitStack | None = None
async def __aenter__(self) -> Client:
"""Enter the async context manager."""
if self._session is not None:
raise RuntimeError("Client is already entered; cannot reenter")
async with AsyncExitStack() as exit_stack:
# Create transport and connect
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(
ClientSession(
read_stream=read_stream,
write_stream=write_stream,
read_timeout_seconds=self._read_timeout_seconds,
sampling_callback=self._sampling_callback,
list_roots_callback=self._list_roots_callback,
logging_callback=self._logging_callback,
message_handler=self._message_handler,
client_info=self._client_info,
elicitation_callback=self._elicitation_callback,
)
)
# Initialize the session
await self._session.initialize()
# Transfer ownership to self for __aexit__ to handle
self._exit_stack = exit_stack.pop_all()
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
"""Exit the async context manager."""
if self._exit_stack: # pragma: no branch
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
self._session = None
@property
def session(self) -> ClientSession:
"""Get the underlying ClientSession.
This provides access to the full ClientSession API for advanced use cases.
Raises:
RuntimeError: If accessed before entering the context manager.
"""
if self._session is None:
raise RuntimeError("Client must be used within an async context manager")
return self._session
@property
def server_capabilities(self) -> ServerCapabilities | None:
"""The server capabilities received during initialization, or None if not yet initialized."""
return self.session.get_server_capabilities()
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Send a ping request to the server."""
return await self.session.send_ping(meta=meta)
async def send_progress_notification(
self,
progress_token: str | int,
progress: float,
total: float | None = None,
message: str | None = None,
) -> None:
"""Send a progress notification to the server."""
await self.session.send_progress_notification(
progress_token=progress_token,
progress=progress,
total=total,
message=message,
)
async def set_logging_level(self, level: LoggingLevel, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Set the logging level on the server."""
return await self.session.set_logging_level(level=level, meta=meta)
async def list_resources(
self,
*,
cursor: str | None = None,
meta: RequestParamsMeta | None = None,
) -> ListResourcesResult:
"""List available resources from the server."""
return await self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
async def list_resource_templates(
self,
*,
cursor: str | None = None,
meta: RequestParamsMeta | None = None,
) -> ListResourceTemplatesResult:
"""List available resource templates from the server."""
return await self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> ReadResourceResult:
"""Read a resource from the server.
Args:
uri: The URI of the resource to read.
meta: Additional metadata for the request
Returns:
The resource content.
"""
return await self.session.read_resource(uri, meta=meta)
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Subscribe to resource updates."""
return await self.session.subscribe_resource(uri, meta=meta)
async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Unsubscribe from resource updates."""
return await self.session.unsubscribe_resource(uri, meta=meta)
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
meta: RequestParamsMeta | None = None,
) -> CallToolResult:
"""Call a tool on the server.
Args:
name: The name of the tool to call
arguments: Arguments to pass to the tool
read_timeout_seconds: Timeout for the tool call
progress_callback: Callback for progress updates
meta: Additional metadata for the request
Returns:
The tool result
"""
return await self.session.call_tool(
name=name,
arguments=arguments,
read_timeout_seconds=read_timeout_seconds,
progress_callback=progress_callback,
meta=meta,
)
async def list_prompts(
self,
*,
cursor: str | None = None,
meta: RequestParamsMeta | None = None,
) -> ListPromptsResult:
"""List available prompts from the server."""
return await self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None, *, meta: RequestParamsMeta | None = None
) -> GetPromptResult:
"""Get a prompt from the server.
Args:
name: The name of the prompt
arguments: Arguments to pass to the prompt
meta: Additional metadata for the request
Returns:
The prompt content.
"""
return await self.session.get_prompt(name=name, arguments=arguments, meta=meta)
async def complete(
self,
ref: ResourceTemplateReference | PromptReference,
argument: dict[str, str],
context_arguments: dict[str, str] | None = None,
) -> CompleteResult:
"""Get completions for a prompt or resource template argument.
Args:
ref: Reference to the prompt or resource template
argument: The argument to complete
context_arguments: Additional context arguments
Returns:
Completion suggestions.
"""
return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments)
async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta | None = None) -> ListToolsResult:
"""List available tools from the server."""
return await self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta))
async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed."""
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
await self.session.send_roots_list_changed() # pragma: no cover