from __future__ import annotations import logging from typing import Any, Protocol import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import TypeAdapter import mcp.types as types from mcp.client.experimental import ExperimentalClientFeatures from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers from mcp.shared._context import RequestContext from mcp.shared.message import SessionMessage from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types._types import RequestParamsMeta DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") logger = logging.getLogger("client") class SamplingFnT(Protocol): async def __call__( self, context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, ) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch class ElicitationFnT(Protocol): async def __call__( self, context: RequestContext[ClientSession], params: types.ElicitRequestParams, ) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch class ListRootsFnT(Protocol): async def __call__( self, context: RequestContext[ClientSession] ) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch class LoggingFnT(Protocol): async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch class MessageHandlerFnT(Protocol): async def __call__( self, message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: ... # pragma: no branch async def _default_message_handler( message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: await anyio.lowlevel.checkpoint() async def _default_sampling_callback( context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, ) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: return types.ErrorData( code=types.INVALID_REQUEST, message="Sampling not supported", ) async def _default_elicitation_callback( context: RequestContext[ClientSession], params: types.ElicitRequestParams, ) -> types.ElicitResult | types.ErrorData: return types.ErrorData( # pragma: no cover code=types.INVALID_REQUEST, message="Elicitation not supported", ) async def _default_list_roots_callback( context: RequestContext[ClientSession], ) -> types.ListRootsResult | types.ErrorData: return types.ErrorData( code=types.INVALID_REQUEST, message="List roots not supported", ) async def _default_logging_callback( params: types.LoggingMessageNotificationParams, ) -> None: pass ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) class ClientSession( BaseSession[ types.ClientRequest, types.ClientNotification, types.ClientResult, types.ServerRequest, types.ServerNotification, ] ): def __init__( self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], read_timeout_seconds: float | None = None, sampling_callback: SamplingFnT | None = None, elicitation_callback: ElicitationFnT | None = None, list_roots_callback: ListRootsFnT | None = None, logging_callback: LoggingFnT | None = None, message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, *, sampling_capabilities: types.SamplingCapability | None = None, experimental_task_handlers: ExperimentalTaskHandlers | None = None, ) -> None: super().__init__(read_stream, write_stream, read_timeout_seconds=read_timeout_seconds) self._client_info = client_info or DEFAULT_CLIENT_INFO self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback self._message_handler = message_handler or _default_message_handler self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} self._server_capabilities: types.ServerCapabilities | None = None self._experimental_features: ExperimentalClientFeatures | None = None # Experimental: Task handlers (use defaults if not provided) self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers() @property def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]: return types.server_request_adapter @property def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]: return types.server_notification_adapter async def initialize(self) -> types.InitializeResult: sampling = ( (self._sampling_capabilities or types.SamplingCapability()) if self._sampling_callback is not _default_sampling_callback else None ) elicitation = ( types.ElicitationCapability(form=types.FormElicitationCapability(), url=types.UrlElicitationCapability()) if self._elicitation_callback is not _default_elicitation_callback else None ) roots = ( # TODO: Should this be based on whether we # _will_ send notifications, or only whether # they're supported? types.RootsCapability(list_changed=True) if self._list_roots_callback is not _default_list_roots_callback else None ) result = await self.send_request( types.InitializeRequest( params=types.InitializeRequestParams( protocol_version=types.LATEST_PROTOCOL_VERSION, capabilities=types.ClientCapabilities( sampling=sampling, elicitation=elicitation, experimental=None, roots=roots, tasks=self._task_handlers.build_capability(), ), client_info=self._client_info, ), ), types.InitializeResult, ) if result.protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}") self._server_capabilities = result.capabilities await self.send_notification(types.InitializedNotification()) return result def get_server_capabilities(self) -> types.ServerCapabilities | None: """Return the server capabilities received during initialization. Returns None if the session has not been initialized yet. """ return self._server_capabilities @property def experimental(self) -> ExperimentalClientFeatures: """Experimental APIs for tasks and other features. !!! warning These APIs are experimental and may change without notice. Example: status = await session.experimental.get_task(task_id) result = await session.experimental.get_task_result(task_id, CallToolResult) """ if self._experimental_features is None: self._experimental_features = ExperimentalClientFeatures(self) return self._experimental_features async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a ping request.""" return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult) async def send_progress_notification( self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None, *, meta: RequestParamsMeta | None = None, ) -> None: """Send a progress notification.""" await self.send_notification( types.ProgressNotification( params=types.ProgressNotificationParams( progress_token=progress_token, progress=progress, total=total, message=message, _meta=meta, ), ) ) async def set_logging_level( self, level: types.LoggingLevel, *, meta: RequestParamsMeta | None = None, ) -> types.EmptyResult: """Send a logging/setLevel request.""" return await self.send_request( types.SetLevelRequest(params=types.SetLevelRequestParams(level=level, _meta=meta)), types.EmptyResult, ) async def list_resources(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListResourcesResult: """Send a resources/list request. Args: params: Full pagination parameters including cursor and any future fields """ return await self.send_request(types.ListResourcesRequest(params=params), types.ListResourcesResult) async def list_resource_templates( self, *, params: types.PaginatedRequestParams | None = None ) -> types.ListResourceTemplatesResult: """Send a resources/templates/list request. Args: params: Full pagination parameters including cursor and any future fields """ return await self.send_request( types.ListResourceTemplatesRequest(params=params), types.ListResourceTemplatesResult, ) async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.ReadResourceResult: """Send a resources/read request.""" return await self.send_request( types.ReadResourceRequest(params=types.ReadResourceRequestParams(uri=uri, _meta=meta)), types.ReadResourceResult, ) async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a resources/subscribe request.""" return await self.send_request( types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a resources/unsubscribe request.""" return await self.send_request( types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) 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, ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" result = await self.send_request( types.CallToolRequest( params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=meta), ), types.CallToolResult, request_read_timeout_seconds=read_timeout_seconds, progress_callback=progress_callback, ) if not result.is_error: await self._validate_tool_result(name, result) return result async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None: """Validate the structured content of a tool result against its output schema.""" if name not in self._tool_output_schemas: # refresh output schema cache await self.list_tools() output_schema = None if name in self._tool_output_schemas: output_schema = self._tool_output_schemas.get(name) else: logger.warning(f"Tool {name} not listed by server, cannot validate any structured content") if output_schema is not None: from jsonschema import SchemaError, ValidationError, validate if result.structured_content is None: raise RuntimeError( f"Tool {name} has an output schema but did not return structured content" ) # pragma: no cover try: validate(result.structured_content, output_schema) except ValidationError as e: raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") except SchemaError as e: # pragma: no cover raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover async def list_prompts(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListPromptsResult: """Send a prompts/list request. Args: params: Full pagination parameters including cursor and any future fields """ return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult) async def get_prompt( self, name: str, arguments: dict[str, str] | None = None, *, meta: RequestParamsMeta | None = None, ) -> types.GetPromptResult: """Send a prompts/get request.""" return await self.send_request( types.GetPromptRequest(params=types.GetPromptRequestParams(name=name, arguments=arguments, _meta=meta)), types.GetPromptResult, ) async def complete( self, ref: types.ResourceTemplateReference | types.PromptReference, argument: dict[str, str], context_arguments: dict[str, str] | None = None, ) -> types.CompleteResult: """Send a completion/complete request.""" context = None if context_arguments is not None: context = types.CompletionContext(arguments=context_arguments) return await self.send_request( types.CompleteRequest( params=types.CompleteRequestParams( ref=ref, argument=types.CompletionArgument(**argument), context=context, ), ), types.CompleteResult, ) async def list_tools(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListToolsResult: """Send a tools/list request. Args: params: Full pagination parameters including cursor and any future fields """ result = await self.send_request( types.ListToolsRequest(params=params), types.ListToolsResult, ) # Cache tool output schemas for future validation # Note: don't clear the cache, as we may be using a cursor for tool in result.tools: self._tool_output_schemas[tool.name] = tool.output_schema return result async def send_roots_list_changed(self) -> None: # pragma: no cover """Send a roots/list_changed notification.""" await self.send_notification(types.RootsListChangedNotification()) async def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None: ctx = RequestContext[ClientSession](request_id=responder.request_id, meta=responder.request_meta, session=self) # Delegate to experimental task handler if applicable if self._task_handlers.handles_request(responder.request): with responder: await self._task_handlers.handle_request(ctx, responder) return None # Core request handling match responder.request: case types.CreateMessageRequest(params=params): with responder: # Check if this is a task-augmented request if params.task is not None: response = await self._task_handlers.augmented_sampling(ctx, params, params.task) else: response = await self._sampling_callback(ctx, params) client_response = ClientResponse.validate_python(response) await responder.respond(client_response) case types.ElicitRequest(params=params): with responder: # Check if this is a task-augmented request if params.task is not None: response = await self._task_handlers.augmented_elicitation(ctx, params, params.task) else: response = await self._elicitation_callback(ctx, params) client_response = ClientResponse.validate_python(response) await responder.respond(client_response) case types.ListRootsRequest(): with responder: response = await self._list_roots_callback(ctx) client_response = ClientResponse.validate_python(response) await responder.respond(client_response) case types.PingRequest(): # pragma: no cover with responder: return await responder.respond(types.EmptyResult()) case _: # pragma: no cover pass # Task requests handled above by _task_handlers return None async def _handle_incoming( self, req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: """Handle incoming messages by forwarding to the message handler.""" await self._message_handler(req) async def _received_notification(self, notification: types.ServerNotification) -> None: """Handle notifications from the server.""" # Process specific notification types match notification: case types.LoggingMessageNotification(params=params): await self._logging_callback(params) case types.ElicitCompleteNotification(params=params): # Handle elicitation completion notification # Clients MAY use this to retry requests or update UI # The notification contains the elicitationId of the completed elicitation pass case _: pass