Jeremiah Lowin commited on
Commit
8fefd59
·
unverified ·
2 Parent(s): de426aa0cc9ac9

Merge pull request #522 from jlowin/config-dicts

Browse files
src/fastmcp/client/transports.py CHANGED
@@ -6,8 +6,7 @@ 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 (
@@ -26,6 +25,10 @@ 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
 
@@ -470,7 +473,13 @@ class FastMCPTransport(ClientTransport):
470
 
471
 
472
  def infer_transport(
473
- transport: ClientTransport | FastMCPServer | AnyUrl | Path | dict[str, Any] | str,
 
 
 
 
 
 
474
  ) -> ClientTransport:
475
  """
476
  Infer the appropriate transport type from the given transport argument.
@@ -481,6 +490,8 @@ def infer_transport(
481
 
482
  For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
483
  """
 
 
484
  # the transport is already a ClientTransport
485
  if isinstance(transport, ClientTransport):
486
  return transport
@@ -500,45 +511,24 @@ def infer_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):
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")
542
 
543
  # the transport is an unknown type
544
  else:
 
6
  import sys
7
  from collections.abc import AsyncIterator
8
  from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, TypedDict, cast
 
10
 
11
  from mcp import ClientSession, StdioServerParameters
12
  from mcp.client.session import (
 
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
+
30
+ if TYPE_CHECKING:
31
+ from fastmcp.utilities.mcp_config import MCPConfig
32
 
33
  logger = get_logger(__name__)
34
 
 
473
 
474
 
475
  def infer_transport(
476
+ transport: ClientTransport
477
+ | FastMCPServer
478
+ | AnyUrl
479
+ | Path
480
+ | MCPConfig
481
+ | dict[str, Any]
482
+ | str,
483
  ) -> ClientTransport:
484
  """
485
  Infer the appropriate transport type from the given transport argument.
 
490
 
491
  For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
492
  """
493
+ from fastmcp.utilities.mcp_config import MCPConfig
494
+
495
  # the transport is already a ClientTransport
496
  if isinstance(transport, ClientTransport):
497
  return transport
 
511
 
512
  # the transport is an http(s) URL
513
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
514
+ inferred_transport_type = infer_transport_type_from_url(transport)
515
+ if inferred_transport_type == "sse":
 
 
 
 
 
516
  inferred_transport = SSETransport(url=transport)
517
  else:
518
  inferred_transport = StreamableHttpTransport(url=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:
src/fastmcp/server/proxy.py CHANGED
@@ -25,6 +25,7 @@ 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
 
29
  if TYPE_CHECKING:
30
  from fastmcp.server import Context
@@ -177,6 +178,13 @@ class FastMCPProxy(FastMCP):
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
 
 
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
  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
 
src/fastmcp/utilities/mcp_config.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Literal
4
+ from urllib.parse import urlparse
5
+
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,
13
+ StreamableHttpTransport,
14
+ )
15
+
16
+
17
+ def infer_transport_type_from_url(
18
+ url: str | AnyUrl,
19
+ ) -> Literal["streamable-http", "sse"]:
20
+ """
21
+ Infer the appropriate transport type from the given URL.
22
+ """
23
+ url = str(url)
24
+ if not url.startswith("http"):
25
+ raise ValueError(f"Invalid URL: {url}")
26
+
27
+ parsed_url = urlparse(url)
28
+ path = parsed_url.path
29
+
30
+ if "/sse/" in path or path.rstrip("/").endswith("/sse"):
31
+ return "sse"
32
+ else:
33
+ return "streamable-http"
34
+
35
+
36
+ class LocalMCPServer(BaseModel):
37
+ command: str
38
+ args: list[str] = Field(default_factory=list)
39
+ env: dict[str, Any] = Field(default_factory=dict)
40
+ cwd: str | None = None
41
+
42
+ def to_transport(self) -> StdioTransport:
43
+ from fastmcp.client.transports import StdioTransport
44
+
45
+ return StdioTransport(
46
+ command=self.command,
47
+ args=self.args,
48
+ env=self.env,
49
+ cwd=self.cwd,
50
+ )
51
+
52
+
53
+ class RemoteMCPServer(BaseModel):
54
+ url: str
55
+ transport: Literal["streamable-http", "sse", "http"] | None = None
56
+ headers: dict[str, str] = Field(default_factory=dict)
57
+
58
+ def to_transport(self) -> StreamableHttpTransport | SSETransport:
59
+ from fastmcp.client.transports import SSETransport, StreamableHttpTransport
60
+
61
+ if self.transport is None:
62
+ transport = infer_transport_type_from_url(self.url)
63
+ else:
64
+ transport = self.transport
65
+
66
+ if transport == "sse":
67
+ return SSETransport(self.url, headers=self.headers)
68
+ else:
69
+ return StreamableHttpTransport(self.url, headers=self.headers)
70
+
71
+
72
+ class MCPConfig(BaseModel):
73
+ mcpServers: dict[str, LocalMCPServer | RemoteMCPServer]
74
+
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
+ }
tests/client/test_client.py CHANGED
@@ -10,6 +10,7 @@ from fastmcp.client import Client
10
  from fastmcp.client.transports import (
11
  FastMCPTransport,
12
  SSETransport,
 
13
  StreamableHttpTransport,
14
  infer_transport,
15
  )
@@ -640,3 +641,31 @@ class TestInferTransport:
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  from fastmcp.client.transports import (
11
  FastMCPTransport,
12
  SSETransport,
13
+ StdioTransport,
14
  StreamableHttpTransport,
15
  infer_transport,
16
  )
 
641
  def test_url_returns_streamable_http_transport(self, url):
642
  """Test that URLs without /sse/ pattern return StreamableHttpTransport."""
643
  assert isinstance(infer_transport(url), StreamableHttpTransport)
644
+
645
+ def test_infer_remote_transport_from_config(self):
646
+ config = {
647
+ "mcpServers": {
648
+ "test_server": {
649
+ "url": "http://localhost:8000/sse",
650
+ "headers": {"Authorization": "Bearer 123"},
651
+ },
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 = {
661
+ "mcpServers": {
662
+ "test_server": {
663
+ "command": "echo",
664
+ "args": ["hello"],
665
+ },
666
+ }
667
+ }
668
+ transport = infer_transport(config)
669
+ assert isinstance(transport, StdioTransport)
670
+ assert transport.command == "echo"
671
+ assert transport.args == ["hello"]
tests/utilities/test_mcp_config.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastmcp.client.transports import (
2
+ SSETransport,
3
+ StdioTransport,
4
+ StreamableHttpTransport,
5
+ )
6
+ from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer
7
+
8
+
9
+ def test_parse_single_stdio_config():
10
+ config = {
11
+ "mcpServers": {
12
+ "test_server": {
13
+ "command": "echo",
14
+ "args": ["hello"],
15
+ }
16
+ }
17
+ }
18
+ mcp_config = MCPConfig.from_dict(config)
19
+ transport = mcp_config.mcpServers["test_server"].to_transport()
20
+ assert isinstance(transport, StdioTransport)
21
+ assert transport.command == "echo"
22
+ assert transport.args == ["hello"]
23
+
24
+
25
+ def test_parse_single_remote_config():
26
+ config = {
27
+ "mcpServers": {
28
+ "test_server": {
29
+ "url": "http://localhost:8000",
30
+ }
31
+ }
32
+ }
33
+ mcp_config = MCPConfig.from_dict(config)
34
+ transport = mcp_config.mcpServers["test_server"].to_transport()
35
+ assert isinstance(transport, StreamableHttpTransport)
36
+ assert transport.url == "http://localhost:8000"
37
+
38
+
39
+ def test_parse_remote_config_with_transport():
40
+ config = {
41
+ "mcpServers": {
42
+ "test_server": {
43
+ "url": "http://localhost:8000",
44
+ "transport": "sse",
45
+ }
46
+ }
47
+ }
48
+ mcp_config = MCPConfig.from_dict(config)
49
+ transport = mcp_config.mcpServers["test_server"].to_transport()
50
+ assert isinstance(transport, SSETransport)
51
+ assert transport.url == "http://localhost:8000"
52
+
53
+
54
+ def test_parse_remote_config_with_url_inference():
55
+ config = {
56
+ "mcpServers": {
57
+ "test_server": {
58
+ "url": "http://localhost:8000/sse",
59
+ }
60
+ }
61
+ }
62
+ mcp_config = MCPConfig.from_dict(config)
63
+ transport = mcp_config.mcpServers["test_server"].to_transport()
64
+ assert isinstance(transport, SSETransport)
65
+ assert transport.url == "http://localhost:8000/sse"
66
+
67
+
68
+ def test_parse_multiple_servers():
69
+ config = {
70
+ "mcpServers": {
71
+ "test_server": {
72
+ "url": "http://localhost:8000/sse",
73
+ },
74
+ "test_server_2": {
75
+ "command": "echo",
76
+ "args": ["hello"],
77
+ "env": {"TEST": "test"},
78
+ },
79
+ }
80
+ }
81
+ mcp_config = MCPConfig.from_dict(config)
82
+ assert len(mcp_config.mcpServers) == 2
83
+ assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
84
+ assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
85
+
86
+ assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer)
87
+ assert isinstance(
88
+ mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
89
+ )
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"}