fastmcp 2.3.5__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/client/client.py CHANGED
@@ -3,12 +3,18 @@ from contextlib import AsyncExitStack, asynccontextmanager
3
3
  from pathlib import Path
4
4
  from typing import Any, cast
5
5
 
6
+ import anyio
6
7
  import mcp.types
7
8
  from exceptiongroup import catch
8
9
  from mcp import ClientSession
9
10
  from pydantic import AnyUrl
10
11
 
11
- from fastmcp.client.logging import LogHandler, MessageHandler, default_log_handler
12
+ from fastmcp.client.logging import (
13
+ LogHandler,
14
+ MessageHandler,
15
+ create_log_callback,
16
+ default_log_handler,
17
+ )
12
18
  from fastmcp.client.progress import ProgressHandler, default_progress_handler
13
19
  from fastmcp.client.roots import (
14
20
  RootsHandler,
@@ -19,6 +25,7 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
19
25
  from fastmcp.exceptions import ToolError
20
26
  from fastmcp.server import FastMCP
21
27
  from fastmcp.utilities.exceptions import get_catch_handlers
28
+ from fastmcp.utilities.mcp_config import MCPConfig
22
29
 
23
30
  from .transports import ClientTransport, SessionKwargs, infer_transport
24
31
 
@@ -47,6 +54,7 @@ class Client:
47
54
  - FastMCP: In-process FastMCP server
48
55
  - AnyUrl | str: URL to connect to
49
56
  - Path: File path for local socket
57
+ - MCPConfig: MCP server configuration
50
58
  - dict: Transport configuration
51
59
  roots: Optional RootsList or RootsHandler for filesystem access
52
60
  sampling_handler: Optional handler for sampling requests
@@ -71,7 +79,13 @@ class Client:
71
79
 
72
80
  def __init__(
73
81
  self,
74
- transport: ClientTransport | FastMCP | AnyUrl | Path | dict[str, Any] | str,
82
+ transport: ClientTransport
83
+ | FastMCP
84
+ | AnyUrl
85
+ | Path
86
+ | MCPConfig
87
+ | dict[str, Any]
88
+ | str,
75
89
  # Common args
76
90
  roots: RootsList | RootsHandler | None = None,
77
91
  sampling_handler: SamplingHandler | None = None,
@@ -100,7 +114,7 @@ class Client:
100
114
  self._session_kwargs: SessionKwargs = {
101
115
  "sampling_callback": None,
102
116
  "list_roots_callback": None,
103
- "logging_callback": log_handler,
117
+ "logging_callback": create_log_callback(log_handler),
104
118
  "message_handler": message_handler,
105
119
  "read_timeout_seconds": timeout,
106
120
  }
@@ -153,10 +167,12 @@ class Client:
153
167
  ) as session:
154
168
  self._session = session
155
169
  # Initialize the session
156
- self._initialize_result = await self._session.initialize()
157
-
158
170
  try:
171
+ with anyio.fail_after(1):
172
+ self._initialize_result = await self._session.initialize()
159
173
  yield
174
+ except TimeoutError:
175
+ raise RuntimeError("Failed to initialize server session")
160
176
  finally:
161
177
  self._exit_stack = None
162
178
  self._session = None
fastmcp/client/logging.py CHANGED
@@ -1,9 +1,7 @@
1
+ from collections.abc import Awaitable, Callable
1
2
  from typing import TypeAlias
2
3
 
3
- from mcp.client.session import (
4
- LoggingFnT,
5
- MessageHandlerFnT,
6
- )
4
+ from mcp.client.session import LoggingFnT, MessageHandlerFnT
7
5
  from mcp.types import LoggingMessageNotificationParams
8
6
 
9
7
  from fastmcp.utilities.logging import get_logger
@@ -11,11 +9,19 @@ from fastmcp.utilities.logging import get_logger
11
9
  logger = get_logger(__name__)
12
10
 
13
11
  LogMessage: TypeAlias = LoggingMessageNotificationParams
14
- LogHandler: TypeAlias = LoggingFnT
12
+ LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
15
13
  MessageHandler: TypeAlias = MessageHandlerFnT
16
14
 
17
- __all__ = ["LogMessage", "LogHandler", "MessageHandler"]
18
15
 
16
+ async def default_log_handler(message: LogMessage) -> None:
17
+ logger.debug(f"Log received: {message}")
19
18
 
20
- async def default_log_handler(params: LogMessage) -> None:
21
- logger.debug(f"Log received: {params}")
19
+
20
+ def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT:
21
+ if handler is None:
22
+ handler = default_log_handler
23
+
24
+ async def log_callback(params: LoggingMessageNotificationParams) -> None:
25
+ await handler(params)
26
+
27
+ return log_callback
@@ -6,8 +6,7 @@ import shutil
6
6
  import sys
7
7
  from collections.abc import AsyncIterator
8
8
  from pathlib import Path
9
- from typing import Any, TypedDict, cast
10
- from urllib.parse import urlparse
9
+ from typing import TYPE_CHECKING, Any, TypedDict, cast
11
10
 
12
11
  from mcp import ClientSession, StdioServerParameters
13
12
  from mcp.client.session import (
@@ -25,7 +24,12 @@ from pydantic import AnyUrl
25
24
  from typing_extensions import Unpack
26
25
 
27
26
  from fastmcp.server import FastMCP as FastMCPServer
27
+ from fastmcp.server.server import FastMCP
28
28
  from fastmcp.utilities.logging import get_logger
29
+ from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
30
+
31
+ if TYPE_CHECKING:
32
+ from fastmcp.utilities.mcp_config import MCPConfig
29
33
 
30
34
  logger = get_logger(__name__)
31
35
 
@@ -71,7 +75,7 @@ class ClientTransport(abc.ABC):
71
75
  A mcp.ClientSession instance.
72
76
  """
73
77
  raise NotImplementedError
74
- yield None # type: ignore
78
+ yield # type: ignore
75
79
 
76
80
  def __repr__(self) -> str:
77
81
  # Basic representation for subclasses
@@ -452,7 +456,7 @@ class FastMCPTransport(ClientTransport):
452
456
  """
453
457
 
454
458
  def __init__(self, mcp: FastMCPServer):
455
- self._fastmcp = mcp # Can be FastMCP or MCPServer
459
+ self.server = mcp # Can be FastMCP or MCPServer
456
460
 
457
461
  @contextlib.asynccontextmanager
458
462
  async def connect_session(
@@ -460,17 +464,105 @@ class FastMCPTransport(ClientTransport):
460
464
  ) -> AsyncIterator[ClientSession]:
461
465
  # create_connected_server_and_client_session manages the session lifecycle itself
462
466
  async with create_connected_server_and_client_session(
463
- server=self._fastmcp._mcp_server,
467
+ server=self.server._mcp_server,
464
468
  **session_kwargs,
465
469
  ) as session:
466
470
  yield session
467
471
 
468
472
  def __repr__(self) -> str:
469
- return f"<FastMCP(server='{self._fastmcp.name}')>"
473
+ return f"<FastMCP(server='{self.server.name}')>"
474
+
475
+
476
+ class MCPConfigTransport(ClientTransport):
477
+ """Transport for connecting to one or more MCP servers defined in an MCPConfig.
478
+
479
+ This transport provides a unified interface to multiple MCP servers defined in an MCPConfig
480
+ object or dictionary matching the MCPConfig schema. It supports two key scenarios:
481
+
482
+ 1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
483
+ 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
484
+ all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
485
+
486
+ In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
487
+ and resources with the pattern `protocol://{server_name}/path/to/resource`.
488
+
489
+ This is particularly useful for creating clients that need to interact with multiple specialized
490
+ MCP servers through a single interface, simplifying client code.
491
+
492
+ Examples:
493
+ ```python
494
+ from fastmcp import Client
495
+ from fastmcp.utilities.mcp_config import MCPConfig
496
+
497
+ # Create a config with multiple servers
498
+ config = {
499
+ "mcpServers": {
500
+ "weather": {
501
+ "url": "https://weather-api.example.com/mcp",
502
+ "transport": "streamable-http"
503
+ },
504
+ "calendar": {
505
+ "url": "https://calendar-api.example.com/mcp",
506
+ "transport": "streamable-http"
507
+ }
508
+ }
509
+ }
510
+
511
+ # Create a client with the config
512
+ client = Client(config)
513
+
514
+ async with client:
515
+ # Access tools with prefixes
516
+ weather = await client.call_tool("weather_get_forecast", {"city": "London"})
517
+ events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
518
+
519
+ # Access resources with prefixed URIs
520
+ icons = await client.read_resource("weather://weather/icons/sunny")
521
+ ```
522
+ """
523
+
524
+ def __init__(self, config: MCPConfig | dict):
525
+ from fastmcp.client.client import Client
526
+
527
+ if isinstance(config, dict):
528
+ config = MCPConfig.from_dict(config)
529
+ self.config = config
530
+
531
+ # if there's exactly one server, create a client for that server
532
+ if len(self.config.mcpServers) == 1:
533
+ self.transport = list(self.config.mcpServers.values())[0].to_transport()
534
+
535
+ # otherwise create a composite client
536
+ else:
537
+ composite_server = FastMCP()
538
+
539
+ for name, server in self.config.mcpServers.items():
540
+ server_client = Client(transport=server.to_transport())
541
+ composite_server.mount(
542
+ prefix=name, server=FastMCP.as_proxy(server_client)
543
+ )
544
+
545
+ self.transport = FastMCPTransport(mcp=composite_server)
546
+
547
+ @contextlib.asynccontextmanager
548
+ async def connect_session(
549
+ self, **session_kwargs: Unpack[SessionKwargs]
550
+ ) -> AsyncIterator[ClientSession]:
551
+ async with self.transport.connect_session(**session_kwargs) as session:
552
+ yield session
553
+
554
+ def __repr__(self) -> str:
555
+ return f"<MCPConfig(config='{self.config}')>"
470
556
 
471
557
 
472
558
  def infer_transport(
473
- transport: ClientTransport | FastMCPServer | AnyUrl | Path | dict[str, Any] | str,
559
+ transport: ClientTransport
560
+ | FastMCPServer
561
+ | AnyUrl
562
+ | Path
563
+ | MCPConfig
564
+ | dict[str, Any]
565
+ | str,
474
566
  ) -> ClientTransport:
475
567
  """
476
568
  Infer the appropriate transport type from the given transport argument.
@@ -479,8 +571,41 @@ def infer_transport(
479
571
  argument, handling various input types and converting them to the appropriate
480
572
  ClientTransport subclass.
481
573
 
574
+ The function supports these input types:
575
+ - ClientTransport: Used directly without modification
576
+ - FastMCPServer: Creates an in-memory FastMCPTransport
577
+ - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
578
+ - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
579
+ - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
580
+
482
581
  For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
582
+
583
+ For MCPConfig with multiple servers, a composite client is created where each server
584
+ is mounted with its name as prefix. This allows accessing tools and resources from multiple
585
+ servers through a single unified client interface, using naming patterns like
586
+ `servername_toolname` for tools and `protocol://servername/path` for resources.
587
+ If the MCPConfig contains only one server, a direct connection is established without prefixing.
588
+
589
+ Examples:
590
+ ```python
591
+ # Connect to a local Python script
592
+ transport = infer_transport("my_script.py")
593
+
594
+ # Connect to a remote server via HTTP
595
+ transport = infer_transport("http://example.com/mcp")
596
+
597
+ # Connect to multiple servers using MCPConfig
598
+ config = {
599
+ "mcpServers": {
600
+ "weather": {"url": "http://weather.example.com/mcp"},
601
+ "calendar": {"url": "http://calendar.example.com/mcp"}
602
+ }
603
+ }
604
+ transport = infer_transport(config)
605
+ ```
483
606
  """
607
+ from fastmcp.utilities.mcp_config import MCPConfig
608
+
484
609
  # the transport is already a ClientTransport
485
610
  if isinstance(transport, ClientTransport):
486
611
  return transport
@@ -500,45 +625,15 @@ def infer_transport(
500
625
 
501
626
  # the transport is an http(s) URL
502
627
  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"):
628
+ inferred_transport_type = infer_transport_type_from_url(transport)
629
+ if inferred_transport_type == "sse":
510
630
  inferred_transport = SSETransport(url=transport)
511
631
  else:
512
632
  inferred_transport = StreamableHttpTransport(url=transport)
513
633
 
514
- ## if the transport is a config dict
515
- elif isinstance(transport, dict):
516
- if "mcpServers" not in transport:
517
- raise ValueError("Invalid transport dictionary: missing 'mcpServers' key")
518
- else:
519
- server = transport["mcpServers"]
520
- if len(list(server.keys())) > 1:
521
- raise ValueError(
522
- "Invalid transport dictionary: multiple servers found - only one expected"
523
- )
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),
531
- cwd=server[server_name].get("cwd", None),
532
- )
533
-
534
- # HTTP transport
535
- elif "url" in server:
536
- inferred_transport = SSETransport(
537
- url=server["url"],
538
- headers=server.get("headers", None),
539
- )
540
-
541
- raise ValueError("Cannot determine transport type from dictionary")
634
+ # if the transport is a config dict or MCPConfig
635
+ elif isinstance(transport, dict | MCPConfig):
636
+ inferred_transport = MCPConfigTransport(config=transport)
542
637
 
543
638
  # the transport is an unknown type
544
639
  else:
fastmcp/server/http.py CHANGED
@@ -306,7 +306,29 @@ def create_streamable_http_app(
306
306
  async def handle_streamable_http(
307
307
  scope: Scope, receive: Receive, send: Send
308
308
  ) -> None:
309
- await session_manager.handle_request(scope, receive, send)
309
+ try:
310
+ await session_manager.handle_request(scope, receive, send)
311
+ except RuntimeError as e:
312
+ if str(e) == "Task group is not initialized. Make sure to use run().":
313
+ logger.error(
314
+ f"Original RuntimeError from mcp library: {e}", exc_info=True
315
+ )
316
+ new_error_message = (
317
+ "FastMCP's StreamableHTTPSessionManager task group was not initialized. "
318
+ "This commonly occurs when the FastMCP application's lifespan is not "
319
+ "passed to the parent ASGI application (e.g., FastAPI or Starlette). "
320
+ "Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
321
+ "parent app's constructor, where `mcp_app` is the application instance "
322
+ "returned by `fastmcp_instance.http_app()`. \\n"
323
+ "For more details, see the FastMCP ASGI integration documentation: "
324
+ "https://gofastmcp.com/deployment/asgi"
325
+ )
326
+ # Raise a new RuntimeError that includes the original error's message
327
+ # for full context, but leads with the more helpful guidance.
328
+ raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
329
+ else:
330
+ # Re-raise other RuntimeErrors if they don't match the specific message
331
+ raise
310
332
 
311
333
  # Get auth middleware and routes
312
334
  auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
fastmcp/server/openapi.py CHANGED
@@ -47,7 +47,7 @@ class RouteType(enum.Enum):
47
47
  class RouteMap:
48
48
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
49
 
50
- methods: list[HttpMethod]
50
+ methods: list[HttpMethod] | Literal["*"]
51
51
  pattern: Pattern[str] | str
52
52
  route_type: RouteType
53
53
 
@@ -86,7 +86,7 @@ def _determine_route_type(
86
86
  # Check mappings in priority order (first match wins)
87
87
  for route_map in mappings:
88
88
  # Check if the HTTP method matches
89
- if route.method in route_map.methods:
89
+ if route_map.methods == "*" or route.method in route_map.methods:
90
90
  # Handle both string patterns and compiled Pattern objects
91
91
  if isinstance(route_map.pattern, Pattern):
92
92
  pattern_matches = route_map.pattern.search(route.path)
@@ -171,17 +171,120 @@ class OpenAPITool(Tool):
171
171
  raise ToolError(f"Missing required path parameters: {missing_params}")
172
172
 
173
173
  for param_name, param_value in path_params.items():
174
+ # Handle array path parameters with style 'simple' (comma-separated)
175
+ # In OpenAPI, 'simple' is the default style for path parameters
176
+ param_info = next(
177
+ (p for p in self._route.parameters if p.name == param_name), None
178
+ )
179
+
180
+ if param_info and isinstance(param_value, list):
181
+ # Check if schema indicates an array type
182
+ schema = param_info.schema_
183
+ is_array = schema.get("type") == "array"
184
+
185
+ if is_array:
186
+ # Format array values as comma-separated string
187
+ # This follows the OpenAPI 'simple' style (default for path)
188
+ if all(
189
+ isinstance(item, str | int | float | bool)
190
+ for item in param_value
191
+ ):
192
+ # Handle simple array types
193
+ path = path.replace(
194
+ f"{{{param_name}}}", ",".join(str(v) for v in param_value)
195
+ )
196
+ else:
197
+ # Handle complex array types (containing objects/dicts)
198
+ try:
199
+ # Try to create a simple representation without Python syntax artifacts
200
+ formatted_parts = []
201
+ for item in param_value:
202
+ if isinstance(item, dict):
203
+ # For objects, serialize key-value pairs
204
+ item_parts = []
205
+ for k, v in item.items():
206
+ item_parts.append(f"{k}:{v}")
207
+ formatted_parts.append(".".join(item_parts))
208
+ else:
209
+ # Fallback for other complex types
210
+ formatted_parts.append(str(item))
211
+
212
+ # Join parts with commas
213
+ formatted_value = ",".join(formatted_parts)
214
+ path = path.replace(f"{{{param_name}}}", formatted_value)
215
+ except Exception as e:
216
+ logger.warning(
217
+ f"Failed to format complex array path parameter '{param_name}': {e}"
218
+ )
219
+ # Fallback to string representation, but remove Python syntax artifacts
220
+ str_value = (
221
+ str(param_value)
222
+ .replace("[", "")
223
+ .replace("]", "")
224
+ .replace("'", "")
225
+ .replace('"', "")
226
+ )
227
+ path = path.replace(f"{{{param_name}}}", str_value)
228
+ continue
229
+
230
+ # Default handling for non-array parameters or non-array schemas
174
231
  path = path.replace(f"{{{param_name}}}", str(param_value))
175
232
 
176
233
  # Prepare query parameters - filter out None and empty strings
177
- query_params = {
178
- p.name: kwargs.get(p.name)
179
- for p in self._route.parameters
180
- if p.location == "query"
181
- and p.name in kwargs
182
- and kwargs.get(p.name) is not None
183
- and kwargs.get(p.name) != ""
184
- }
234
+ query_params = {}
235
+ for p in self._route.parameters:
236
+ if (
237
+ p.location == "query"
238
+ and p.name in kwargs
239
+ and kwargs.get(p.name) is not None
240
+ and kwargs.get(p.name) != ""
241
+ ):
242
+ param_value = kwargs.get(p.name)
243
+
244
+ # Format array query parameters as comma-separated strings
245
+ # following OpenAPI form style (default for query parameters)
246
+ if isinstance(param_value, list) and p.schema_.get("type") == "array":
247
+ # Get explode parameter from schema, default is True for query parameters
248
+ # If explode is True, the array is serialized as separate parameters
249
+ # If explode is False, the array is serialized as a comma-separated string
250
+ explode = p.schema_.get("explode", True)
251
+
252
+ if explode:
253
+ # When explode=True, we pass the array directly, which HTTPX will serialize
254
+ # as multiple parameters with the same name
255
+ query_params[p.name] = param_value
256
+ else:
257
+ # For arrays of simple types (strings, numbers, etc.), join with commas
258
+ if all(
259
+ isinstance(item, str | int | float | bool)
260
+ for item in param_value
261
+ ):
262
+ query_params[p.name] = ",".join(str(v) for v in param_value)
263
+ else:
264
+ # For complex types, try to create a simpler representation
265
+ try:
266
+ # Try to create a simple string representation
267
+ formatted_parts = []
268
+ for item in param_value:
269
+ if isinstance(item, dict):
270
+ # For objects, serialize key-value pairs
271
+ item_parts = []
272
+ for k, v in item.items():
273
+ item_parts.append(f"{k}:{v}")
274
+ formatted_parts.append(".".join(item_parts))
275
+ else:
276
+ formatted_parts.append(str(item))
277
+
278
+ query_params[p.name] = ",".join(formatted_parts)
279
+ except Exception as e:
280
+ logger.warning(
281
+ f"Failed to format complex array query parameter '{p.name}': {e}"
282
+ )
283
+ # Fallback to string representation
284
+ query_params[p.name] = param_value
285
+ else:
286
+ # Non-array parameters are passed as is
287
+ query_params[p.name] = param_value
185
288
 
186
289
  # Prepare headers - fix typing by ensuring all values are strings
187
290
  headers = {}