mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Merge branch 'main' into initialize-result
This commit is contained in:
commit
4eb5c3245b
5 changed files with 221 additions and 4 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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -82,6 +86,14 @@ class Client:
|
|||
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)
|
||||
|
||||
|
|
@ -97,7 +109,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:
|
||||
|
|
@ -450,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.
|
||||
|
|
@ -461,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.
|
||||
|
|
@ -472,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
|
||||
|
||||
|
|
@ -481,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
|
||||
]:
|
||||
|
|
@ -491,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]:
|
||||
|
|
@ -504,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)
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue