From 701a86e4db4033816cd48e93d982a40a919bcb26 Mon Sep 17 00:00:00 2001 From: David Soria Parra <davidsp@anthropic.com> Date: Thu, 13 Feb 2025 16:40:59 +0000 Subject: [PATCH 01/10] v1.3.0rc1 --- CHANGELOG.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2683bb46 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,148 @@ +# Changelog + +## [1.3.0rc1] - 2025-02-13 + +### Breaking Changes + +- **Context API Changes**: The Context logging methods (info, debug, warning, error) are now async and must be awaited. ([#172](https://github.com/modelcontextprotocol/python-sdk/pull/172)) +- **Resource Response Format**: Standardized resource response format to return both content and MIME type. Method `read_resource()` now returns a tuple of `(content, mime_type)` instead of just content. ([#170](https://github.com/modelcontextprotocol/python-sdk/pull/170)) + +### New Features + +#### Lifespan Support +Added comprehensive server lifecycle management through the lifespan API: +```python +@dataclass +class AppContext: + db: Database + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + try: + await db.connect() + yield AppContext(db=db) + finally: + await db.disconnect() + +mcp = FastMCP("My App", lifespan=app_lifespan) + +@mcp.tool() +def query_db(ctx: Context) -> str: + db = ctx.request_context.lifespan_context["db"] + return db.query() +``` +([#203](https://github.com/modelcontextprotocol/python-sdk/pull/203)) + +#### Async Resources +Added support for async resource functions in FastMCP: +```python +@mcp.resource("users://{user_id}") +async def get_user(user_id: str) -> str: + async with client.session() as session: + response = await session.get(f"/users/{user_id}") + return await response.text() +``` +([#157](https://github.com/modelcontextprotocol/python-sdk/pull/157)) + +#### Concurrent Request Handling +Made message handling concurrent, allowing multiple requests to be processed simultaneously. ([#206](https://github.com/modelcontextprotocol/python-sdk/pull/206)) + +#### Request Cancellation +Added support for canceling in-flight requests and cleaning up resources. ([#167](https://github.com/modelcontextprotocol/python-sdk/pull/167)) + +#### Server Instructions +Added support for the `instructions` field in server initialization, allowing servers to provide usage guidance. ([#150](https://github.com/modelcontextprotocol/python-sdk/pull/150)) + +### Bug Fixes + +- Fixed progress reporting for first tool call by correcting progress_token handling ([#176](https://github.com/modelcontextprotocol/python-sdk/pull/176)) +- Fixed server crash when using debug logging ([#158](https://github.com/modelcontextprotocol/python-sdk/pull/158)) +- Fixed resource template handling in FastMCP server ([#137](https://github.com/modelcontextprotocol/python-sdk/pull/137)) +- Fixed MIME type preservation in resource responses ([#170](https://github.com/modelcontextprotocol/python-sdk/pull/170)) +- Fixed documentation for environment variables in CLI commands ([#149](https://github.com/modelcontextprotocol/python-sdk/pull/149)) +- Fixed request ID preservation in JSON-RPC responses ([#205](https://github.com/modelcontextprotocol/python-sdk/pull/205)) + +### Dependency Updates + +- Relaxed version constraints for better compatibility: + - `pydantic`: Changed from `>=2.10.1,<3.0.0` to `>=2.7.2,<3.0.0` + - `pydantic-settings`: Changed from `>=2.6.1` to `>=2.5.2` + - `uvicorn`: Changed from `>=0.30` to `>=0.23.1` + ([#180](https://github.com/modelcontextprotocol/python-sdk/pull/180)) + +### Examples + +- Added a simple chatbot example client to demonstrate SDK usage ([#98](https://github.com/modelcontextprotocol/python-sdk/pull/98)) + +### Internal Improvements + +- Improved type annotations for better IDE support ([#181](https://github.com/modelcontextprotocol/python-sdk/pull/181)) +- Added comprehensive tests for SSE transport ([#151](https://github.com/modelcontextprotocol/python-sdk/pull/151)) +- Updated types to match 2024-11-05 MCP schema ([#165](https://github.com/modelcontextprotocol/python-sdk/pull/165)) +- Refactored request and notification handling for better code organization ([#166](https://github.com/modelcontextprotocol/python-sdk/pull/166)) + +## [1.2.1] - 2024-01-27 + +### Added +- Support for async resources +- Example and test for parameter descriptions in FastMCP tools + +### Fixed +- MCP install command with environment variables +- Resource template handling in FastMCP server (#129) +- Package in the generated MCP run config (#128) +- FastMCP logger debug output +- Handling of strings containing numbers in FastMCP (@sd2k, #142) + +### Changed +- Refactored to use built-in typing.Annotated instead of typing_extensions +- Updated uv.lock +- Added .DS_Store to gitignore + +# MCP Python SDK v1.2.0rc1 Release Notes + +## Major Features + +### FastMCP Integration +- Integrated [FastMCP](https://github.com/jlowin/fastmcp) as the recommended high-level server framework +- Added new `mcp.server.fastmcp` module with simplified decorator-based API +- Introduced `FastMCP` class for easier server creation and management +- Added comprehensive documentation and examples for FastMCP usage + +### New CLI Package +- Added new CLI package for improved developer experience +- Introduced `mcp dev` command for local development and testing +- Added `mcp install` command for Claude Desktop integration +- Added `mcp run` command for direct server execution + +## Improvements + +### Documentation +- Completely revamped README with new structure and examples +- Added detailed sections on core concepts (Resources, Tools, Prompts) +- Updated documentation to recommend FastMCP as primary API +- Added sections on development workflow and deployment options +- Improved example server documentation + +### Developer Experience +- Added pre-commit hooks for code quality +- Updated to Pydantic 2.10.0 for improved type checking +- Added uvicorn as a dependency for better server capabilities + +## Bug Fixes +- Fixed deprecation warnings in core components +- Fixed Pydantic field handling for meta fields +- Fixed type issues throughout the codebase +- Fixed example server READMEs + +## Breaking Changes +- Deprecated direct usage of `mcp.server` in favor of `mcp.server.fastmcp` +- Updated import paths for FastMCP integration +- Changed recommended installation to include CLI features (`pip install "mcp[cli]"`) + +## Contributors +Special thanks to all contributors who made this release possible, including: +- Jeremiah Lowin (FastMCP) +- Oskar Raszkiewicz + +**Full Changelog**: https://github.com/modelcontextprotocol/python-sdk/compare/v1.1.2...v1.2.0rc1 diff --git a/pyproject.toml b/pyproject.toml index 05494d85..fae776d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp" -version = "1.3.0.dev0" +version = "1.3.0rc1" description = "Model Context Protocol SDK" readme = "README.md" requires-python = ">=3.10" From 68fcf92947f7d02d50340053a72a969d6bb70e1b Mon Sep 17 00:00:00 2001 From: Henry Wildermuth <hmwildermuth@gmail.com> Date: Thu, 13 Feb 2025 14:21:50 -0800 Subject: [PATCH 02/10] Update URL validation to allow file and other nonstandard schemas --- .../mcp_simple_resource/server.py | 7 +++--- src/mcp/types.py | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 11ba5692..0ec1d926 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -2,7 +2,7 @@ import click import mcp.types as types from mcp.server.lowlevel import Server -from pydantic import AnyUrl +from pydantic import FileUrl SAMPLE_RESOURCES = { "greeting": "Hello! This is a sample text resource.", @@ -26,7 +26,7 @@ def main(port: int, transport: str) -> int: async def list_resources() -> list[types.Resource]: return [ types.Resource( - uri=AnyUrl(f"file:///{name}.txt"), + uri=FileUrl(f"file:///{name}.txt"), name=name, description=f"A sample text resource named {name}", mimeType="text/plain", @@ -35,8 +35,7 @@ async def list_resources() -> list[types.Resource]: ] @app.read_resource() - async def read_resource(uri: AnyUrl) -> str | bytes: - assert uri.path is not None + async def read_resource(uri: FileUrl) -> str | bytes: name = uri.path.replace(".txt", "").lstrip("/") if name not in SAMPLE_RESOURCES: diff --git a/src/mcp/types.py b/src/mcp/types.py index d1157aa6..7d867bd3 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -1,7 +1,15 @@ -from typing import Annotated, Any, Callable, Generic, Literal, TypeAlias, TypeVar +from typing import ( + Annotated, + Any, + Callable, + Generic, + Literal, + TypeAlias, + TypeVar, +) from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel -from pydantic.networks import AnyUrl +from pydantic.networks import AnyUrl, UrlConstraints """ Model Context Protocol bindings for Python @@ -353,7 +361,7 @@ class Annotations(BaseModel): class Resource(BaseModel): """A known resource that the server is capable of reading.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of this resource.""" name: str """A human-readable name for this resource.""" @@ -415,7 +423,7 @@ class ListResourceTemplatesResult(PaginatedResult): class ReadResourceRequestParams(RequestParams): """Parameters for reading a resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """ The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. @@ -433,7 +441,7 @@ class ReadResourceRequest(Request): class ResourceContents(BaseModel): """The contents of a specific resource or sub-resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of this resource.""" mimeType: str | None = None """The MIME type of this resource, if known.""" @@ -476,7 +484,7 @@ class ResourceListChangedNotification(Notification): class SubscribeRequestParams(RequestParams): """Parameters for subscribing to a resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """ The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. @@ -497,7 +505,7 @@ class SubscribeRequest(Request): class UnsubscribeRequestParams(RequestParams): """Parameters for unsubscribing from a resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of the resource to unsubscribe from.""" model_config = ConfigDict(extra="allow") @@ -515,7 +523,7 @@ class UnsubscribeRequest(Request): class ResourceUpdatedNotificationParams(NotificationParams): """Parameters for resource update notifications.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """ The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. From d92ee8feaa5675efddd399f3e8ebe8ed976b84c2 Mon Sep 17 00:00:00 2001 From: Randall Nortman <rngithub@wonderclown.net> Date: Mon, 10 Feb 2025 08:10:24 -0500 Subject: [PATCH 03/10] Force stdin/stdout encoding to UTF-8 The character encoding of the stdin/stdout streams in Python is platform- dependent. On Windows it will be something weird, like CP437 or CP1252, depending on the locale. This change ensures that no matter the platform, UTF-8 is used. --- src/mcp/server/stdio.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index d74d6bc4..0e0e4912 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -20,6 +20,7 @@ async def run_server(): import sys from contextlib import asynccontextmanager +from io import TextIOWrapper import anyio import anyio.lowlevel @@ -38,11 +39,13 @@ async def stdio_server( from the current process' stdin and writing to stdout. """ # Purposely not using context managers for these, as we don't want to close - # standard process handles. + # standard process handles. Encoding of stdin/stdout as text streams on + # python is platform-dependent (Windows is particularly problematic), so we + # re-wrap the underlying binary stream to ensure UTF-8. if not stdin: - stdin = anyio.wrap_file(sys.stdin) + stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8")) if not stdout: - stdout = anyio.wrap_file(sys.stdout) + stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] From b8a77fc2e8103b16e04c2d3e5831142a6c131a14 Mon Sep 17 00:00:00 2001 From: Jerome <jerome@anthropic.com> Date: Thu, 20 Feb 2025 10:49:43 +0000 Subject: [PATCH 04/10] Add client handling for sampling, list roots, ping (#218) Adds sampling and list roots callbacks to the ClientSession, allowing the client to handle requests from the server. Co-authored-by: TerminalMan <84923604+SecretiveShell@users.noreply.github.com> Co-authored-by: David Soria Parra <davidsp@anthropic.com> --- README.md | 14 +++- src/mcp/client/session.py | 98 +++++++++++++++++++++--- src/mcp/shared/memory.py | 6 +- tests/client/test_list_roots_callback.py | 70 +++++++++++++++++ tests/client/test_sampling_callback.py | 73 ++++++++++++++++++ tests/client/test_stdio.py | 7 +- 6 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 tests/client/test_list_roots_callback.py create mode 100644 tests/client/test_sampling_callback.py diff --git a/README.md b/README.md index 370b4f33..bdbc9bca 100644 --- a/README.md +++ b/README.md @@ -476,9 +476,21 @@ server_params = StdioServerParameters( env=None # Optional environment variables ) +# Optional: create a sampling callback +async def handle_sampling_message(message: types.CreateMessageRequestParams) -> types.CreateMessageResult: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stopReason="endTurn", + ) + async def run(): async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: # Initialize the connection await session.initialize() diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 4858ede5..37036e2b 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,13 +1,51 @@ from datetime import timedelta +from typing import Any, Protocol from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl +from pydantic import AnyUrl, TypeAdapter import mcp.types as types -from mcp.shared.session import BaseSession +from mcp.shared.context import RequestContext +from mcp.shared.session import BaseSession, RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +class SamplingFnT(Protocol): + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.ErrorData: ... + + +class ListRootsFnT(Protocol): + async def __call__( + self, context: RequestContext["ClientSession", Any] + ) -> types.ListRootsResult | types.ErrorData: ... + + +async def _default_sampling_callback( + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, +) -> types.CreateMessageResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Sampling not supported", + ) + + +async def _default_list_roots_callback( + context: RequestContext["ClientSession", Any], +) -> types.ListRootsResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="List roots not supported", + ) + + +ClientResponse = TypeAdapter(types.ClientResult | types.ErrorData) + + class ClientSession( BaseSession[ types.ClientRequest, @@ -22,6 +60,8 @@ def __init__( read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], write_stream: MemoryObjectSendStream[types.JSONRPCMessage], read_timeout_seconds: timedelta | None = None, + sampling_callback: SamplingFnT | None = None, + list_roots_callback: ListRootsFnT | None = None, ) -> None: super().__init__( read_stream, @@ -30,8 +70,24 @@ def __init__( types.ServerNotification, read_timeout_seconds=read_timeout_seconds, ) + self._sampling_callback = sampling_callback or _default_sampling_callback + self._list_roots_callback = list_roots_callback or _default_list_roots_callback async def initialize(self) -> types.InitializeResult: + sampling = ( + types.SamplingCapability() if self._sampling_callback is not None else None + ) + roots = ( + types.RootsCapability( + # TODO: Should this be based on whether we + # _will_ send notifications, or only whether + # they're supported? + listChanged=True, + ) + if self._list_roots_callback is not None + else None + ) + result = await self.send_request( types.ClientRequest( types.InitializeRequest( @@ -39,14 +95,9 @@ async def initialize(self) -> types.InitializeResult: params=types.InitializeRequestParams( protocolVersion=types.LATEST_PROTOCOL_VERSION, capabilities=types.ClientCapabilities( - sampling=None, + sampling=sampling, experimental=None, - roots=types.RootsCapability( - # TODO: Should this be based on whether we - # _will_ send notifications, or only whether - # they're supported? - listChanged=True - ), + roots=roots, ), clientInfo=types.Implementation(name="mcp", version="0.1.0"), ), @@ -243,3 +294,32 @@ async def send_roots_list_changed(self) -> None: ) ) ) + + async def _received_request( + self, responder: RequestResponder[types.ServerRequest, types.ClientResult] + ) -> None: + ctx = RequestContext[ClientSession, Any]( + request_id=responder.request_id, + meta=responder.request_meta, + session=self, + lifespan_context=None, + ) + + match responder.request.root: + case types.CreateMessageRequest(params=params): + with responder: + response = await self._sampling_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(): + with responder: + return await responder.respond( + types.ClientResult(root=types.EmptyResult()) + ) diff --git a/src/mcp/shared/memory.py b/src/mcp/shared/memory.py index 72549925..ae6b0be5 100644 --- a/src/mcp/shared/memory.py +++ b/src/mcp/shared/memory.py @@ -9,7 +9,7 @@ import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp.client.session import ClientSession +from mcp.client.session import ClientSession, ListRootsFnT, SamplingFnT from mcp.server import Server from mcp.types import JSONRPCMessage @@ -54,6 +54,8 @@ async def create_client_server_memory_streams() -> ( async def create_connected_server_and_client_session( server: Server, read_timeout_seconds: timedelta | None = None, + sampling_callback: SamplingFnT | None = None, + list_roots_callback: ListRootsFnT | None = None, raise_exceptions: bool = False, ) -> AsyncGenerator[ClientSession, None]: """Creates a ClientSession that is connected to a running MCP server.""" @@ -80,6 +82,8 @@ async def create_connected_server_and_client_session( read_stream=client_read, write_stream=client_write, read_timeout_seconds=read_timeout_seconds, + sampling_callback=sampling_callback, + list_roots_callback=list_roots_callback, ) as client_session: await client_session.initialize() yield client_session diff --git a/tests/client/test_list_roots_callback.py b/tests/client/test_list_roots_callback.py new file mode 100644 index 00000000..384e7676 --- /dev/null +++ b/tests/client/test_list_roots_callback.py @@ -0,0 +1,70 @@ +import pytest +from pydantic import FileUrl + +from mcp.client.session import ClientSession +from mcp.server.fastmcp.server import Context +from mcp.shared.context import RequestContext +from mcp.shared.memory import ( + create_connected_server_and_client_session as create_session, +) +from mcp.types import ( + ListRootsResult, + Root, + TextContent, +) + + +@pytest.mark.anyio +async def test_list_roots_callback(): + from mcp.server.fastmcp import FastMCP + + server = FastMCP("test") + + callback_return = ListRootsResult( + roots=[ + Root( + uri=FileUrl("file://users/fake/test"), + name="Test Root 1", + ), + Root( + uri=FileUrl("file://users/fake/test/2"), + name="Test Root 2", + ), + ] + ) + + async def list_roots_callback( + context: RequestContext[ClientSession, None], + ) -> ListRootsResult: + return callback_return + + @server.tool("test_list_roots") + async def test_list_roots(context: Context, message: str): + roots = await context.session.list_roots() + assert roots == callback_return + return True + + # Test with list_roots callback + async with create_session( + server._mcp_server, list_roots_callback=list_roots_callback + ) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_list_roots", {"message": "test message"} + ) + assert result.isError is False + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "true" + + # Test without list_roots callback + async with create_session(server._mcp_server) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_list_roots", {"message": "test message"} + ) + assert result.isError is True + assert isinstance(result.content[0], TextContent) + assert ( + result.content[0].text + == "Error executing tool test_list_roots: List roots not supported" + ) diff --git a/tests/client/test_sampling_callback.py b/tests/client/test_sampling_callback.py new file mode 100644 index 00000000..ba586d4a --- /dev/null +++ b/tests/client/test_sampling_callback.py @@ -0,0 +1,73 @@ +import pytest + +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext +from mcp.shared.memory import ( + create_connected_server_and_client_session as create_session, +) +from mcp.types import ( + CreateMessageRequestParams, + CreateMessageResult, + SamplingMessage, + TextContent, +) + + +@pytest.mark.anyio +async def test_sampling_callback(): + from mcp.server.fastmcp import FastMCP + + server = FastMCP("test") + + callback_return = CreateMessageResult( + role="assistant", + content=TextContent( + type="text", text="This is a response from the sampling callback" + ), + model="test-model", + stopReason="endTurn", + ) + + async def sampling_callback( + context: RequestContext[ClientSession, None], + params: CreateMessageRequestParams, + ) -> CreateMessageResult: + return callback_return + + @server.tool("test_sampling") + async def test_sampling_tool(message: str): + value = await server.get_context().session.create_message( + messages=[ + SamplingMessage( + role="user", content=TextContent(type="text", text=message) + ) + ], + max_tokens=100, + ) + assert value == callback_return + return True + + # Test with sampling callback + async with create_session( + server._mcp_server, sampling_callback=sampling_callback + ) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_sampling", {"message": "Test message for sampling"} + ) + assert result.isError is False + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "true" + + # Test without sampling callback + async with create_session(server._mcp_server) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_sampling", {"message": "Test message for sampling"} + ) + assert result.isError is True + assert isinstance(result.content[0], TextContent) + assert ( + result.content[0].text + == "Error executing tool test_sampling: Sampling not supported" + ) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 0bdec72d..95747ffd 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -1,12 +1,17 @@ +import shutil + import pytest from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse +tee: str = shutil.which("tee") # type: ignore + @pytest.mark.anyio +@pytest.mark.skipif(tee is None, reason="could not find tee command") async def test_stdio_client(): - server_parameters = StdioServerParameters(command="/usr/bin/tee") + server_parameters = StdioServerParameters(command=tee) async with stdio_client(server_parameters) as (read_stream, write_stream): # Test sending and receiving messages From e503de1c053fd9eb4781e580d2b1426843def760 Mon Sep 17 00:00:00 2001 From: David Soria Parra <davidsp@anthropic.com> Date: Thu, 20 Feb 2025 11:04:29 +0000 Subject: [PATCH 05/10] fix: mark test as pytest.mark.anyio --- tests/issues/test_188_concurrency.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/issues/test_188_concurrency.py b/tests/issues/test_188_concurrency.py index a56c0d30..2aa6c49c 100644 --- a/tests/issues/test_188_concurrency.py +++ b/tests/issues/test_188_concurrency.py @@ -1,4 +1,5 @@ import anyio +import pytest from pydantic import AnyUrl from mcp.server.fastmcp import FastMCP @@ -10,6 +11,7 @@ _resource_name = "slow://slow_resource" +@pytest.mark.anyio async def test_messages_are_executed_concurrently(): server = FastMCP("test") From 2ab3d256f6daca54f51b27c213ec312d4f269716 Mon Sep 17 00:00:00 2001 From: David Soria Parra <davidsp@anthropic.com> Date: Thu, 20 Feb 2025 11:04:46 +0000 Subject: [PATCH 06/10] fix: ruff format --- src/mcp/client/session.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 37036e2b..c1cc5b5f 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -21,7 +21,7 @@ async def __call__( class ListRootsFnT(Protocol): async def __call__( self, context: RequestContext["ClientSession", Any] - ) -> types.ListRootsResult | types.ErrorData: ... + ) -> types.ListRootsResult | types.ErrorData: ... async def _default_sampling_callback( @@ -36,7 +36,7 @@ async def _default_sampling_callback( async def _default_list_roots_callback( context: RequestContext["ClientSession", Any], -) -> types.ListRootsResult | types.ErrorData: +) -> types.ListRootsResult | types.ErrorData: return types.ErrorData( code=types.INVALID_REQUEST, message="List roots not supported", @@ -317,7 +317,7 @@ async def _received_request( response = await self._list_roots_callback(ctx) client_response = ClientResponse.validate_python(response) await responder.respond(client_response) - + case types.PingRequest(): with responder: return await responder.respond( From 2eb9d1786a99862b35ba93e4a45ecac3f2d2da5d Mon Sep 17 00:00:00 2001 From: Jerome <jerome@anthropic.com> Date: Thu, 20 Feb 2025 11:07:54 +0000 Subject: [PATCH 07/10] Merge pull request #217 from modelcontextprotocol/jerome/fix/request-context-typing Updated typing on request context for the server to use server session --- src/mcp/server/fastmcp/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 5ae30a5c..e08a161c 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -34,6 +34,7 @@ from mcp.server.lowlevel.server import ( lifespan as default_lifespan, ) +from mcp.server.session import ServerSession from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.shared.context import RequestContext @@ -597,7 +598,7 @@ def my_tool(x: int, ctx: Context) -> str: The context is optional - tools that don't need it can omit the parameter. """ - _request_context: RequestContext | None + _request_context: RequestContext[ServerSession, Any] | None _fastmcp: FastMCP | None def __init__( From c8ce49c92db4e8fb86c37318e37aefe1f9cbcfec Mon Sep 17 00:00:00 2001 From: David Soria Parra <167242713+dsp-ant@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:31:26 +0000 Subject: [PATCH 08/10] Fix #177: Returning multiple tool results (#222) * feat: allow lowlevel servers to return a list of resources The resource/read message in MCP allows of multiple resources to be returned. However, in the SDK we do not allow this. This change is such that we allow returning multiple resource in the lowlevel API if needed. However in FastMCP we stick to one, since a FastMCP resource defines the mime_type in the decorator and hence a resource cannot dynamically return different mime_typed resources. It also is just the better default to only return one resource. However in the lowlevel API we will allow this. Strictly speaking this is not a BC break since the new return value is additive, but if people subclassed server, it will break them. * feat: lower the type requriements for call_tool to Iterable --- src/mcp/server/fastmcp/server.py | 8 +++--- src/mcp/server/lowlevel/server.py | 25 ++++++++++++----- tests/issues/test_141_resource_templates.py | 6 +++-- tests/issues/test_152_resource_mime_type.py | 8 +++--- .../fastmcp/servers/test_file_server.py | 15 ++++++++--- tests/server/fastmcp/test_server.py | 5 +++- tests/server/test_read_resource.py | 27 +++++++++++-------- 7 files changed, 62 insertions(+), 32 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index e08a161c..122acebb 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -3,7 +3,7 @@ import inspect import json import re -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterable from contextlib import ( AbstractAsyncContextManager, asynccontextmanager, @@ -236,7 +236,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: for template in templates ] - async def read_resource(self, uri: AnyUrl | str) -> ReadResourceContents: + async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]: """Read a resource by URI.""" resource = await self._resource_manager.get_resource(uri) @@ -245,7 +245,7 @@ async def read_resource(self, uri: AnyUrl | str) -> ReadResourceContents: try: content = await resource.read() - return ReadResourceContents(content=content, mime_type=resource.mime_type) + return [ReadResourceContents(content=content, mime_type=resource.mime_type)] except Exception as e: logger.error(f"Error reading resource {uri}: {e}") raise ResourceError(str(e)) @@ -649,7 +649,7 @@ async def report_progress( progress_token=progress_token, progress=progress, total=total ) - async def read_resource(self, uri: str | AnyUrl) -> ReadResourceContents: + async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: """Read a resource by URI. Args: diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c0008b32..25e94365 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -67,9 +67,9 @@ async def main(): import contextvars import logging import warnings -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, AsyncIterator, Generic, Sequence, TypeVar +from typing import Any, AsyncIterator, Generic, TypeVar import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream @@ -279,7 +279,9 @@ async def handler(_: Any): def read_resource(self): def decorator( - func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]], + func: Callable[ + [AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]] + ], ): logger.debug("Registering handler for ReadResourceRequest") @@ -307,13 +309,22 @@ def create_content(data: str | bytes, mime_type: str | None): case str() | bytes() as data: warnings.warn( "Returning str or bytes from read_resource is deprecated. " - "Use ReadResourceContents instead.", + "Use Iterable[ReadResourceContents] instead.", DeprecationWarning, stacklevel=2, ) content = create_content(data, None) - case ReadResourceContents() as contents: - content = create_content(contents.content, contents.mime_type) + case Iterable() as contents: + contents_list = [ + create_content(content_item.content, content_item.mime_type) + for content_item in contents + if isinstance(content_item, ReadResourceContents) + ] + return types.ServerResult( + types.ReadResourceResult( + contents=contents_list, + ) + ) case _: raise ValueError( f"Unexpected return type from read_resource: {type(result)}" @@ -387,7 +398,7 @@ def decorator( func: Callable[ ..., Awaitable[ - Sequence[ + Iterable[ types.TextContent | types.ImageContent | types.EmbeddedResource ] ], diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index d6526e9f..3c17cd55 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -51,8 +51,10 @@ def get_user_profile_missing(user_id: str) -> str: # Verify valid template works result = await mcp.read_resource("resource://users/123/posts/456") - assert result.content == "Post 456 by user 123" - assert result.mime_type == "text/plain" + result_list = list(result) + assert len(result_list) == 1 + assert result_list[0].content == "Post 456 by user 123" + assert result_list[0].mime_type == "text/plain" # Verify invalid parameters raise error with pytest.raises(ValueError, match="Unknown resource"): diff --git a/tests/issues/test_152_resource_mime_type.py b/tests/issues/test_152_resource_mime_type.py index 7a1b6606..1143195e 100644 --- a/tests/issues/test_152_resource_mime_type.py +++ b/tests/issues/test_152_resource_mime_type.py @@ -99,11 +99,11 @@ async def handle_list_resources(): @server.read_resource() async def handle_read_resource(uri: AnyUrl): if str(uri) == "test://image": - return ReadResourceContents(content=base64_string, mime_type="image/png") + return [ReadResourceContents(content=base64_string, mime_type="image/png")] elif str(uri) == "test://image_bytes": - return ReadResourceContents( - content=bytes(image_bytes), mime_type="image/png" - ) + return [ + ReadResourceContents(content=bytes(image_bytes), mime_type="image/png") + ] raise Exception(f"Resource not found: {uri}") # Test that resources are listed with correct mime type diff --git a/tests/server/fastmcp/servers/test_file_server.py b/tests/server/fastmcp/servers/test_file_server.py index edaaa159..c51ecb25 100644 --- a/tests/server/fastmcp/servers/test_file_server.py +++ b/tests/server/fastmcp/servers/test_file_server.py @@ -88,7 +88,10 @@ async def test_list_resources(mcp: FastMCP): @pytest.mark.anyio async def test_read_resource_dir(mcp: FastMCP): - res = await mcp.read_resource("dir://test_dir") + res_iter = await mcp.read_resource("dir://test_dir") + res_list = list(res_iter) + assert len(res_list) == 1 + res = res_list[0] assert res.mime_type == "text/plain" files = json.loads(res.content) @@ -102,7 +105,10 @@ async def test_read_resource_dir(mcp: FastMCP): @pytest.mark.anyio async def test_read_resource_file(mcp: FastMCP): - res = await mcp.read_resource("file://test_dir/example.py") + res_iter = await mcp.read_resource("file://test_dir/example.py") + res_list = list(res_iter) + assert len(res_list) == 1 + res = res_list[0] assert res.content == "print('hello world')" @@ -119,5 +125,8 @@ async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): await mcp.call_tool( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) - res = await mcp.read_resource("file://test_dir/example.py") + res_iter = await mcp.read_resource("file://test_dir/example.py") + res_list = list(res_iter) + assert len(res_list) == 1 + res = res_list[0] assert res.content == "File not found" diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index d90e9939..5d375ccc 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -581,7 +581,10 @@ def test_resource() -> str: @mcp.tool() async def tool_with_resource(ctx: Context) -> str: - r = await ctx.read_resource("test://data") + r_iter = await ctx.read_resource("test://data") + r_list = list(r_iter) + assert len(r_list) == 1 + r = r_list[0] return f"Read resource: {r.content} with mime type {r.mime_type}" async with client_session(mcp._mcp_server) as client: diff --git a/tests/server/test_read_resource.py b/tests/server/test_read_resource.py index de00bc3d..469eef85 100644 --- a/tests/server/test_read_resource.py +++ b/tests/server/test_read_resource.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from pathlib import Path from tempfile import NamedTemporaryFile @@ -26,8 +27,8 @@ async def test_read_resource_text(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> ReadResourceContents: - return ReadResourceContents(content="Hello World", mime_type="text/plain") + async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ReadResourceContents(content="Hello World", mime_type="text/plain")] # Get the handler directly from the server handler = server.request_handlers[types.ReadResourceRequest] @@ -54,10 +55,12 @@ async def test_read_resource_binary(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> ReadResourceContents: - return ReadResourceContents( - content=b"Hello World", mime_type="application/octet-stream" - ) + async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ + ReadResourceContents( + content=b"Hello World", mime_type="application/octet-stream" + ) + ] # Get the handler directly from the server handler = server.request_handlers[types.ReadResourceRequest] @@ -83,11 +86,13 @@ async def test_read_resource_default_mime(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> ReadResourceContents: - return ReadResourceContents( - content="Hello World", - # No mime_type specified, should default to text/plain - ) + async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ + ReadResourceContents( + content="Hello World", + # No mime_type specified, should default to text/plain + ) + ] # Get the handler directly from the server handler = server.request_handlers[types.ReadResourceRequest] From 689c54c5915dda3ba484e55a59c126cb46dfc739 Mon Sep 17 00:00:00 2001 From: David Soria Parra <davidsp@anthropic.com> Date: Thu, 20 Feb 2025 21:39:30 +0000 Subject: [PATCH 09/10] v1.3.0 --- CHANGELOG.md | 12 +++++++++++- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2683bb46..80c3605e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.3.0rc1] - 2025-02-13 +## [1.3.0] - 2025-02-20 ### Breaking Changes @@ -74,6 +74,16 @@ Added support for the `instructions` field in server initialization, allowing se - Added a simple chatbot example client to demonstrate SDK usage ([#98](https://github.com/modelcontextprotocol/python-sdk/pull/98)) +### Client Improvements + +- Added client support for sampling, list roots, and ping requests ([#218](https://github.com/modelcontextprotocol/python-sdk/pull/218)) +- Added flexible type system for tool result returns ([#222](https://github.com/modelcontextprotocol/python-sdk/pull/222)) + +### Compatibility and Platform Support + +- Updated URL validation to allow file and other nonstandard schemas ([#68fcf92](https://github.com/modelcontextprotocol/python-sdk/commit/68fcf92947f7d02d50340053a72a969d6bb70e1b)) +- Force stdin/stdout encoding to UTF-8 for cross-platform compatibility ([#d92ee8f](https://github.com/modelcontextprotocol/python-sdk/commit/d92ee8feaa5675efddd399f3e8ebe8ed976b84c2)) + ### Internal Improvements - Improved type annotations for better IDE support ([#181](https://github.com/modelcontextprotocol/python-sdk/pull/181)) diff --git a/pyproject.toml b/pyproject.toml index fae776d1..2a5558a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp" -version = "1.3.0rc1" +version = "1.3.0" description = "Model Context Protocol SDK" readme = "README.md" requires-python = ">=3.10" From d72a0f7000869d55a01633226d56859f02069cf3 Mon Sep 17 00:00:00 2001 From: Matt Zhou <mattzh1314@gmail.com> Date: Fri, 14 Mar 2025 11:54:16 -0700 Subject: [PATCH 10/10] Add startup wait time to stdio client --- pyproject.toml | 1 + src/mcp/client/stdio.py | 37 ++++++++++++++++++++++++--------- tests/client/test_stdio.py | 42 ++++++++++++++++++++++++++++++-------- uv.lock | 8 ++++++-- 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2a5558a4..c0a62c32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "sse-starlette>=1.6.1", "pydantic-settings>=2.5.2", "uvicorn>=0.23.1", + "exceptiongroup>=1.2.2", ] [project.optional-dependencies] diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 2eaa3475..e203ee3a 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -83,7 +83,7 @@ class StdioServerParameters(BaseModel): @asynccontextmanager -async def stdio_client(server: StdioServerParameters): +async def stdio_client(server: StdioServerParameters, startup_wait_time: float = 0.0): """ Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. @@ -97,11 +97,17 @@ async def stdio_client(server: StdioServerParameters): read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - process = await anyio.open_process( - [server.command, *server.args], - env=server.env if server.env is not None else get_default_environment(), - stderr=sys.stderr, - ) + try: + process = await anyio.open_process( + [server.command, *server.args], + env=server.env or get_default_environment(), + stderr=sys.stderr, + ) + except OSError as exc: + raise RuntimeError( + f"Failed to spawn process: {server.command} {server.args}. " + f"Check that the binary exists and is executable." + ) from exc async def stdout_reader(): assert process.stdout, "Opened process is missing stdout" @@ -144,10 +150,21 @@ async def stdin_writer(): except anyio.ClosedResourceError: await anyio.lowlevel.checkpoint() - async with ( - anyio.create_task_group() as tg, - process, - ): + async def watch_process_exit(): + returncode = await process.wait() + if returncode != 0: + raise RuntimeError( + f"Subprocess exited with code {returncode}. " + f"Command: {server.command}, {server.args}" + ) + + async with anyio.create_task_group() as tg, process: tg.start_soon(stdout_reader) tg.start_soon(stdin_writer) + tg.start_soon(watch_process_exit) + + if startup_wait_time > 0: + with anyio.move_on_after(startup_wait_time): + await anyio.sleep_forever() + yield read_stream, write_stream diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 95747ffd..d1d2b5ea 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -1,4 +1,11 @@ +import re import shutil +import sys + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup +else: + ExceptionGroup = ExceptionGroup import pytest @@ -14,7 +21,6 @@ async def test_stdio_client(): server_parameters = StdioServerParameters(command=tee) async with stdio_client(server_parameters) as (read_stream, write_stream): - # Test sending and receiving messages messages = [ JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")), JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})), @@ -31,13 +37,31 @@ async def test_stdio_client(): raise message read_messages.append(message) - if len(read_messages) == 2: + if len(read_messages) == len(messages): break - assert len(read_messages) == 2 - assert read_messages[0] == JSONRPCMessage( - root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") - ) - assert read_messages[1] == JSONRPCMessage( - root=JSONRPCResponse(jsonrpc="2.0", id=2, result={}) - ) + assert read_messages == messages + + +@pytest.mark.anyio +async def test_stdio_client_spawn_failure(): + server_parameters = StdioServerParameters(command="/does/not/exist") + + with pytest.raises(RuntimeError, match="Failed to spawn process"): + async with stdio_client(server_parameters): + pytest.fail("Should never be reached.") + + +@pytest.mark.anyio +async def test_stdio_client_nonzero_exit(): + server_parameters = StdioServerParameters( + command="python", args=["-c", "import sys; sys.exit(2)"] + ) + + with pytest.raises(ExceptionGroup) as eg_info: + async with stdio_client(server_parameters, startup_wait_time=0.2): + pytest.fail("Should never be reached.") + + exc = eg_info.value.exceptions[0] + assert isinstance(exc, RuntimeError) + assert re.search(r"exited with code 2", str(exc)) diff --git a/uv.lock b/uv.lock index 7ff1a3ea..fd1c27b7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.10" [options] @@ -78,7 +79,7 @@ name = "click" version = "8.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/2b/7ebad1e59a99207d417c0784f7fb67893465eef84b5b47c788324f1b4095/click-8.1.0.tar.gz", hash = "sha256:977c213473c7665d3aa092b41ff12063227751c41d7b17165013e10069cc5cd2", size = 329986 } wheels = [ @@ -191,10 +192,11 @@ wheels = [ [[package]] name = "mcp" -version = "1.3.0.dev0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "anyio" }, + { name = "exceptiongroup" }, { name = "httpx" }, { name = "httpx-sse" }, { name = "pydantic" }, @@ -226,6 +228,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.5" }, + { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-sse", specifier = ">=0.4" }, { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, @@ -237,6 +240,7 @@ requires-dist = [ { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, { name = "uvicorn", specifier = ">=0.23.1" }, ] +provides-extras = ["rich", "cli"] [package.metadata.requires-dev] dev = [