mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 21:14:17 +02:00
Merge branch 'main' into prep-release
This commit is contained in:
commit
7a65e0ff4e
11 changed files with 415 additions and 75 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
38
src/fastmcp/client/progress.py
Normal file
38
src/fastmcp/client/progress.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
70
tests/client/test_progress.py
Normal file
70
tests/client/test_progress.py
Normal 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
|
||||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue