From 526b831bec81ea03942a5e46739e85bc80152b32 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 19 May 2025 20:49:03 -0400 Subject: [PATCH 1/7] Store the initialize result on the client --- src/fastmcp/client/client.py | 45 ++++++++++++++++-------- src/fastmcp/client/transports.py | 11 +++--- tests/client/test_client.py | 41 ++++++++++++++++++--- tests/server/test_server_interactions.py | 4 +-- 4 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 320addb8c..0bfdddeaa 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,5 +1,5 @@ import datetime -from contextlib import AsyncExitStack +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from typing import Any, cast @@ -80,6 +80,7 @@ class Client: self._session: ClientSession | None = None self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 + self._initialize_result: mcp.types.InitializeResult | None = None if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) @@ -103,10 +104,19 @@ class Client: """Get the current active session. Raises RuntimeError if not connected.""" if self._session is None: raise RuntimeError( - "Client is not connected. Use 'async with client:' context manager first." + "Client is not connected. Use the 'async with client:' context manager first." ) return self._session + @property + def initialize_result(self) -> mcp.types.InitializeResult: + """Get the result of the initialization request.""" + if self._initialize_result is None: + raise RuntimeError( + "Client is not connected. Use the 'async with client:' context manager first." + ) + return self._initialize_result + def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" self._session_kwargs["list_roots_callback"] = create_roots_callback(roots) @@ -121,27 +131,35 @@ class Client: """Check if the client is currently connected.""" return self._session is not None + @asynccontextmanager + async def _context_manager(self): + with catch(get_catch_handlers()): + async with self.transport.connect_session( + **self._session_kwargs + ) as session: + self._session = session + # Initialize the session + self._initialize_result = await self._session.initialize() + + try: + yield + finally: + self._exit_stack = None + self._session = None + self._initialize_result = None + async def __aenter__(self): if self._nesting_counter == 0: # Create exit stack to manage both context managers stack = AsyncExitStack() await stack.__aenter__() - # Add the exception handling context - stack.enter_context(catch(get_catch_handlers())) + await stack.enter_async_context(self._context_manager()) - # the above catch will only apply once this __aenter__ finishes so - # we need to wrap the session creation in a new context in case it - # raises errors itself - with catch(get_catch_handlers()): - # Create and enter the transport session using the exit stack - session_cm = self.transport.connect_session(**self._session_kwargs) - self._session = await stack.enter_async_context(session_cm) - - # Store the stack for cleanup in __aexit__ self._exit_stack = stack self._nesting_counter += 1 + return self async def __aexit__(self, exc_type, exc_val, exc_tb): @@ -154,7 +172,6 @@ class Client: await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) finally: self._exit_stack = None - self._session = None # --- MCP Client Methods --- diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7faeab613..0b0510ce5 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -44,6 +44,7 @@ class ClientTransport(abc.ABC): A Transport is responsible for establishing and managing connections to an MCP server, and providing a ClientSession within an async context. + """ @abc.abstractmethod @@ -52,7 +53,9 @@ class ClientTransport(abc.ABC): self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: """ - Establishes a connection and yields an active, initialized ClientSession. + Establishes a connection and yields an active ClientSession. + + The ClientSession is *not* expected to be initialized in this context manager. The session is guaranteed to be valid only within the scope of the async context manager. Connection setup and teardown are handled @@ -63,7 +66,7 @@ class ClientTransport(abc.ABC): constructor (e.g., callbacks, timeouts). Yields: - An initialized mcp.ClientSession instance. + A mcp.ClientSession instance. """ raise NotImplementedError yield None # type: ignore @@ -92,7 +95,6 @@ class WSTransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() # Initialize after session creation yield session def __repr__(self) -> str: @@ -141,7 +143,6 @@ class SSETransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() yield session def __repr__(self) -> str: @@ -187,7 +188,6 @@ class StreamableHttpTransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() yield session def __repr__(self) -> str: @@ -235,7 +235,6 @@ class StdioTransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() yield session def __repr__(self) -> str: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d88a7c53d..cd7464033 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -250,18 +250,51 @@ async def test_read_resource_mcp(fastmcp_server): async def test_client_connection(fastmcp_server): - """Test that the client connects and disconnects properly.""" + """Test that connect is idempotent.""" client = Client(transport=FastMCPTransport(fastmcp_server)) - # Before connection + # Connect idempotently + async with client: + assert client.is_connected() + # Make a request to ensure connection is working + await client.ping() assert not client.is_connected() - # During connection + +async def test_initialize_result_connected(fastmcp_server): + """Test that initialize_result returns the correct result when connected.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + + # Initialize result should not be accessible before connection + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.initialize_result + + async with client: + # Once connected, initialize_result should be available + result = client.initialize_result + + # Verify the initialize result has expected properties + assert hasattr(result, "serverInfo") + assert result.serverInfo.name == "TestServer" + assert result.serverInfo.version is not None + + +async def test_initialize_result_disconnected(fastmcp_server): + """Test that initialize_result raises an error when not connected.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + + # Initialize result should not be accessible before connection + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.initialize_result + + # Connect and then disconnect async with client: assert client.is_connected() - # After connection + # After disconnection, initialize_result should raise an error assert not client.is_connected() + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.initialize_result async def test_client_nested_context_manager(fastmcp_server): diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 1f9ef6f2b..2d8d2504b 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -640,7 +640,6 @@ class TestToolContextInjection: assert len(result) == 1 content = result[0] assert isinstance(content, TextContent) - assert content.text == "1" async def test_async_context(self): """Test that context works in async functions.""" @@ -798,7 +797,6 @@ class TestResourceContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) - assert result[0].text == "1" class TestResourceTemplates: @@ -1096,7 +1094,7 @@ class TestResourceTemplateContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Resource template: test 1" + assert result[0].text.startswith("Resource template: test") class TestPrompts: From 8490905b0ca26490f54bbe9bd3b5d1edb2ab7457 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 08:57:02 -0400 Subject: [PATCH 2/7] Infer sse transport from url --- docs/clients/transports.mdx | 32 +++++++++++++----- src/fastmcp/client/transports.py | 44 ++++++++++++------------ tests/client/test_client.py | 57 +++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 2b2b24ec5..4653c7233 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -40,7 +40,7 @@ Streamable HTTP is the recommended transport for web-based deployments, providin #### Overview - **Class:** `fastmcp.client.transports.StreamableHttpTransport` -- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) +- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) that do not contain `/sse/` in the path - **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode #### Basic Usage @@ -62,6 +62,15 @@ async def main(): asyncio.run(main()) ``` +You can also explicitly instantiate the transport: + +```python +from fastmcp.client.transports import StreamableHttpTransport + +transport = StreamableHttpTransport(url="https://example.com/mcp") +client = Client(transport) +``` + #### Authentication with Headers For servers requiring authentication: @@ -88,23 +97,19 @@ Server-Sent Events (SSE) is a transport that allows servers to push data to clie #### Overview - **Class:** `fastmcp.client.transports.SSETransport` -- **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified) +- **Inferred From:** HTTP URLs containing `/sse/` in the path - **Server Compatibility:** Works with FastMCP servers running in `sse` mode #### Basic Usage -Since v2.3.0, you must explicitly create an `SSETransport` for SSE connections: +The simplest way to use SSE is to let the transport be inferred from a URL with `/sse/` in the path: ```python from fastmcp import Client -from fastmcp.client.transports import SSETransport import asyncio -# Create an SSE transport -transport = SSETransport(url="https://example.com/sse") - -# Pass the transport to the client -client = Client(transport) +# The Client automatically uses SSETransport for URLs containing /sse/ in the path +client = Client("https://example.com/sse") async def main(): async with client: @@ -114,6 +119,15 @@ async def main(): asyncio.run(main()) ``` +You can also explicitly instantiate the transport for URLs that do not contain `/sse/` in the path or for more control: + +```python +from fastmcp.client.transports import SSETransport + +transport = SSETransport(url="https://example.com/sse") +client = Client(transport) +``` + #### Authentication with Headers SSE transport also supports custom headers for authentication: diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7faeab613..debc2629d 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -1,14 +1,13 @@ import abc import contextlib import datetime -import inspect import os import shutil import sys -import warnings from collections.abc import AsyncIterator from pathlib import Path from typing import Any, TypedDict, cast +from urllib.parse import urlparse from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( @@ -26,6 +25,9 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.server import FastMCP as FastMCPServer +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) class SessionKwargs(TypedDict, total=False): @@ -486,36 +488,29 @@ def infer_transport( # the transport is a FastMCP server elif isinstance(transport, FastMCPServer): - return FastMCPTransport(mcp=transport) + inferred_transport = FastMCPTransport(mcp=transport) # the transport is a path to a script elif isinstance(transport, Path | str) and Path(transport).exists(): if str(transport).endswith(".py"): - return PythonStdioTransport(script_path=transport) + inferred_transport = PythonStdioTransport(script_path=transport) elif str(transport).endswith(".js"): - return NodeStdioTransport(script_path=transport) + inferred_transport = NodeStdioTransport(script_path=transport) else: raise ValueError(f"Unsupported script type: {transport}") # the transport is an http(s) URL elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"): - if str(transport).rstrip("/").endswith("/sse"): - warnings.warn( - inspect.cleandoc( - """ - As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP. - The provided URL ends in `/sse`, so you may encounter unexpected behavior. - If you intended to use SSE, please use the `SSETransport` class directly. - """ - ), - category=UserWarning, - stacklevel=2, - ) - return StreamableHttpTransport(url=transport) + transport_str = str(transport) + # Parse out just the path portion to check for /sse + parsed_url = urlparse(transport_str) + path = parsed_url.path - # the transport is a websocket URL - elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"): - return WSTransport(url=transport) + # Check if path contains /sse/ or ends with /sse + if "/sse/" in path or path.rstrip("/").endswith("/sse"): + inferred_transport = SSETransport(url=transport) + else: + inferred_transport = StreamableHttpTransport(url=transport) ## if the transport is a config dict elif isinstance(transport, dict): @@ -530,7 +525,7 @@ def infer_transport( server_name = list(server.keys())[0] # Stdio transport if "command" in server[server_name] and "args" in server[server_name]: - return StdioTransport( + inferred_transport = StdioTransport( command=server[server_name]["command"], args=server[server_name]["args"], env=server[server_name].get("env", None), @@ -539,7 +534,7 @@ def infer_transport( # HTTP transport elif "url" in server: - return SSETransport( + inferred_transport = SSETransport( url=server["url"], headers=server.get("headers", None), ) @@ -549,3 +544,6 @@ def infer_transport( # the transport is an unknown type else: raise ValueError(f"Could not infer a valid transport from: {transport}") + + logger.debug(f"Inferred transport: {inferred_transport}") + return inferred_transport diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d88a7c53d..21b9d81f4 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -6,7 +6,12 @@ from mcp import McpError from pydantic import AnyUrl from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport +from fastmcp.client.transports import ( + FastMCPTransport, + SSETransport, + StreamableHttpTransport, + infer_transport, +) from fastmcp.exceptions import ResourceError, ToolError from fastmcp.prompts.prompt import TextContent from fastmcp.server.server import FastMCP @@ -543,3 +548,53 @@ class TestTimeout: timeout=0.01, ) as client: await client.call_tool("sleep", {"seconds": 0.1}, timeout=2) + + +class TestInferTransport: + """Tests for the infer_transport function.""" + + @pytest.mark.parametrize( + "url", + [ + "http://example.com/api/sse/stream", + "https://localhost:8080/mcp/sse/endpoint", + "http://example.com/api/sse", + "https://localhost:8080/mcp/sse", + "http://example.com/api/sse?param=value", + "https://localhost:8080/mcp/sse/?param=value", + "https://localhost:8000/mcp/sse?x=1&y=2", + ], + ids=[ + "path_with_sse_directory", + "path_with_sse_subdirectory", + "path_ending_with_sse", + "path_ending_with_sse_https", + "path_with_sse_and_query_params", + "path_with_sse_slash_and_query_params", + "path_with_sse_and_ampersand_param", + ], + ) + def test_url_returns_sse_transport(self, url): + """Test that URLs with /sse/ pattern return SSETransport.""" + assert isinstance(infer_transport(url), SSETransport) + + @pytest.mark.parametrize( + "url", + [ + "http://example.com/api", + "https://localhost:8080/mcp", + "http://example.com/asset/image.jpg", + "https://localhost:8080/sservice/endpoint", + "https://example.com/assets/file", + ], + ids=[ + "regular_http_url", + "regular_https_url", + "url_with_unrelated_path", + "url_with_sservice_in_path", + "url_with_assets_in_path", + ], + ) + def test_url_returns_streamable_http_transport(self, url): + """Test that URLs without /sse/ pattern return StreamableHttpTransport.""" + assert isinstance(infer_transport(url), StreamableHttpTransport) From c71138d6b822d78cac072bd94a04bdaa5021567a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:27:47 -0400 Subject: [PATCH 3/7] Add progress handler to client --- docs/clients/client.mdx | 79 +++++++++++++++++++++++++++++++- src/fastmcp/client/client.py | 30 ++++++++++-- src/fastmcp/client/logging.py | 8 ++++ src/fastmcp/client/progress.py | 38 +++++++++++++++ src/fastmcp/client/transports.py | 2 + tests/client/test_progress.py | 68 +++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 src/fastmcp/client/progress.py create mode 100644 tests/client/test_progress.py diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 5c9607800..3b086ab5c 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -18,6 +18,17 @@ The FastMCP Client architecture separates the protocol logic (`Client`) from the - **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks. - **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory). +```python +from fastmcp import Client, FastMCP +from fastmcp.client import ( + RootsHandler, + RootsList, + LogHandler, + MessageHandler, + SamplingHandler, + ProgressHandler # For handling progress notifications +) +``` ### Transports @@ -114,7 +125,7 @@ The standard client methods return user-friendly representations that may change tools = await client.list_tools() # tools -> list[mcp.types.Tool] ``` -* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server. +* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None, progress_handler: ProgressHandler | None = None)`**: Executes a tool on the server. ```python result = await client.call_tool("add", {"a": 5, "b": 3}) # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...] @@ -122,10 +133,18 @@ The standard client methods return user-friendly representations that may change # With timeout (aborts if execution takes longer than 2 seconds) result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0) + + # With progress handler (to track execution progress) + result = await client.call_tool( + "long_running_task", + {"param": "value"}, + progress_handler=my_progress_handler + ) ``` * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed. * Returns a list of content objects (usually `TextContent` or `ImageContent`). * The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout. + * The optional `progress_handler` parameter receives progress updates during execution, overriding any client-level progress handler. #### Resource Operations @@ -234,6 +253,64 @@ Timeout behavior varies between transport types: For consistent behavior across all transports, we recommend explicitly setting timeouts at the individual tool call level when needed, rather than relying on client-level timeouts. +#### Progress Tracking + + + +MCP servers can report progress during long-running operations. The client can set a progress handler to receive and process these updates. + +```python +from fastmcp import Client +from fastmcp.client.progress import ProgressHandler + +# A simple progress handler that prints progress updates +async def my_progress_handler( + progress: float, + total: float | None, + message: str | None +) -> None: + """Handle progress updates from the server.""" + if total is not None: + percent = (progress / total) * 100 + print(f"Progress: {percent:.1f}% ({progress}/{total})") + else: + print(f"Progress: {progress}") + + if message: + print(f"Message: {message}") + +# Set the progress handler at client level +client = Client( + my_mcp_server, + progress_handler=my_progress_handler +) +``` + +By default, FastMCP uses a handler that logs progress updates at the debug level. This default handler properly handles cases where `total` or `message` might be None. + +You can override the progress handler for specific tool calls: + +```python +# Client uses the default debug logger for progress +client = Client(my_mcp_server) + +async with client: + # Use default progress handler (debug logging) + result1 = await client.call_tool("long_task", {"param": "value"}) + + # Override with custom progress handler just for this call + result2 = await client.call_tool( + "another_task", + {"param": "value"}, + progress_handler=my_progress_handler + ) +``` + +A typical progress update includes: +- Current progress value (e.g., 2 of 5 steps completed) +- Total expected value (may be None) +- Status message (may be None) + #### LLM Sampling MCP Servers can request LLM completions from clients. The client can provide a `sampling_handler` to handle these requests. The sampling handler receives a list of messages and other parameters from the server, and should return a string completion. diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 320addb8c..803b5a205 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -8,7 +8,8 @@ from exceptiongroup import catch from mcp import ClientSession from pydantic import AnyUrl -from fastmcp.client.logging import LogHandler, MessageHandler +from fastmcp.client.logging import LogHandler, MessageHandler, default_log_handler +from fastmcp.client.progress import ProgressHandler, default_progress_handler from fastmcp.client.roots import ( RootsHandler, RootsList, @@ -28,6 +29,7 @@ __all__ = [ "LogHandler", "MessageHandler", "SamplingHandler", + "ProgressHandler", ] @@ -50,6 +52,7 @@ class Client: sampling_handler: Optional handler for sampling requests log_handler: Optional handler for log messages message_handler: Optional handler for protocol messages + progress_handler: Optional handler for progress notifications timeout: Optional timeout for requests (seconds or timedelta) Examples: @@ -74,6 +77,7 @@ class Client: sampling_handler: SamplingHandler | None = None, log_handler: LogHandler | None = None, message_handler: MessageHandler | None = None, + progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, ): self.transport = infer_transport(transport) @@ -81,6 +85,14 @@ class Client: self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 + if log_handler is None: + log_handler = default_log_handler + + if progress_handler is None: + progress_handler = default_progress_handler + + self._progress_handler = progress_handler + if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) @@ -96,7 +108,9 @@ class Client: self.set_roots(roots) if sampling_handler is not None: - self.set_sampling_callback(sampling_handler) + self._session_kwargs["sampling_callback"] = create_sampling_callback( + sampling_handler + ) @property def session(self) -> ClientSession: @@ -433,6 +447,7 @@ class Client: self, name: str, arguments: dict[str, Any], + progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, ) -> mcp.types.CallToolResult: """Send a tools/call request and return the complete MCP protocol result. @@ -444,6 +459,8 @@ class Client: name (str): The name of the tool to call. arguments (dict[str, Any]): Arguments to pass to the tool. timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. + progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. + Returns: mcp.types.CallToolResult: The complete response object from the protocol, containing the tool result and any additional metadata. @@ -455,7 +472,10 @@ class Client: if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) result = await self.session.call_tool( - name=name, arguments=arguments, read_timeout_seconds=timeout + name=name, + arguments=arguments, + read_timeout_seconds=timeout, + progress_callback=progress_handler or self._progress_handler, ) return result @@ -464,6 +484,7 @@ class Client: name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, + progress_handler: ProgressHandler | None = None, ) -> list[ mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource ]: @@ -474,6 +495,8 @@ class Client: Args: name (str): The name of the tool to call. arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None. + timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. + progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. Returns: list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]: @@ -487,6 +510,7 @@ class Client: name=name, arguments=arguments or {}, timeout=timeout, + progress_handler=progress_handler, ) if result.isError: msg = cast(mcp.types.TextContent, result.content[0]).text diff --git a/src/fastmcp/client/logging.py b/src/fastmcp/client/logging.py index 2047633e3..826eb0d28 100644 --- a/src/fastmcp/client/logging.py +++ b/src/fastmcp/client/logging.py @@ -6,8 +6,16 @@ from mcp.client.session import ( ) from mcp.types import LoggingMessageNotificationParams +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + LogMessage: TypeAlias = LoggingMessageNotificationParams LogHandler: TypeAlias = LoggingFnT MessageHandler: TypeAlias = MessageHandlerFnT __all__ = ["LogMessage", "LogHandler", "MessageHandler"] + + +async def default_log_handler(params: LogMessage) -> None: + logger.debug(f"Log received: {params}") diff --git a/src/fastmcp/client/progress.py b/src/fastmcp/client/progress.py new file mode 100644 index 000000000..2470f18ce --- /dev/null +++ b/src/fastmcp/client/progress.py @@ -0,0 +1,38 @@ +from typing import TypeAlias + +from mcp.shared.session import ProgressFnT + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + +ProgressHandler: TypeAlias = ProgressFnT + + +async def default_progress_handler( + progress: float, total: float | None, message: str | None +) -> None: + """Default handler for progress notifications. + + Logs progress updates at debug level, properly handling missing total or message values. + + Args: + progress: Current progress value + total: Optional total expected value + message: Optional status message + """ + if total is not None: + # We have both progress and total + percent = (progress / total) * 100 + progress_str = f"{progress}/{total} ({percent:.1f}%)" + else: + # We only have progress + progress_str = f"{progress}" + + # Include message if available + if message: + log_msg = f"Progress: {progress_str} - {message}" + else: + log_msg = f"Progress: {progress_str}" + + logger.debug(log_msg) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7faeab613..e1597bec8 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -22,6 +22,7 @@ from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client from mcp.client.websocket import websocket_client from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.session import ProgressFnT from pydantic import AnyUrl from typing_extensions import Unpack @@ -35,6 +36,7 @@ class SessionKwargs(TypedDict, total=False): list_roots_callback: ListRootsFnT | None logging_callback: LoggingFnT | None message_handler: MessageHandlerFnT | None + progress_callback: ProgressFnT | None read_timeout_seconds: datetime.timedelta | None diff --git a/tests/client/test_progress.py b/tests/client/test_progress.py new file mode 100644 index 000000000..5f61c3487 --- /dev/null +++ b/tests/client/test_progress.py @@ -0,0 +1,68 @@ +import pytest + +from fastmcp import Client, Context, FastMCP + +PROGRESS_MESSAGES = [] + + +@pytest.fixture(autouse=True) +def clear_progress_messages(): + PROGRESS_MESSAGES.clear() + yield + PROGRESS_MESSAGES.clear() + + +@pytest.fixture +def fastmcp_server(): + mcp = FastMCP() + + @mcp.tool() + async def progress_tool(context: Context) -> int: + for i in range(3): + await context.report_progress( + progress=i + 1, + total=3, + message=f"{(i + 1) / 3 * 100:.2f}% complete", + ) + return 100 + + return mcp + + +EXPECTED_PROGRESS_MESSAGES = [ + dict(progress=1, total=3, message="33.33% complete"), + dict(progress=2, total=3, message="66.67% complete"), + dict(progress=3, total=3, message="100.00% complete"), +] + + +async def progress_handler( + progress: float, total: float | None, message: str | None +) -> None: + PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message)) + + +async def test_progress_handler(fastmcp_server: FastMCP): + async with Client(fastmcp_server, progress_handler=progress_handler) as client: + await client.call_tool("progress_tool", {}) + + assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES + + +async def test_progress_handler_can_be_supplied_on_tool_call(fastmcp_server: FastMCP): + async with Client(fastmcp_server) as client: + await client.call_tool("progress_tool", {}, progress_handler=progress_handler) + + assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES + + +async def test_progress_handler_supplied_on_tool_call_overrides_default( + fastmcp_server: FastMCP, +): + async def bad_progress_handler(*args, **kwargs): + 1 / 0 + + async with Client(fastmcp_server, progress_handler=bad_progress_handler) as client: + await client.call_tool("progress_tool", {}, progress_handler=progress_handler) + + assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES From dde2dc471236ee1c569cfdd60cee7382aaab43be Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:30:03 -0400 Subject: [PATCH 4/7] Flaky test --- tests/client/test_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 21b9d81f4..6352e9ada 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,4 +1,5 @@ import asyncio +import sys from typing import cast import pytest @@ -540,6 +541,10 @@ class TestTimeout: with pytest.raises(McpError): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + @pytest.mark.skipif( + sys.platform == "win32", + reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", + ) async def test_timeout_tool_call_overrides_client_timeout_even_if_lower( self, fastmcp_server: FastMCP ): From a0e69a4cb25ae8a6a2c7db985398446fb7c67d57 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:33:17 -0400 Subject: [PATCH 5/7] Skip timeout tests on windows --- tests/client/test_client.py | 5 +++++ tests/client/test_sse.py | 12 ++++-------- tests/client/test_streamable_http.py | 4 ++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d88a7c53d..52d4448f8 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,4 +1,5 @@ import asyncio +import sys from typing import cast import pytest @@ -509,6 +510,10 @@ class TestErrorHandling: assert "This is a resource error (xyz)" in str(excinfo.value) +@pytest.mark.skipif( + sys.platform == "win32", + reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", +) class TestTimeout: async def test_timeout(self, fastmcp_server: FastMCP): async with Client( diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 7ecfc7ee6..787657882 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -136,11 +136,11 @@ async def test_nested_sse_server_resolves_correctly(): assert result is True +@pytest.mark.skipif( + sys.platform == "win32", + reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", +) class TestTimeout: - @pytest.mark.skipif( - sys.platform == "win32", - reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", - ) async def test_timeout(self, sse_server: str): with pytest.raises( McpError, @@ -167,10 +167,6 @@ class TestTimeout: with pytest.raises(McpError, match="Timed out"): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) - @pytest.mark.skipif( - sys.platform == "win32", - reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", - ) async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower( self, sse_server: str ): diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index be860950e..53e765242 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -149,6 +149,10 @@ async def test_nested_streamable_http_server_resolves_correctly(): assert result is True +@pytest.mark.skipif( + sys.platform == "win32", + reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", +) class TestTimeout: async def test_timeout(self, streamable_http_server: str): # note this transport behaves differently than others and raises From 5a89e0cc2b6c19a734db426fde79a17f80d821cc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:36:00 -0400 Subject: [PATCH 6/7] Fix typing --- src/fastmcp/client/transports.py | 2 -- tests/client/test_progress.py | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index e1597bec8..7faeab613 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -22,7 +22,6 @@ from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client from mcp.client.websocket import websocket_client from mcp.shared.memory import create_connected_server_and_client_session -from mcp.shared.session import ProgressFnT from pydantic import AnyUrl from typing_extensions import Unpack @@ -36,7 +35,6 @@ class SessionKwargs(TypedDict, total=False): list_roots_callback: ListRootsFnT | None logging_callback: LoggingFnT | None message_handler: MessageHandlerFnT | None - progress_callback: ProgressFnT | None read_timeout_seconds: datetime.timedelta | None diff --git a/tests/client/test_progress.py b/tests/client/test_progress.py index 5f61c3487..f67f5c54c 100644 --- a/tests/client/test_progress.py +++ b/tests/client/test_progress.py @@ -59,8 +59,10 @@ async def test_progress_handler_can_be_supplied_on_tool_call(fastmcp_server: Fas async def test_progress_handler_supplied_on_tool_call_overrides_default( fastmcp_server: FastMCP, ): - async def bad_progress_handler(*args, **kwargs): - 1 / 0 + async def bad_progress_handler( + progress: float, total: float | None, message: str | None + ) -> None: + raise Exception("This should not be called") async with Client(fastmcp_server, progress_handler=bad_progress_handler) as client: await client.call_tool("progress_tool", {}, progress_handler=progress_handler) From eadec830836b6e08ee3611745039c57cff4ed288 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 10:45:27 -0400 Subject: [PATCH 7/7] Add request id back to tests --- tests/server/test_server_interactions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 2d8d2504b..2e739c2b8 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -655,8 +655,7 @@ class TestToolContextInjection: assert len(result) == 1 content = result[0] assert isinstance(content, TextContent) - assert "Async request" in content.text - assert "42" in content.text + assert content.text == "Async request 2: 42" async def test_optional_context(self): """Test that context is optional.""" @@ -797,6 +796,7 @@ class TestResourceContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) + assert result[0].text == "2" class TestResourceTemplates: @@ -1094,7 +1094,7 @@ class TestResourceTemplateContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) - assert result[0].text.startswith("Resource template: test") + assert result[0].text.startswith("Resource template: test 2") class TestPrompts: