Jeremiah Lowin commited on
Commit
c27f039
·
1 Parent(s): a7c6f7e

Add progress handler to client

Browse files
docs/clients/client.mdx CHANGED
@@ -18,6 +18,17 @@ The FastMCP Client architecture separates the protocol logic (`Client`) from the
18
  - **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks.
19
  - **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory).
20
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  ### Transports
23
 
@@ -114,7 +125,7 @@ The standard client methods return user-friendly representations that may change
114
  tools = await client.list_tools()
115
  # tools -> list[mcp.types.Tool]
116
  ```
117
- * **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server.
118
  ```python
119
  result = await client.call_tool("add", {"a": 5, "b": 3})
120
  # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
@@ -122,10 +133,18 @@ The standard client methods return user-friendly representations that may change
122
 
123
  # With timeout (aborts if execution takes longer than 2 seconds)
124
  result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0)
 
 
 
 
 
 
 
125
  ```
126
  * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
127
  * Returns a list of content objects (usually `TextContent` or `ImageContent`).
128
  * The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout.
 
129
 
130
  #### Resource Operations
131
 
@@ -234,6 +253,64 @@ Timeout behavior varies between transport types:
234
  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.
235
  </Warning>
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  #### LLM Sampling
238
 
239
  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.
 
18
  - **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks.
19
  - **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory).
20
 
21
+ ```python
22
+ from fastmcp import Client, FastMCP
23
+ from fastmcp.client import (
24
+ RootsHandler,
25
+ RootsList,
26
+ LogHandler,
27
+ MessageHandler,
28
+ SamplingHandler,
29
+ ProgressHandler # For handling progress notifications
30
+ )
31
+ ```
32
 
33
  ### Transports
34
 
 
125
  tools = await client.list_tools()
126
  # tools -> list[mcp.types.Tool]
127
  ```
128
+ * **`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.
129
  ```python
130
  result = await client.call_tool("add", {"a": 5, "b": 3})
131
  # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
 
133
 
134
  # With timeout (aborts if execution takes longer than 2 seconds)
135
  result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0)
136
+
137
+ # With progress handler (to track execution progress)
138
+ result = await client.call_tool(
139
+ "long_running_task",
140
+ {"param": "value"},
141
+ progress_handler=my_progress_handler
142
+ )
143
  ```
144
  * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
145
  * Returns a list of content objects (usually `TextContent` or `ImageContent`).
146
  * The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout.
147
+ * The optional `progress_handler` parameter receives progress updates during execution, overriding any client-level progress handler.
148
 
149
  #### Resource Operations
150
 
 
253
  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.
254
  </Warning>
255
 
256
+ #### Progress Tracking
257
+
258
+ <VersionBadge version="2.3.5" />
259
+
260
+ MCP servers can report progress during long-running operations. The client can set a progress handler to receive and process these updates.
261
+
262
+ ```python
263
+ from fastmcp import Client
264
+ from fastmcp.client.progress import ProgressHandler
265
+
266
+ # A simple progress handler that prints progress updates
267
+ async def my_progress_handler(
268
+ progress: float,
269
+ total: float | None,
270
+ message: str | None
271
+ ) -> None:
272
+ """Handle progress updates from the server."""
273
+ if total is not None:
274
+ percent = (progress / total) * 100
275
+ print(f"Progress: {percent:.1f}% ({progress}/{total})")
276
+ else:
277
+ print(f"Progress: {progress}")
278
+
279
+ if message:
280
+ print(f"Message: {message}")
281
+
282
+ # Set the progress handler at client level
283
+ client = Client(
284
+ my_mcp_server,
285
+ progress_handler=my_progress_handler
286
+ )
287
+ ```
288
+
289
+ 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.
290
+
291
+ You can override the progress handler for specific tool calls:
292
+
293
+ ```python
294
+ # Client uses the default debug logger for progress
295
+ client = Client(my_mcp_server)
296
+
297
+ async with client:
298
+ # Use default progress handler (debug logging)
299
+ result1 = await client.call_tool("long_task", {"param": "value"})
300
+
301
+ # Override with custom progress handler just for this call
302
+ result2 = await client.call_tool(
303
+ "another_task",
304
+ {"param": "value"},
305
+ progress_handler=my_progress_handler
306
+ )
307
+ ```
308
+
309
+ A typical progress update includes:
310
+ - Current progress value (e.g., 2 of 5 steps completed)
311
+ - Total expected value (may be None)
312
+ - Status message (may be None)
313
+
314
  #### LLM Sampling
315
 
316
  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.
src/fastmcp/client/client.py CHANGED
@@ -8,7 +8,8 @@ from exceptiongroup import catch
8
  from mcp import ClientSession
9
  from pydantic import AnyUrl
10
 
11
- from fastmcp.client.logging import LogHandler, MessageHandler
 
12
  from fastmcp.client.roots import (
13
  RootsHandler,
14
  RootsList,
@@ -28,6 +29,7 @@ __all__ = [
28
  "LogHandler",
29
  "MessageHandler",
30
  "SamplingHandler",
 
31
  ]
32
 
33
 
@@ -50,6 +52,7 @@ class Client:
50
  sampling_handler: Optional handler for sampling requests
51
  log_handler: Optional handler for log messages
52
  message_handler: Optional handler for protocol messages
 
53
  timeout: Optional timeout for requests (seconds or timedelta)
54
 
55
  Examples:
@@ -74,6 +77,7 @@ class Client:
74
  sampling_handler: SamplingHandler | None = None,
75
  log_handler: LogHandler | None = None,
76
  message_handler: MessageHandler | None = None,
 
77
  timeout: datetime.timedelta | float | int | None = None,
78
  ):
79
  self.transport = infer_transport(transport)
@@ -81,6 +85,14 @@ class Client:
81
  self._exit_stack: AsyncExitStack | None = None
82
  self._nesting_counter: int = 0
83
 
 
 
 
 
 
 
 
 
84
  if isinstance(timeout, int | float):
85
  timeout = datetime.timedelta(seconds=timeout)
86
 
@@ -96,7 +108,9 @@ class Client:
96
  self.set_roots(roots)
97
 
98
  if sampling_handler is not None:
99
- self.set_sampling_callback(sampling_handler)
 
 
100
 
101
  @property
102
  def session(self) -> ClientSession:
@@ -433,6 +447,7 @@ class Client:
433
  self,
434
  name: str,
435
  arguments: dict[str, Any],
 
436
  timeout: datetime.timedelta | float | int | None = None,
437
  ) -> mcp.types.CallToolResult:
438
  """Send a tools/call request and return the complete MCP protocol result.
@@ -444,6 +459,8 @@ class Client:
444
  name (str): The name of the tool to call.
445
  arguments (dict[str, Any]): Arguments to pass to the tool.
446
  timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
 
 
447
  Returns:
448
  mcp.types.CallToolResult: The complete response object from the protocol,
449
  containing the tool result and any additional metadata.
@@ -455,7 +472,10 @@ class Client:
455
  if isinstance(timeout, int | float):
456
  timeout = datetime.timedelta(seconds=timeout)
457
  result = await self.session.call_tool(
458
- name=name, arguments=arguments, read_timeout_seconds=timeout
 
 
 
459
  )
460
  return result
461
 
@@ -464,6 +484,7 @@ class Client:
464
  name: str,
465
  arguments: dict[str, Any] | None = None,
466
  timeout: datetime.timedelta | float | int | None = None,
 
467
  ) -> list[
468
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
469
  ]:
@@ -474,6 +495,8 @@ class Client:
474
  Args:
475
  name (str): The name of the tool to call.
476
  arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
 
 
477
 
478
  Returns:
479
  list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]:
@@ -487,6 +510,7 @@ class Client:
487
  name=name,
488
  arguments=arguments or {},
489
  timeout=timeout,
 
490
  )
491
  if result.isError:
492
  msg = cast(mcp.types.TextContent, result.content[0]).text
 
8
  from mcp import ClientSession
9
  from pydantic import AnyUrl
10
 
11
+ from fastmcp.client.logging import LogHandler, MessageHandler, default_log_handler
12
+ from fastmcp.client.progress import ProgressHandler, default_progress_handler
13
  from fastmcp.client.roots import (
14
  RootsHandler,
15
  RootsList,
 
29
  "LogHandler",
30
  "MessageHandler",
31
  "SamplingHandler",
32
+ "ProgressHandler",
33
  ]
34
 
35
 
 
52
  sampling_handler: Optional handler for sampling requests
53
  log_handler: Optional handler for log messages
54
  message_handler: Optional handler for protocol messages
55
+ progress_handler: Optional handler for progress notifications
56
  timeout: Optional timeout for requests (seconds or timedelta)
57
 
58
  Examples:
 
77
  sampling_handler: SamplingHandler | None = None,
78
  log_handler: LogHandler | None = None,
79
  message_handler: MessageHandler | None = None,
80
+ progress_handler: ProgressHandler | None = None,
81
  timeout: datetime.timedelta | float | int | None = None,
82
  ):
83
  self.transport = infer_transport(transport)
 
85
  self._exit_stack: AsyncExitStack | None = None
86
  self._nesting_counter: int = 0
87
 
88
+ if log_handler is None:
89
+ log_handler = default_log_handler
90
+
91
+ if progress_handler is None:
92
+ progress_handler = default_progress_handler
93
+
94
+ self._progress_handler = progress_handler
95
+
96
  if isinstance(timeout, int | float):
97
  timeout = datetime.timedelta(seconds=timeout)
98
 
 
108
  self.set_roots(roots)
109
 
110
  if sampling_handler is not None:
111
+ self._session_kwargs["sampling_callback"] = create_sampling_callback(
112
+ sampling_handler
113
+ )
114
 
115
  @property
116
  def session(self) -> ClientSession:
 
447
  self,
448
  name: str,
449
  arguments: dict[str, Any],
450
+ progress_handler: ProgressHandler | None = None,
451
  timeout: datetime.timedelta | float | int | None = None,
452
  ) -> mcp.types.CallToolResult:
453
  """Send a tools/call request and return the complete MCP protocol result.
 
459
  name (str): The name of the tool to call.
460
  arguments (dict[str, Any]): Arguments to pass to the tool.
461
  timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
462
+ progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
463
+
464
  Returns:
465
  mcp.types.CallToolResult: The complete response object from the protocol,
466
  containing the tool result and any additional metadata.
 
472
  if isinstance(timeout, int | float):
473
  timeout = datetime.timedelta(seconds=timeout)
474
  result = await self.session.call_tool(
475
+ name=name,
476
+ arguments=arguments,
477
+ read_timeout_seconds=timeout,
478
+ progress_callback=progress_handler or self._progress_handler,
479
  )
480
  return result
481
 
 
484
  name: str,
485
  arguments: dict[str, Any] | None = None,
486
  timeout: datetime.timedelta | float | int | None = None,
487
+ progress_handler: ProgressHandler | None = None,
488
  ) -> list[
489
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
490
  ]:
 
495
  Args:
496
  name (str): The name of the tool to call.
497
  arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
498
+ timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
499
+ progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
500
 
501
  Returns:
502
  list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]:
 
510
  name=name,
511
  arguments=arguments or {},
512
  timeout=timeout,
513
+ progress_handler=progress_handler,
514
  )
515
  if result.isError:
516
  msg = cast(mcp.types.TextContent, result.content[0]).text
src/fastmcp/client/logging.py CHANGED
@@ -6,8 +6,16 @@ from mcp.client.session import (
6
  )
7
  from mcp.types import LoggingMessageNotificationParams
8
 
 
 
 
 
9
  LogMessage: TypeAlias = LoggingMessageNotificationParams
10
  LogHandler: TypeAlias = LoggingFnT
11
  MessageHandler: TypeAlias = MessageHandlerFnT
12
 
13
  __all__ = ["LogMessage", "LogHandler", "MessageHandler"]
 
 
 
 
 
6
  )
7
  from mcp.types import LoggingMessageNotificationParams
8
 
9
+ from fastmcp.utilities.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
  LogMessage: TypeAlias = LoggingMessageNotificationParams
14
  LogHandler: TypeAlias = LoggingFnT
15
  MessageHandler: TypeAlias = MessageHandlerFnT
16
 
17
  __all__ = ["LogMessage", "LogHandler", "MessageHandler"]
18
+
19
+
20
+ async def default_log_handler(params: LogMessage) -> None:
21
+ logger.debug(f"Log received: {params}")
src/fastmcp/client/progress.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypeAlias
2
+
3
+ from mcp.shared.session import ProgressFnT
4
+
5
+ from fastmcp.utilities.logging import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+ ProgressHandler: TypeAlias = ProgressFnT
10
+
11
+
12
+ async def default_progress_handler(
13
+ progress: float, total: float | None, message: str | None
14
+ ) -> None:
15
+ """Default handler for progress notifications.
16
+
17
+ Logs progress updates at debug level, properly handling missing total or message values.
18
+
19
+ Args:
20
+ progress: Current progress value
21
+ total: Optional total expected value
22
+ message: Optional status message
23
+ """
24
+ if total is not None:
25
+ # We have both progress and total
26
+ percent = (progress / total) * 100
27
+ progress_str = f"{progress}/{total} ({percent:.1f}%)"
28
+ else:
29
+ # We only have progress
30
+ progress_str = f"{progress}"
31
+
32
+ # Include message if available
33
+ if message:
34
+ log_msg = f"Progress: {progress_str} - {message}"
35
+ else:
36
+ log_msg = f"Progress: {progress_str}"
37
+
38
+ logger.debug(log_msg)
src/fastmcp/client/transports.py CHANGED
@@ -22,6 +22,7 @@ from mcp.client.stdio import stdio_client
22
  from mcp.client.streamable_http import streamablehttp_client
23
  from mcp.client.websocket import websocket_client
24
  from mcp.shared.memory import create_connected_server_and_client_session
 
25
  from pydantic import AnyUrl
26
  from typing_extensions import Unpack
27
 
@@ -35,6 +36,7 @@ class SessionKwargs(TypedDict, total=False):
35
  list_roots_callback: ListRootsFnT | None
36
  logging_callback: LoggingFnT | None
37
  message_handler: MessageHandlerFnT | None
 
38
  read_timeout_seconds: datetime.timedelta | None
39
 
40
 
 
22
  from mcp.client.streamable_http import streamablehttp_client
23
  from mcp.client.websocket import websocket_client
24
  from mcp.shared.memory import create_connected_server_and_client_session
25
+ from mcp.shared.session import ProgressFnT
26
  from pydantic import AnyUrl
27
  from typing_extensions import Unpack
28
 
 
36
  list_roots_callback: ListRootsFnT | None
37
  logging_callback: LoggingFnT | None
38
  message_handler: MessageHandlerFnT | None
39
+ progress_callback: ProgressFnT | None
40
  read_timeout_seconds: datetime.timedelta | None
41
 
42
 
tests/client/test_progress.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from fastmcp import Client, Context, FastMCP
4
+
5
+ PROGRESS_MESSAGES = []
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def clear_progress_messages():
10
+ PROGRESS_MESSAGES.clear()
11
+ yield
12
+ PROGRESS_MESSAGES.clear()
13
+
14
+
15
+ @pytest.fixture
16
+ def fastmcp_server():
17
+ mcp = FastMCP()
18
+
19
+ @mcp.tool()
20
+ async def progress_tool(context: Context) -> int:
21
+ for i in range(3):
22
+ await context.report_progress(
23
+ progress=i + 1,
24
+ total=3,
25
+ message=f"{(i + 1) / 3 * 100:.2f}% complete",
26
+ )
27
+ return 100
28
+
29
+ return mcp
30
+
31
+
32
+ EXPECTED_PROGRESS_MESSAGES = [
33
+ dict(progress=1, total=3, message="33.33% complete"),
34
+ dict(progress=2, total=3, message="66.67% complete"),
35
+ dict(progress=3, total=3, message="100.00% complete"),
36
+ ]
37
+
38
+
39
+ async def progress_handler(
40
+ progress: float, total: float | None, message: str | None
41
+ ) -> None:
42
+ PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message))
43
+
44
+
45
+ async def test_progress_handler(fastmcp_server: FastMCP):
46
+ async with Client(fastmcp_server, progress_handler=progress_handler) as client:
47
+ await client.call_tool("progress_tool", {})
48
+
49
+ assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
50
+
51
+
52
+ async def test_progress_handler_can_be_supplied_on_tool_call(fastmcp_server: FastMCP):
53
+ async with Client(fastmcp_server) as client:
54
+ await client.call_tool("progress_tool", {}, progress_handler=progress_handler)
55
+
56
+ assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
57
+
58
+
59
+ async def test_progress_handler_supplied_on_tool_call_overrides_default(
60
+ fastmcp_server: FastMCP,
61
+ ):
62
+ async def bad_progress_handler(*args, **kwargs):
63
+ 1 / 0
64
+
65
+ async with Client(fastmcp_server, progress_handler=bad_progress_handler) as client:
66
+ await client.call_tool("progress_tool", {}, progress_handler=progress_handler)
67
+
68
+ assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES