Jeremiah Lowin commited on
Commit
a9cea6e
·
1 Parent(s): 12847fe

Formalize MCP Config

Browse files
src/fastmcp/client/transports.py CHANGED
@@ -6,7 +6,7 @@ import shutil
6
  import sys
7
  from collections.abc import AsyncIterator
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any, TypedDict, cast
10
  from urllib.parse import urlparse
11
 
12
  from mcp import ClientSession, StdioServerParameters
@@ -24,12 +24,12 @@ from mcp.shared.memory import create_connected_server_and_client_session
24
  from pydantic import AnyUrl
25
  from typing_extensions import Unpack
26
 
27
- from fastmcp.client.mcp_config import MCPConfig
28
  from fastmcp.server import FastMCP as FastMCPServer
29
  from fastmcp.utilities.logging import get_logger
 
30
 
31
  if TYPE_CHECKING:
32
- from fastmcp.client.mcp_config import MCPConfig
33
 
34
  logger = get_logger(__name__)
35
 
@@ -491,7 +491,7 @@ def infer_transport(
491
 
492
  For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
493
  """
494
- from fastmcp.client.mcp_config import MCPConfig
495
 
496
  # the transport is already a ClientTransport
497
  if isinstance(transport, ClientTransport):
@@ -512,13 +512,8 @@ def infer_transport(
512
 
513
  # the transport is an http(s) URL
514
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
515
- transport_str = str(transport)
516
- # Parse out just the path portion to check for /sse
517
- parsed_url = urlparse(transport_str)
518
- path = parsed_url.path
519
-
520
- # Check if path contains /sse/ or ends with /sse
521
- if "/sse/" in path or path.rstrip("/").endswith("/sse"):
522
  inferred_transport = SSETransport(url=transport)
523
  else:
524
  inferred_transport = StreamableHttpTransport(url=transport)
@@ -542,3 +537,22 @@ def infer_transport(
542
 
543
  logger.debug(f"Inferred transport: {inferred_transport}")
544
  return inferred_transport
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import sys
7
  from collections.abc import AsyncIterator
8
  from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
10
  from urllib.parse import urlparse
11
 
12
  from mcp import ClientSession, StdioServerParameters
 
24
  from pydantic import AnyUrl
25
  from typing_extensions import Unpack
26
 
 
27
  from fastmcp.server import FastMCP as FastMCPServer
28
  from fastmcp.utilities.logging import get_logger
29
+ from fastmcp.utilities.mcp_config import MCPConfig
30
 
31
  if TYPE_CHECKING:
32
+ from fastmcp.utilities.mcp_config import MCPConfig
33
 
34
  logger = get_logger(__name__)
35
 
 
491
 
492
  For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
493
  """
494
+ from fastmcp.utilities.mcp_config import MCPConfig
495
 
496
  # the transport is already a ClientTransport
497
  if isinstance(transport, ClientTransport):
 
512
 
513
  # the transport is an http(s) URL
514
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
515
+ inferred_transport_type = infer_transport_type_from_url(transport)
516
+ if inferred_transport_type == "sse":
 
 
 
 
 
517
  inferred_transport = SSETransport(url=transport)
518
  else:
519
  inferred_transport = StreamableHttpTransport(url=transport)
 
537
 
538
  logger.debug(f"Inferred transport: {inferred_transport}")
539
  return inferred_transport
540
+
541
+
542
+ def infer_transport_type_from_url(
543
+ url: str | AnyUrl,
544
+ ) -> Literal["streamable-http", "sse"]:
545
+ """
546
+ Infer the appropriate transport type from the given URL.
547
+ """
548
+ url = str(url)
549
+ if not url.startswith("http"):
550
+ raise ValueError(f"Invalid URL: {url}")
551
+
552
+ parsed_url = urlparse(url)
553
+ path = parsed_url.path
554
+
555
+ if "/sse/" in path or path.rstrip("/").endswith("/sse"):
556
+ return "sse"
557
+ else:
558
+ return "streamable-http"
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/{client → utilities}/mcp_config.py RENAMED
@@ -1,9 +1,9 @@
1
  from __future__ import annotations
2
 
3
- from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias
 
4
 
5
- from pydantic import Field
6
- from pydantic.dataclasses import dataclass
7
 
8
  if TYPE_CHECKING:
9
  from fastmcp.client.client import Client
@@ -14,10 +14,28 @@ if TYPE_CHECKING:
14
  )
15
 
16
 
17
- @dataclass
18
- class LocalMCPServer:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  command: str
20
- args: list[str]
21
  env: dict[str, Any] = Field(default_factory=dict)
22
  cwd: str | None = None
23
 
@@ -32,38 +50,36 @@ class LocalMCPServer:
32
  )
33
 
34
 
35
- @dataclass
36
- class RemoteMCPServer:
37
  url: str
38
- transport: Literal["http", "sse"] | None = None
39
  headers: dict[str, str] = Field(default_factory=dict)
40
 
41
  def to_transport(self) -> StreamableHttpTransport | SSETransport:
42
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
43
 
44
- if self.transport in {"http", None}:
45
- return StreamableHttpTransport(self.url, headers=self.headers)
46
  else:
47
- return SSETransport(self.url, headers=self.headers)
48
 
49
-
50
- MCPServer: TypeAlias = LocalMCPServer | RemoteMCPServer
 
 
51
 
52
 
53
- @dataclass
54
- class MCPConfig:
55
- mcp_servers: Annotated[dict[str, MCPServer], Field(alias="mcpServers")]
56
 
57
  @classmethod
58
  def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
59
- return cls(mcp_servers=config.get("mcpServers", config))
60
 
61
  def to_transports(
62
  self,
63
  ) -> dict[str, StdioTransport | StreamableHttpTransport | SSETransport]:
64
- return {
65
- name: server.to_transport() for name, server in self.mcp_servers.items()
66
- }
67
 
68
  def to_clients(self) -> dict[str, Client]:
69
  from fastmcp.client.client import Client
 
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
 
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
 
 
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
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"}