Jeremiah Lowin commited on
Commit
a01a818
·
1 Parent(s): d3e7c9f

Support creating clients from mcpconfig

Browse files
src/fastmcp/client/client.py CHANGED
@@ -25,6 +25,7 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
25
  from fastmcp.exceptions import ToolError
26
  from fastmcp.server import FastMCP
27
  from fastmcp.utilities.exceptions import get_catch_handlers
 
28
 
29
  from .transports import ClientTransport, SessionKwargs, infer_transport
30
 
@@ -53,6 +54,7 @@ class Client:
53
  - FastMCP: In-process FastMCP server
54
  - AnyUrl | str: URL to connect to
55
  - Path: File path for local socket
 
56
  - dict: Transport configuration
57
  roots: Optional RootsList or RootsHandler for filesystem access
58
  sampling_handler: Optional handler for sampling requests
@@ -77,7 +79,13 @@ class Client:
77
 
78
  def __init__(
79
  self,
80
- transport: ClientTransport | FastMCP | AnyUrl | Path | dict[str, Any] | str,
 
 
 
 
 
 
81
  # Common args
82
  roots: RootsList | RootsHandler | None = None,
83
  sampling_handler: SamplingHandler | None = None,
 
25
  from fastmcp.exceptions import ToolError
26
  from fastmcp.server import FastMCP
27
  from fastmcp.utilities.exceptions import get_catch_handlers
28
+ from fastmcp.utilities.mcp_config import MCPConfig
29
 
30
  from .transports import ClientTransport, SessionKwargs, infer_transport
31
 
 
54
  - FastMCP: In-process FastMCP server
55
  - AnyUrl | str: URL to connect to
56
  - Path: File path for local socket
57
+ - MCPConfig: MCP server configuration
58
  - dict: Transport configuration
59
  roots: Optional RootsList or RootsHandler for filesystem access
60
  sampling_handler: Optional handler for sampling requests
 
79
 
80
  def __init__(
81
  self,
82
+ transport: ClientTransport
83
+ | FastMCP
84
+ | AnyUrl
85
+ | Path
86
+ | MCPConfig
87
+ | dict[str, Any]
88
+ | str,
89
  # Common args
90
  roots: RootsList | RootsHandler | None = None,
91
  sampling_handler: SamplingHandler | None = None,
src/fastmcp/client/transports.py CHANGED
@@ -24,6 +24,7 @@ from pydantic import AnyUrl
24
  from typing_extensions import Unpack
25
 
26
  from fastmcp.server import FastMCP as FastMCPServer
 
27
  from fastmcp.utilities.logging import get_logger
28
  from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
29
 
@@ -74,7 +75,7 @@ class ClientTransport(abc.ABC):
74
  A mcp.ClientSession instance.
75
  """
76
  raise NotImplementedError
77
- yield None # type: ignore
78
 
79
  def __repr__(self) -> str:
80
  # Basic representation for subclasses
@@ -455,7 +456,7 @@ class FastMCPTransport(ClientTransport):
455
  """
456
 
457
  def __init__(self, mcp: FastMCPServer):
458
- self._fastmcp = mcp # Can be FastMCP or MCPServer
459
 
460
  @contextlib.asynccontextmanager
461
  async def connect_session(
@@ -463,13 +464,50 @@ class FastMCPTransport(ClientTransport):
463
  ) -> AsyncIterator[ClientSession]:
464
  # create_connected_server_and_client_session manages the session lifecycle itself
465
  async with create_connected_server_and_client_session(
466
- server=self._fastmcp._mcp_server,
467
  **session_kwargs,
468
  ) as session:
469
  yield session
470
 
471
  def __repr__(self) -> str:
472
- return f"<FastMCP(server='{self._fastmcp.name}')>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
 
475
  def infer_transport(
@@ -519,16 +557,7 @@ def infer_transport(
519
 
520
  # if the transport is a config dict or MCPConfig
521
  elif isinstance(transport, dict | MCPConfig):
522
- if isinstance(transport, dict):
523
- config = MCPConfig.from_dict(transport)
524
- else:
525
- config = transport
526
- inferred_transports = config.to_transports()
527
- if len(inferred_transports) > 1:
528
- raise ValueError(
529
- "Invalid transport dictionary: multiple servers found - only one expected"
530
- )
531
- inferred_transport = list(inferred_transports.values())[0]
532
 
533
  # the transport is an unknown type
534
  else:
 
24
  from typing_extensions import Unpack
25
 
26
  from fastmcp.server import FastMCP as FastMCPServer
27
+ from fastmcp.server.server import FastMCP
28
  from fastmcp.utilities.logging import get_logger
29
  from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
30
 
 
75
  A mcp.ClientSession instance.
76
  """
77
  raise NotImplementedError
78
+ yield # type: ignore
79
 
80
  def __repr__(self) -> str:
81
  # Basic representation for subclasses
 
456
  """
457
 
458
  def __init__(self, mcp: FastMCPServer):
459
+ self.server = mcp # Can be FastMCP or MCPServer
460
 
461
  @contextlib.asynccontextmanager
462
  async def connect_session(
 
464
  ) -> AsyncIterator[ClientSession]:
465
  # create_connected_server_and_client_session manages the session lifecycle itself
466
  async with create_connected_server_and_client_session(
467
+ server=self.server._mcp_server,
468
  **session_kwargs,
469
  ) as session:
470
  yield session
471
 
472
  def __repr__(self) -> str:
473
+ return f"<FastMCP(server='{self.server.name}')>"
474
+
475
+
476
+ class MCPConfigTransport(ClientTransport):
477
+ """Transport for running MCPConfig."""
478
+
479
+ def __init__(self, config: MCPConfig | dict):
480
+ from fastmcp.client.client import Client
481
+
482
+ if isinstance(config, dict):
483
+ config = MCPConfig.from_dict(config)
484
+ self.config = config
485
+
486
+ # if there's exactly one server, create a client for that server
487
+ if len(self.config.mcpServers) == 1:
488
+ self.transport = list(self.config.mcpServers.values())[0].to_transport()
489
+
490
+ # otherwise create a composite client
491
+ else:
492
+ composite_server = FastMCP()
493
+
494
+ for name, server in self.config.mcpServers.items():
495
+ server_client = Client(transport=server.to_transport())
496
+ composite_server.mount(
497
+ prefix=name, server=FastMCP.as_proxy(server_client)
498
+ )
499
+
500
+ self.transport = FastMCPTransport(mcp=composite_server)
501
+
502
+ @contextlib.asynccontextmanager
503
+ async def connect_session(
504
+ self, **session_kwargs: Unpack[SessionKwargs]
505
+ ) -> AsyncIterator[ClientSession]:
506
+ async with self.transport.connect_session(**session_kwargs) as session:
507
+ yield session
508
+
509
+ def __repr__(self) -> str:
510
+ return f"<MCPConfig(config='{self.config}')>"
511
 
512
 
513
  def infer_transport(
 
557
 
558
  # if the transport is a config dict or MCPConfig
559
  elif isinstance(transport, dict | MCPConfig):
560
+ inferred_transport = MCPConfigTransport(config=transport)
 
 
 
 
 
 
 
 
 
561
 
562
  # the transport is an unknown type
563
  else:
src/fastmcp/server/proxy.py CHANGED
@@ -25,7 +25,6 @@ from fastmcp.server.context import Context
25
  from fastmcp.server.server import FastMCP
26
  from fastmcp.tools.tool import Tool
27
  from fastmcp.utilities.logging import get_logger
28
- from fastmcp.utilities.mcp_config import MCPConfig
29
 
30
  if TYPE_CHECKING:
31
  from fastmcp.server import Context
@@ -178,13 +177,6 @@ class FastMCPProxy(FastMCP):
178
  super().__init__(**kwargs)
179
  self.client = client
180
 
181
- @classmethod
182
- async def from_mcp_config(cls, config: MCPConfig | dict) -> FastMCPProxy:
183
- if isinstance(config, dict):
184
- config = MCPConfig.from_dict(config)
185
- clients = config.to_clients()
186
- return cls(client=clients[list(clients.keys())[0]])
187
-
188
  async def get_tools(self) -> dict[str, Tool]:
189
  tools = await super().get_tools()
190
 
 
25
  from fastmcp.server.server import FastMCP
26
  from fastmcp.tools.tool import Tool
27
  from fastmcp.utilities.logging import get_logger
 
28
 
29
  if TYPE_CHECKING:
30
  from fastmcp.server import Context
 
177
  super().__init__(**kwargs)
178
  self.client = client
179
 
 
 
 
 
 
 
 
180
  async def get_tools(self) -> dict[str, Tool]:
181
  tools = await super().get_tools()
182
 
src/fastmcp/server/server.py CHANGED
@@ -58,6 +58,7 @@ from fastmcp.tools.tool import Tool
58
  from fastmcp.utilities.cache import TimedCache
59
  from fastmcp.utilities.decorators import DecoratedFunction
60
  from fastmcp.utilities.logging import get_logger
 
61
 
62
  if TYPE_CHECKING:
63
  from fastmcp.client import Client
@@ -1203,6 +1204,7 @@ class FastMCP(Generic[LifespanResultT]):
1203
  | FastMCP[Any]
1204
  | AnyUrl
1205
  | Path
 
1206
  | dict[str, Any]
1207
  | str,
1208
  **settings: Any,
 
58
  from fastmcp.utilities.cache import TimedCache
59
  from fastmcp.utilities.decorators import DecoratedFunction
60
  from fastmcp.utilities.logging import get_logger
61
+ from fastmcp.utilities.mcp_config import MCPConfig
62
 
63
  if TYPE_CHECKING:
64
  from fastmcp.client import Client
 
1204
  | FastMCP[Any]
1205
  | AnyUrl
1206
  | Path
1207
+ | MCPConfig
1208
  | dict[str, Any]
1209
  | str,
1210
  **settings: Any,
src/fastmcp/utilities/exceptions.py CHANGED
@@ -18,6 +18,7 @@ def iter_exc(group: BaseExceptionGroup):
18
 
19
 
20
  def _exception_handler(group: BaseExceptionGroup):
 
21
  for leaf in iter_exc(group):
22
  if isinstance(leaf, httpx.ConnectTimeout):
23
  raise McpError(
 
18
 
19
 
20
  def _exception_handler(group: BaseExceptionGroup):
21
+ print(list(iter_exc(group)))
22
  for leaf in iter_exc(group):
23
  if isinstance(leaf, httpx.ConnectTimeout):
24
  raise McpError(
src/fastmcp/utilities/mcp_config.py CHANGED
@@ -6,7 +6,6 @@ from urllib.parse import urlparse
6
  from pydantic import AnyUrl, BaseModel, Field
7
 
8
  if TYPE_CHECKING:
9
- from fastmcp.client.client import Client
10
  from fastmcp.client.transports import (
11
  SSETransport,
12
  StdioTransport,
@@ -75,16 +74,3 @@ class MCPConfig(BaseModel):
75
  @classmethod
76
  def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
77
  return cls(mcpServers=config.get("mcpServers", config))
78
-
79
- def to_transports(
80
- self,
81
- ) -> dict[str, StdioTransport | StreamableHttpTransport | SSETransport]:
82
- return {name: server.to_transport() for name, server in self.mcpServers.items()}
83
-
84
- def to_clients(self) -> dict[str, Client]:
85
- from fastmcp.client.client import Client
86
-
87
- return {
88
- name: Client(transport=transport)
89
- for name, transport in self.to_transports().items()
90
- }
 
6
  from pydantic import AnyUrl, BaseModel, Field
7
 
8
  if TYPE_CHECKING:
 
9
  from fastmcp.client.transports import (
10
  SSETransport,
11
  StdioTransport,
 
74
  @classmethod
75
  def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
76
  return cls(mcpServers=config.get("mcpServers", config))
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/client/test_client.py CHANGED
@@ -9,6 +9,7 @@ from pydantic import AnyUrl
9
  from fastmcp.client import Client
10
  from fastmcp.client.transports import (
11
  FastMCPTransport,
 
12
  SSETransport,
13
  StdioTransport,
14
  StreamableHttpTransport,
@@ -652,9 +653,10 @@ class TestInferTransport:
652
  }
653
  }
654
  transport = infer_transport(config)
655
- assert isinstance(transport, SSETransport)
656
- assert transport.url == "http://localhost:8000/sse"
657
- assert transport.headers == {"Authorization": "Bearer 123"}
 
658
 
659
  def test_infer_local_transport_from_config(self):
660
  config = {
@@ -666,6 +668,25 @@ class TestInferTransport:
666
  }
667
  }
668
  transport = infer_transport(config)
669
- assert isinstance(transport, StdioTransport)
670
- assert transport.command == "echo"
671
- assert transport.args == ["hello"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from fastmcp.client import Client
10
  from fastmcp.client.transports import (
11
  FastMCPTransport,
12
+ MCPConfigTransport,
13
  SSETransport,
14
  StdioTransport,
15
  StreamableHttpTransport,
 
653
  }
654
  }
655
  transport = infer_transport(config)
656
+ assert isinstance(transport, MCPConfigTransport)
657
+ assert isinstance(transport.transport, SSETransport)
658
+ assert transport.transport.url == "http://localhost:8000/sse"
659
+ assert transport.transport.headers == {"Authorization": "Bearer 123"}
660
 
661
  def test_infer_local_transport_from_config(self):
662
  config = {
 
668
  }
669
  }
670
  transport = infer_transport(config)
671
+ assert isinstance(transport, MCPConfigTransport)
672
+ assert isinstance(transport.transport, StdioTransport)
673
+ assert transport.transport.command == "echo"
674
+ assert transport.transport.args == ["hello"]
675
+
676
+ def test_infer_composite_client(config):
677
+ config = {
678
+ "mcpServers": {
679
+ "local": {
680
+ "command": "echo",
681
+ "args": ["hello"],
682
+ },
683
+ "remote": {
684
+ "url": "http://localhost:8000/sse",
685
+ "headers": {"Authorization": "Bearer 123"},
686
+ },
687
+ }
688
+ }
689
+ transport = infer_transport(config)
690
+ assert isinstance(transport, MCPConfigTransport)
691
+ assert isinstance(transport.transport, FastMCPTransport)
692
+ assert len(transport.transport.server._mounted_servers) == 2
tests/utilities/test_mcp_config.py CHANGED
@@ -1,3 +1,9 @@
 
 
 
 
 
 
1
  from fastmcp.client.transports import (
2
  SSETransport,
3
  StdioTransport,
@@ -90,3 +96,47 @@ def test_parse_multiple_servers():
90
  assert mcp_config.mcpServers["test_server_2"].command == "echo"
91
  assert mcp_config.mcpServers["test_server_2"].args == ["hello"]
92
  assert mcp_config.mcpServers["test_server_2"].env == {"TEST": "test"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import inspect
2
+ from pathlib import Path
3
+
4
+ from mcp.types import TextContent
5
+
6
+ from fastmcp.client.client import Client
7
  from fastmcp.client.transports import (
8
  SSETransport,
9
  StdioTransport,
 
96
  assert mcp_config.mcpServers["test_server_2"].command == "echo"
97
  assert mcp_config.mcpServers["test_server_2"].args == ["hello"]
98
  assert mcp_config.mcpServers["test_server_2"].env == {"TEST": "test"}
99
+
100
+
101
+ async def test_multi_client(tmp_path: Path):
102
+ server_script = inspect.cleandoc("""
103
+ from fastmcp import FastMCP
104
+
105
+ mcp = FastMCP()
106
+
107
+ @mcp.tool()
108
+ def add(a: int, b: int) -> int:
109
+ return a + b
110
+
111
+ if __name__ == '__main__':
112
+ mcp.run()
113
+ """)
114
+
115
+ script_path = tmp_path / "test.py"
116
+ script_path.write_text(server_script)
117
+
118
+ config = {
119
+ "mcpServers": {
120
+ "test_1": {
121
+ "command": "python",
122
+ "args": [str(script_path)],
123
+ },
124
+ "test_2": {
125
+ "command": "python",
126
+ "args": [str(script_path)],
127
+ },
128
+ }
129
+ }
130
+
131
+ client = Client(config)
132
+
133
+ async with client:
134
+ tools = await client.list_tools()
135
+ assert len(tools) == 2
136
+
137
+ result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
138
+ result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
139
+ assert isinstance(result_1[0], TextContent)
140
+ assert result_1[0].text == "3"
141
+ assert isinstance(result_2[0], TextContent)
142
+ assert result_2[0].text == "3"