Jeremiah Lowin commited on
Commit
4504a7b
·
unverified ·
2 Parent(s): feafdb60522f9f

Merge branch 'main' into prep-release

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.
docs/clients/transports.mdx CHANGED
@@ -40,7 +40,7 @@ Streamable HTTP is the recommended transport for web-based deployments, providin
40
  #### Overview
41
 
42
  - **Class:** `fastmcp.client.transports.StreamableHttpTransport`
43
- - **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0)
44
  - **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
45
 
46
  #### Basic Usage
@@ -62,6 +62,15 @@ async def main():
62
  asyncio.run(main())
63
  ```
64
 
 
 
 
 
 
 
 
 
 
65
  #### Authentication with Headers
66
 
67
  For servers requiring authentication:
@@ -88,23 +97,19 @@ Server-Sent Events (SSE) is a transport that allows servers to push data to clie
88
  #### Overview
89
 
90
  - **Class:** `fastmcp.client.transports.SSETransport`
91
- - **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified)
92
  - **Server Compatibility:** Works with FastMCP servers running in `sse` mode
93
 
94
  #### Basic Usage
95
 
96
- Since v2.3.0, you must explicitly create an `SSETransport` for SSE connections:
97
 
98
  ```python
99
  from fastmcp import Client
100
- from fastmcp.client.transports import SSETransport
101
  import asyncio
102
 
103
- # Create an SSE transport
104
- transport = SSETransport(url="https://example.com/sse")
105
-
106
- # Pass the transport to the client
107
- client = Client(transport)
108
 
109
  async def main():
110
  async with client:
@@ -114,6 +119,15 @@ async def main():
114
  asyncio.run(main())
115
  ```
116
 
 
 
 
 
 
 
 
 
 
117
  #### Authentication with Headers
118
 
119
  SSE transport also supports custom headers for authentication:
 
40
  #### Overview
41
 
42
  - **Class:** `fastmcp.client.transports.StreamableHttpTransport`
43
+ - **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) that do not contain `/sse/` in the path
44
  - **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
45
 
46
  #### Basic Usage
 
62
  asyncio.run(main())
63
  ```
64
 
65
+ You can also explicitly instantiate the transport:
66
+
67
+ ```python
68
+ from fastmcp.client.transports import StreamableHttpTransport
69
+
70
+ transport = StreamableHttpTransport(url="https://example.com/mcp")
71
+ client = Client(transport)
72
+ ```
73
+
74
  #### Authentication with Headers
75
 
76
  For servers requiring authentication:
 
97
  #### Overview
98
 
99
  - **Class:** `fastmcp.client.transports.SSETransport`
100
+ - **Inferred From:** HTTP URLs containing `/sse/` in the path
101
  - **Server Compatibility:** Works with FastMCP servers running in `sse` mode
102
 
103
  #### Basic Usage
104
 
105
+ The simplest way to use SSE is to let the transport be inferred from a URL with `/sse/` in the path:
106
 
107
  ```python
108
  from fastmcp import Client
 
109
  import asyncio
110
 
111
+ # The Client automatically uses SSETransport for URLs containing /sse/ in the path
112
+ client = Client("https://example.com/sse")
 
 
 
113
 
114
  async def main():
115
  async with client:
 
119
  asyncio.run(main())
120
  ```
121
 
122
+ You can also explicitly instantiate the transport for URLs that do not contain `/sse/` in the path or for more control:
123
+
124
+ ```python
125
+ from fastmcp.client.transports import SSETransport
126
+
127
+ transport = SSETransport(url="https://example.com/sse")
128
+ client = Client(transport)
129
+ ```
130
+
131
  #### Authentication with Headers
132
 
133
  SSE transport also supports custom headers for authentication:
src/fastmcp/client/client.py CHANGED
@@ -1,5 +1,5 @@
1
  import datetime
2
- from contextlib import AsyncExitStack
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
@@ -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,12 +77,22 @@ 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)
80
  self._session: ClientSession | None = None
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)
@@ -96,17 +109,28 @@ 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:
103
  """Get the current active session. Raises RuntimeError if not connected."""
104
  if self._session is None:
105
  raise RuntimeError(
106
- "Client is not connected. Use 'async with client:' context manager first."
107
  )
108
  return self._session
109
 
 
 
 
 
 
 
 
 
 
110
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
111
  """Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
112
  self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
@@ -121,27 +145,35 @@ class Client:
121
  """Check if the client is currently connected."""
122
  return self._session is not None
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  async def __aenter__(self):
125
  if self._nesting_counter == 0:
126
  # Create exit stack to manage both context managers
127
  stack = AsyncExitStack()
128
  await stack.__aenter__()
129
 
130
- # Add the exception handling context
131
- stack.enter_context(catch(get_catch_handlers()))
132
-
133
- # the above catch will only apply once this __aenter__ finishes so
134
- # we need to wrap the session creation in a new context in case it
135
- # raises errors itself
136
- with catch(get_catch_handlers()):
137
- # Create and enter the transport session using the exit stack
138
- session_cm = self.transport.connect_session(**self._session_kwargs)
139
- self._session = await stack.enter_async_context(session_cm)
140
 
141
- # Store the stack for cleanup in __aexit__
142
  self._exit_stack = stack
143
 
144
  self._nesting_counter += 1
 
145
  return self
146
 
147
  async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -154,7 +186,6 @@ class Client:
154
  await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
155
  finally:
156
  self._exit_stack = None
157
- self._session = None
158
 
159
  # --- MCP Client Methods ---
160
 
@@ -433,6 +464,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 +476,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 +489,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 +501,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 +512,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 +527,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
 
1
  import datetime
2
+ from contextlib import AsyncExitStack, asynccontextmanager
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
 
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)
84
  self._session: ClientSession | None = None
85
  self._exit_stack: AsyncExitStack | None = None
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)
 
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:
118
  """Get the current active session. Raises RuntimeError if not connected."""
119
  if self._session is None:
120
  raise RuntimeError(
121
+ "Client is not connected. Use the 'async with client:' context manager first."
122
  )
123
  return self._session
124
 
125
+ @property
126
+ def initialize_result(self) -> mcp.types.InitializeResult:
127
+ """Get the result of the initialization request."""
128
+ if self._initialize_result is None:
129
+ raise RuntimeError(
130
+ "Client is not connected. Use the 'async with client:' context manager first."
131
+ )
132
+ return self._initialize_result
133
+
134
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
135
  """Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
136
  self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
 
145
  """Check if the client is currently connected."""
146
  return self._session is not None
147
 
148
+ @asynccontextmanager
149
+ async def _context_manager(self):
150
+ with catch(get_catch_handlers()):
151
+ async with self.transport.connect_session(
152
+ **self._session_kwargs
153
+ ) as session:
154
+ self._session = session
155
+ # Initialize the session
156
+ self._initialize_result = await self._session.initialize()
157
+
158
+ try:
159
+ yield
160
+ finally:
161
+ self._exit_stack = None
162
+ self._session = None
163
+ self._initialize_result = None
164
+
165
  async def __aenter__(self):
166
  if self._nesting_counter == 0:
167
  # Create exit stack to manage both context managers
168
  stack = AsyncExitStack()
169
  await stack.__aenter__()
170
 
171
+ await stack.enter_async_context(self._context_manager())
 
 
 
 
 
 
 
 
 
172
 
 
173
  self._exit_stack = stack
174
 
175
  self._nesting_counter += 1
176
+
177
  return self
178
 
179
  async def __aexit__(self, exc_type, exc_val, exc_tb):
 
186
  await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
187
  finally:
188
  self._exit_stack = None
 
189
 
190
  # --- MCP Client Methods ---
191
 
 
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)
src/fastmcp/client/transports.py CHANGED
@@ -1,14 +1,13 @@
1
  import abc
2
  import contextlib
3
  import datetime
4
- import inspect
5
  import os
6
  import shutil
7
  import sys
8
- import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
  from typing import Any, TypedDict, cast
 
12
 
13
  from mcp import ClientSession, StdioServerParameters
14
  from mcp.client.session import (
@@ -26,6 +25,9 @@ from pydantic import AnyUrl
26
  from typing_extensions import Unpack
27
 
28
  from fastmcp.server import FastMCP as FastMCPServer
 
 
 
29
 
30
 
31
  class SessionKwargs(TypedDict, total=False):
@@ -44,6 +46,7 @@ class ClientTransport(abc.ABC):
44
 
45
  A Transport is responsible for establishing and managing connections
46
  to an MCP server, and providing a ClientSession within an async context.
 
47
  """
48
 
49
  @abc.abstractmethod
@@ -52,7 +55,9 @@ class ClientTransport(abc.ABC):
52
  self, **session_kwargs: Unpack[SessionKwargs]
53
  ) -> AsyncIterator[ClientSession]:
54
  """
55
- Establishes a connection and yields an active, initialized ClientSession.
 
 
56
 
57
  The session is guaranteed to be valid only within the scope of the
58
  async context manager. Connection setup and teardown are handled
@@ -63,7 +68,7 @@ class ClientTransport(abc.ABC):
63
  constructor (e.g., callbacks, timeouts).
64
 
65
  Yields:
66
- An initialized mcp.ClientSession instance.
67
  """
68
  raise NotImplementedError
69
  yield None # type: ignore
@@ -92,7 +97,6 @@ class WSTransport(ClientTransport):
92
  async with ClientSession(
93
  read_stream, write_stream, **session_kwargs
94
  ) as session:
95
- await session.initialize() # Initialize after session creation
96
  yield session
97
 
98
  def __repr__(self) -> str:
@@ -141,7 +145,6 @@ class SSETransport(ClientTransport):
141
  async with ClientSession(
142
  read_stream, write_stream, **session_kwargs
143
  ) as session:
144
- await session.initialize()
145
  yield session
146
 
147
  def __repr__(self) -> str:
@@ -187,7 +190,6 @@ class StreamableHttpTransport(ClientTransport):
187
  async with ClientSession(
188
  read_stream, write_stream, **session_kwargs
189
  ) as session:
190
- await session.initialize()
191
  yield session
192
 
193
  def __repr__(self) -> str:
@@ -235,7 +237,6 @@ class StdioTransport(ClientTransport):
235
  async with ClientSession(
236
  read_stream, write_stream, **session_kwargs
237
  ) as session:
238
- await session.initialize()
239
  yield session
240
 
241
  def __repr__(self) -> str:
@@ -486,36 +487,29 @@ def infer_transport(
486
 
487
  # the transport is a FastMCP server
488
  elif isinstance(transport, FastMCPServer):
489
- return FastMCPTransport(mcp=transport)
490
 
491
  # the transport is a path to a script
492
  elif isinstance(transport, Path | str) and Path(transport).exists():
493
  if str(transport).endswith(".py"):
494
- return PythonStdioTransport(script_path=transport)
495
  elif str(transport).endswith(".js"):
496
- return NodeStdioTransport(script_path=transport)
497
  else:
498
  raise ValueError(f"Unsupported script type: {transport}")
499
 
500
  # the transport is an http(s) URL
501
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
502
- if str(transport).rstrip("/").endswith("/sse"):
503
- warnings.warn(
504
- inspect.cleandoc(
505
- """
506
- As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP.
507
- The provided URL ends in `/sse`, so you may encounter unexpected behavior.
508
- If you intended to use SSE, please use the `SSETransport` class directly.
509
- """
510
- ),
511
- category=UserWarning,
512
- stacklevel=2,
513
- )
514
- return StreamableHttpTransport(url=transport)
515
-
516
- # the transport is a websocket URL
517
- elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
518
- return WSTransport(url=transport)
519
 
520
  ## if the transport is a config dict
521
  elif isinstance(transport, dict):
@@ -530,7 +524,7 @@ def infer_transport(
530
  server_name = list(server.keys())[0]
531
  # Stdio transport
532
  if "command" in server[server_name] and "args" in server[server_name]:
533
- return StdioTransport(
534
  command=server[server_name]["command"],
535
  args=server[server_name]["args"],
536
  env=server[server_name].get("env", None),
@@ -539,7 +533,7 @@ def infer_transport(
539
 
540
  # HTTP transport
541
  elif "url" in server:
542
- return SSETransport(
543
  url=server["url"],
544
  headers=server.get("headers", None),
545
  )
@@ -549,3 +543,6 @@ def infer_transport(
549
  # the transport is an unknown type
550
  else:
551
  raise ValueError(f"Could not infer a valid transport from: {transport}")
 
 
 
 
1
  import abc
2
  import contextlib
3
  import datetime
 
4
  import os
5
  import shutil
6
  import sys
 
7
  from collections.abc import AsyncIterator
8
  from pathlib import Path
9
  from typing import Any, TypedDict, cast
10
+ from urllib.parse import urlparse
11
 
12
  from mcp import ClientSession, StdioServerParameters
13
  from mcp.client.session import (
 
25
  from typing_extensions import Unpack
26
 
27
  from fastmcp.server import FastMCP as FastMCPServer
28
+ from fastmcp.utilities.logging import get_logger
29
+
30
+ logger = get_logger(__name__)
31
 
32
 
33
  class SessionKwargs(TypedDict, total=False):
 
46
 
47
  A Transport is responsible for establishing and managing connections
48
  to an MCP server, and providing a ClientSession within an async context.
49
+
50
  """
51
 
52
  @abc.abstractmethod
 
55
  self, **session_kwargs: Unpack[SessionKwargs]
56
  ) -> AsyncIterator[ClientSession]:
57
  """
58
+ Establishes a connection and yields an active ClientSession.
59
+
60
+ The ClientSession is *not* expected to be initialized in this context manager.
61
 
62
  The session is guaranteed to be valid only within the scope of the
63
  async context manager. Connection setup and teardown are handled
 
68
  constructor (e.g., callbacks, timeouts).
69
 
70
  Yields:
71
+ A mcp.ClientSession instance.
72
  """
73
  raise NotImplementedError
74
  yield None # type: ignore
 
97
  async with ClientSession(
98
  read_stream, write_stream, **session_kwargs
99
  ) as session:
 
100
  yield session
101
 
102
  def __repr__(self) -> str:
 
145
  async with ClientSession(
146
  read_stream, write_stream, **session_kwargs
147
  ) as session:
 
148
  yield session
149
 
150
  def __repr__(self) -> str:
 
190
  async with ClientSession(
191
  read_stream, write_stream, **session_kwargs
192
  ) as session:
 
193
  yield session
194
 
195
  def __repr__(self) -> str:
 
237
  async with ClientSession(
238
  read_stream, write_stream, **session_kwargs
239
  ) as session:
 
240
  yield session
241
 
242
  def __repr__(self) -> str:
 
487
 
488
  # the transport is a FastMCP server
489
  elif isinstance(transport, FastMCPServer):
490
+ inferred_transport = FastMCPTransport(mcp=transport)
491
 
492
  # the transport is a path to a script
493
  elif isinstance(transport, Path | str) and Path(transport).exists():
494
  if str(transport).endswith(".py"):
495
+ inferred_transport = PythonStdioTransport(script_path=transport)
496
  elif str(transport).endswith(".js"):
497
+ inferred_transport = NodeStdioTransport(script_path=transport)
498
  else:
499
  raise ValueError(f"Unsupported script type: {transport}")
500
 
501
  # the transport is an http(s) URL
502
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
503
+ transport_str = str(transport)
504
+ # Parse out just the path portion to check for /sse
505
+ parsed_url = urlparse(transport_str)
506
+ path = parsed_url.path
507
+
508
+ # Check if path contains /sse/ or ends with /sse
509
+ if "/sse/" in path or path.rstrip("/").endswith("/sse"):
510
+ inferred_transport = SSETransport(url=transport)
511
+ else:
512
+ inferred_transport = StreamableHttpTransport(url=transport)
 
 
 
 
 
 
 
513
 
514
  ## if the transport is a config dict
515
  elif isinstance(transport, dict):
 
524
  server_name = list(server.keys())[0]
525
  # Stdio transport
526
  if "command" in server[server_name] and "args" in server[server_name]:
527
+ inferred_transport = StdioTransport(
528
  command=server[server_name]["command"],
529
  args=server[server_name]["args"],
530
  env=server[server_name].get("env", None),
 
533
 
534
  # HTTP transport
535
  elif "url" in server:
536
+ inferred_transport = SSETransport(
537
  url=server["url"],
538
  headers=server.get("headers", None),
539
  )
 
543
  # the transport is an unknown type
544
  else:
545
  raise ValueError(f"Could not infer a valid transport from: {transport}")
546
+
547
+ logger.debug(f"Inferred transport: {inferred_transport}")
548
+ return inferred_transport
tests/client/test_client.py CHANGED
@@ -1,4 +1,5 @@
1
  import asyncio
 
2
  from typing import cast
3
 
4
  import pytest
@@ -6,7 +7,12 @@ from mcp import McpError
6
  from pydantic import AnyUrl
7
 
8
  from fastmcp.client import Client
9
- from fastmcp.client.transports import FastMCPTransport
 
 
 
 
 
10
  from fastmcp.exceptions import ResourceError, ToolError
11
  from fastmcp.prompts.prompt import TextContent
12
  from fastmcp.server.server import FastMCP
@@ -250,18 +256,51 @@ async def test_read_resource_mcp(fastmcp_server):
250
 
251
 
252
  async def test_client_connection(fastmcp_server):
253
- """Test that the client connects and disconnects properly."""
254
  client = Client(transport=FastMCPTransport(fastmcp_server))
255
 
256
- # Before connection
 
 
 
 
257
  assert not client.is_connected()
258
 
259
- # During connection
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  async with client:
261
  assert client.is_connected()
262
 
263
- # After connection
264
  assert not client.is_connected()
 
 
265
 
266
 
267
  async def test_client_nested_context_manager(fastmcp_server):
@@ -509,6 +548,10 @@ class TestErrorHandling:
509
  assert "This is a resource error (xyz)" in str(excinfo.value)
510
 
511
 
 
 
 
 
512
  class TestTimeout:
513
  async def test_timeout(self, fastmcp_server: FastMCP):
514
  async with Client(
@@ -535,6 +578,10 @@ class TestTimeout:
535
  with pytest.raises(McpError):
536
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
537
 
 
 
 
 
538
  async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
539
  self, fastmcp_server: FastMCP
540
  ):
@@ -543,3 +590,53 @@ class TestTimeout:
543
  timeout=0.01,
544
  ) as client:
545
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
2
+ import sys
3
  from typing import cast
4
 
5
  import pytest
 
7
  from pydantic import AnyUrl
8
 
9
  from fastmcp.client import Client
10
+ from fastmcp.client.transports import (
11
+ FastMCPTransport,
12
+ SSETransport,
13
+ StreamableHttpTransport,
14
+ infer_transport,
15
+ )
16
  from fastmcp.exceptions import ResourceError, ToolError
17
  from fastmcp.prompts.prompt import TextContent
18
  from fastmcp.server.server import FastMCP
 
256
 
257
 
258
  async def test_client_connection(fastmcp_server):
259
+ """Test that connect is idempotent."""
260
  client = Client(transport=FastMCPTransport(fastmcp_server))
261
 
262
+ # Connect idempotently
263
+ async with client:
264
+ assert client.is_connected()
265
+ # Make a request to ensure connection is working
266
+ await client.ping()
267
  assert not client.is_connected()
268
 
269
+
270
+ async def test_initialize_result_connected(fastmcp_server):
271
+ """Test that initialize_result returns the correct result when connected."""
272
+ client = Client(transport=FastMCPTransport(fastmcp_server))
273
+
274
+ # Initialize result should not be accessible before connection
275
+ with pytest.raises(RuntimeError, match="Client is not connected"):
276
+ _ = client.initialize_result
277
+
278
+ async with client:
279
+ # Once connected, initialize_result should be available
280
+ result = client.initialize_result
281
+
282
+ # Verify the initialize result has expected properties
283
+ assert hasattr(result, "serverInfo")
284
+ assert result.serverInfo.name == "TestServer"
285
+ assert result.serverInfo.version is not None
286
+
287
+
288
+ async def test_initialize_result_disconnected(fastmcp_server):
289
+ """Test that initialize_result raises an error when not connected."""
290
+ client = Client(transport=FastMCPTransport(fastmcp_server))
291
+
292
+ # Initialize result should not be accessible before connection
293
+ with pytest.raises(RuntimeError, match="Client is not connected"):
294
+ _ = client.initialize_result
295
+
296
+ # Connect and then disconnect
297
  async with client:
298
  assert client.is_connected()
299
 
300
+ # After disconnection, initialize_result should raise an error
301
  assert not client.is_connected()
302
+ with pytest.raises(RuntimeError, match="Client is not connected"):
303
+ _ = client.initialize_result
304
 
305
 
306
  async def test_client_nested_context_manager(fastmcp_server):
 
548
  assert "This is a resource error (xyz)" in str(excinfo.value)
549
 
550
 
551
+ @pytest.mark.skipif(
552
+ sys.platform == "win32",
553
+ reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
554
+ )
555
  class TestTimeout:
556
  async def test_timeout(self, fastmcp_server: FastMCP):
557
  async with Client(
 
578
  with pytest.raises(McpError):
579
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
580
 
581
+ @pytest.mark.skipif(
582
+ sys.platform == "win32",
583
+ reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
584
+ )
585
  async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
586
  self, fastmcp_server: FastMCP
587
  ):
 
590
  timeout=0.01,
591
  ) as client:
592
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
593
+
594
+
595
+ class TestInferTransport:
596
+ """Tests for the infer_transport function."""
597
+
598
+ @pytest.mark.parametrize(
599
+ "url",
600
+ [
601
+ "http://example.com/api/sse/stream",
602
+ "https://localhost:8080/mcp/sse/endpoint",
603
+ "http://example.com/api/sse",
604
+ "https://localhost:8080/mcp/sse",
605
+ "http://example.com/api/sse?param=value",
606
+ "https://localhost:8080/mcp/sse/?param=value",
607
+ "https://localhost:8000/mcp/sse?x=1&y=2",
608
+ ],
609
+ ids=[
610
+ "path_with_sse_directory",
611
+ "path_with_sse_subdirectory",
612
+ "path_ending_with_sse",
613
+ "path_ending_with_sse_https",
614
+ "path_with_sse_and_query_params",
615
+ "path_with_sse_slash_and_query_params",
616
+ "path_with_sse_and_ampersand_param",
617
+ ],
618
+ )
619
+ def test_url_returns_sse_transport(self, url):
620
+ """Test that URLs with /sse/ pattern return SSETransport."""
621
+ assert isinstance(infer_transport(url), SSETransport)
622
+
623
+ @pytest.mark.parametrize(
624
+ "url",
625
+ [
626
+ "http://example.com/api",
627
+ "https://localhost:8080/mcp",
628
+ "http://example.com/asset/image.jpg",
629
+ "https://localhost:8080/sservice/endpoint",
630
+ "https://example.com/assets/file",
631
+ ],
632
+ ids=[
633
+ "regular_http_url",
634
+ "regular_https_url",
635
+ "url_with_unrelated_path",
636
+ "url_with_sservice_in_path",
637
+ "url_with_assets_in_path",
638
+ ],
639
+ )
640
+ def test_url_returns_streamable_http_transport(self, url):
641
+ """Test that URLs without /sse/ pattern return StreamableHttpTransport."""
642
+ assert isinstance(infer_transport(url), StreamableHttpTransport)
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
tests/client/test_sse.py CHANGED
@@ -136,11 +136,11 @@ async def test_nested_sse_server_resolves_correctly():
136
  assert result is True
137
 
138
 
 
 
 
 
139
  class TestTimeout:
140
- @pytest.mark.skipif(
141
- sys.platform == "win32",
142
- reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
143
- )
144
  async def test_timeout(self, sse_server: str):
145
  with pytest.raises(
146
  McpError,
@@ -167,10 +167,6 @@ class TestTimeout:
167
  with pytest.raises(McpError, match="Timed out"):
168
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
169
 
170
- @pytest.mark.skipif(
171
- sys.platform == "win32",
172
- reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
173
- )
174
  async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower(
175
  self, sse_server: str
176
  ):
 
136
  assert result is True
137
 
138
 
139
+ @pytest.mark.skipif(
140
+ sys.platform == "win32",
141
+ reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
142
+ )
143
  class TestTimeout:
 
 
 
 
144
  async def test_timeout(self, sse_server: str):
145
  with pytest.raises(
146
  McpError,
 
167
  with pytest.raises(McpError, match="Timed out"):
168
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
169
 
 
 
 
 
170
  async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower(
171
  self, sse_server: str
172
  ):
tests/client/test_streamable_http.py CHANGED
@@ -149,6 +149,10 @@ async def test_nested_streamable_http_server_resolves_correctly():
149
  assert result is True
150
 
151
 
 
 
 
 
152
  class TestTimeout:
153
  async def test_timeout(self, streamable_http_server: str):
154
  # note this transport behaves differently than others and raises
 
149
  assert result is True
150
 
151
 
152
+ @pytest.mark.skipif(
153
+ sys.platform == "win32",
154
+ reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
155
+ )
156
  class TestTimeout:
157
  async def test_timeout(self, streamable_http_server: str):
158
  # note this transport behaves differently than others and raises
tests/server/test_server_interactions.py CHANGED
@@ -640,7 +640,6 @@ class TestToolContextInjection:
640
  assert len(result) == 1
641
  content = result[0]
642
  assert isinstance(content, TextContent)
643
- assert content.text == "1"
644
 
645
  async def test_async_context(self):
646
  """Test that context works in async functions."""
@@ -656,8 +655,7 @@ class TestToolContextInjection:
656
  assert len(result) == 1
657
  content = result[0]
658
  assert isinstance(content, TextContent)
659
- assert "Async request" in content.text
660
- assert "42" in content.text
661
 
662
  async def test_optional_context(self):
663
  """Test that context is optional."""
@@ -798,7 +796,7 @@ class TestResourceContext:
798
  async with Client(mcp) as client:
799
  result = await client.read_resource(AnyUrl("resource://test"))
800
  assert isinstance(result[0], TextResourceContents)
801
- assert result[0].text == "1"
802
 
803
 
804
  class TestResourceTemplates:
@@ -1096,7 +1094,7 @@ class TestResourceTemplateContext:
1096
  async with Client(mcp) as client:
1097
  result = await client.read_resource(AnyUrl("resource://test"))
1098
  assert isinstance(result[0], TextResourceContents)
1099
- assert result[0].text == "Resource template: test 1"
1100
 
1101
 
1102
  class TestPrompts:
 
640
  assert len(result) == 1
641
  content = result[0]
642
  assert isinstance(content, TextContent)
 
643
 
644
  async def test_async_context(self):
645
  """Test that context works in async functions."""
 
655
  assert len(result) == 1
656
  content = result[0]
657
  assert isinstance(content, TextContent)
658
+ assert content.text == "Async request 2: 42"
 
659
 
660
  async def test_optional_context(self):
661
  """Test that context is optional."""
 
796
  async with Client(mcp) as client:
797
  result = await client.read_resource(AnyUrl("resource://test"))
798
  assert isinstance(result[0], TextResourceContents)
799
+ assert result[0].text == "2"
800
 
801
 
802
  class TestResourceTemplates:
 
1094
  async with Client(mcp) as client:
1095
  result = await client.read_resource(AnyUrl("resource://test"))
1096
  assert isinstance(result[0], TextResourceContents)
1097
+ assert result[0].text.startswith("Resource template: test 2")
1098
 
1099
 
1100
  class TestPrompts: