Merge branch 'main' into prep-release

This commit is contained in:
Jeremiah Lowin 2025-05-20 10:48:45 -04:00 committed by GitHub
commit 7a65e0ff4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 415 additions and 75 deletions

View file

@ -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.
</Warning>
#### Progress Tracking
<VersionBadge version="2.3.5" />
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.

View file

@ -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:

View file

@ -1,5 +1,5 @@
import datetime
from contextlib import AsyncExitStack
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import Any, cast
@ -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,12 +77,22 @@ 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)
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 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,17 +109,28 @@ 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:
"""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 +145,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 +186,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 ---
@ -433,6 +464,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 +476,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 +489,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 +501,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 +512,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 +527,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

View file

@ -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}")

View file

@ -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)

View file

@ -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):
@ -44,6 +46,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 +55,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 +68,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 +97,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 +145,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 +190,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 +237,6 @@ class StdioTransport(ClientTransport):
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
await session.initialize()
yield session
def __repr__(self) -> str:
@ -486,36 +487,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 +524,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 +533,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 +543,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

View file

@ -1,4 +1,5 @@
import asyncio
import sys
from typing import cast
import pytest
@ -6,7 +7,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
@ -250,18 +256,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):
@ -509,6 +548,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(
@ -535,6 +578,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
):
@ -543,3 +590,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)

View file

@ -0,0 +1,70 @@
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(
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)
assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES

View file

@ -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
):

View file

@ -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

View file

@ -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."""
@ -656,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."""
@ -798,7 +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 == "1"
assert result[0].text == "2"
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 2")
class TestPrompts: