Jeremiah Lowin commited on
Commit
a2a14a4
·
unverified ·
2 Parent(s): 32ff292500d69e

Merge branch 'main' into initialize-result

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)
@@ -82,6 +86,14 @@ class Client:
82
  self._nesting_counter: int = 0
83
  self._initialize_result: mcp.types.InitializeResult | None = None
84
 
 
 
 
 
 
 
 
 
85
  if isinstance(timeout, int | float):
86
  timeout = datetime.timedelta(seconds=timeout)
87
 
@@ -97,7 +109,9 @@ class Client:
97
  self.set_roots(roots)
98
 
99
  if sampling_handler is not None:
100
- self.set_sampling_callback(sampling_handler)
 
 
101
 
102
  @property
103
  def session(self) -> ClientSession:
@@ -450,6 +464,7 @@ class Client:
450
  self,
451
  name: str,
452
  arguments: dict[str, Any],
 
453
  timeout: datetime.timedelta | float | int | None = None,
454
  ) -> mcp.types.CallToolResult:
455
  """Send a tools/call request and return the complete MCP protocol result.
@@ -461,6 +476,8 @@ class Client:
461
  name (str): The name of the tool to call.
462
  arguments (dict[str, Any]): Arguments to pass to the tool.
463
  timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
 
 
464
  Returns:
465
  mcp.types.CallToolResult: The complete response object from the protocol,
466
  containing the tool result and any additional metadata.
@@ -472,7 +489,10 @@ class Client:
472
  if isinstance(timeout, int | float):
473
  timeout = datetime.timedelta(seconds=timeout)
474
  result = await self.session.call_tool(
475
- name=name, arguments=arguments, read_timeout_seconds=timeout
 
 
 
476
  )
477
  return result
478
 
@@ -481,6 +501,7 @@ class Client:
481
  name: str,
482
  arguments: dict[str, Any] | None = None,
483
  timeout: datetime.timedelta | float | int | None = None,
 
484
  ) -> list[
485
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
486
  ]:
@@ -491,6 +512,8 @@ class Client:
491
  Args:
492
  name (str): The name of the tool to call.
493
  arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
 
 
494
 
495
  Returns:
496
  list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]:
@@ -504,6 +527,7 @@ class Client:
504
  name=name,
505
  arguments=arguments or {},
506
  timeout=timeout,
 
507
  )
508
  if result.isError:
509
  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)
 
86
  self._nesting_counter: int = 0
87
  self._initialize_result: mcp.types.InitializeResult | None = None
88
 
89
+ if log_handler is None:
90
+ log_handler = default_log_handler
91
+
92
+ if progress_handler is None:
93
+ progress_handler = default_progress_handler
94
+
95
+ self._progress_handler = progress_handler
96
+
97
  if isinstance(timeout, int | float):
98
  timeout = datetime.timedelta(seconds=timeout)
99
 
 
109
  self.set_roots(roots)
110
 
111
  if sampling_handler is not None:
112
+ self._session_kwargs["sampling_callback"] = create_sampling_callback(
113
+ sampling_handler
114
+ )
115
 
116
  @property
117
  def session(self) -> ClientSession:
 
464
  self,
465
  name: str,
466
  arguments: dict[str, Any],
467
+ progress_handler: ProgressHandler | None = None,
468
  timeout: datetime.timedelta | float | int | None = None,
469
  ) -> mcp.types.CallToolResult:
470
  """Send a tools/call request and return the complete MCP protocol result.
 
476
  name (str): The name of the tool to call.
477
  arguments (dict[str, Any]): Arguments to pass to the tool.
478
  timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
479
+ progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
480
+
481
  Returns:
482
  mcp.types.CallToolResult: The complete response object from the protocol,
483
  containing the tool result and any additional metadata.
 
489
  if isinstance(timeout, int | float):
490
  timeout = datetime.timedelta(seconds=timeout)
491
  result = await self.session.call_tool(
492
+ name=name,
493
+ arguments=arguments,
494
+ read_timeout_seconds=timeout,
495
+ progress_callback=progress_handler or self._progress_handler,
496
  )
497
  return result
498
 
 
501
  name: str,
502
  arguments: dict[str, Any] | None = None,
503
  timeout: datetime.timedelta | float | int | None = None,
504
+ progress_handler: ProgressHandler | None = None,
505
  ) -> list[
506
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
507
  ]:
 
512
  Args:
513
  name (str): The name of the tool to call.
514
  arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
515
+ timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
516
+ progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
517
 
518
  Returns:
519
  list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]:
 
527
  name=name,
528
  arguments=arguments or {},
529
  timeout=timeout,
530
+ progress_handler=progress_handler,
531
  )
532
  if result.isError:
533
  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)
tests/client/test_progress.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(
63
+ progress: float, total: float | None, message: str | None
64
+ ) -> None:
65
+ raise Exception("This should not be called")
66
+
67
+ async with Client(fastmcp_server, progress_handler=bad_progress_handler) as client:
68
+ await client.call_tool("progress_tool", {}, progress_handler=progress_handler)
69
+
70
+ assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES