Jeremiah Lowin commited on
Commit
f4a3ff2
·
2 Parent(s): c997d33f4d1162

Merge branch 'main' into custom-routes

Browse files
docs/clients/client.mdx CHANGED
@@ -91,7 +91,7 @@ For more control over connection details (like headers for SSE, environment vari
91
 
92
  ### Multi-Server Clients
93
 
94
- <VersionBadge version="2.3.6" />
95
 
96
  FastMCP supports creating clients that connect to multiple MCP servers through a single client interface using a standard MCP configuration format (`MCPConfig`). This configuration approach makes it easy to connect to multiple specialized servers or create composable systems with a simple, declarative syntax.
97
 
 
91
 
92
  ### Multi-Server Clients
93
 
94
+ <VersionBadge version="2.4.0" />
95
 
96
  FastMCP supports creating clients that connect to multiple MCP servers through a single client interface using a standard MCP configuration format (`MCPConfig`). This configuration approach makes it easy to connect to multiple specialized servers or create composable systems with a simple, declarative syntax.
97
 
docs/clients/transports.mdx CHANGED
@@ -323,7 +323,7 @@ Communication happens through efficient in-memory queues, making it very fast an
323
 
324
  ### MCPConfig Transport
325
 
326
- <VersionBadge version="2.3.6" />
327
 
328
  - **Class:** `fastmcp.client.transports.MCPConfigTransport`
329
  - **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema
 
323
 
324
  ### MCPConfig Transport
325
 
326
+ <VersionBadge version="2.4.0" />
327
 
328
  - **Class:** `fastmcp.client.transports.MCPConfigTransport`
329
  - **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema
docs/servers/composition.mdx CHANGED
@@ -35,7 +35,7 @@ The choice of importing or mounting depends on your use case and requirements.
35
 
36
  FastMCP supports [MCP proxying](/patterns/proxy), which allows you to mirror a local or remote server in a local FastMCP instance. Proxies are fully compatible with both importing and mounting.
37
 
38
- <VersionBadge version="2.3.6" />
39
 
40
  You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time.
41
 
 
35
 
36
  FastMCP supports [MCP proxying](/patterns/proxy), which allows you to mirror a local or remote server in a local FastMCP instance. Proxies are fully compatible with both importing and mounting.
37
 
38
+ <VersionBadge version="2.4.0" />
39
 
40
  You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time.
41
 
docs/servers/context.mdx CHANGED
@@ -228,8 +228,8 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
228
  # Create a sampling prompt asking for sentiment analysis
229
  prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}"
230
 
231
- # Send the sampling request to the client's LLM
232
- response = await ctx.sample(prompt)
233
 
234
  # Process the LLM's response
235
  sentiment = response.text.strip().lower()
@@ -247,11 +247,12 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
247
 
248
  **Method signature:**
249
 
250
- - **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None) -> TextContent | ImageContent`**
251
  - `messages`: A string or list of strings/message objects to send to the LLM
252
  - `system_prompt`: Optional system prompt to guide the LLM's behavior
253
  - `temperature`: Optional sampling temperature (controls randomness)
254
  - `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
 
255
  - Returns the LLM's response as TextContent or ImageContent
256
 
257
  When providing a simple string, it's treated as a user message. For more complex scenarios, you can provide a list of messages with different roles.
 
228
  # Create a sampling prompt asking for sentiment analysis
229
  prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}"
230
 
231
+ # Send the sampling request to the client's LLM (provide a hint for the model you want to use)
232
+ response = await ctx.sample(prompt, model_preferences="claude-3-sonnet")
233
 
234
  # Process the LLM's response
235
  sentiment = response.text.strip().lower()
 
247
 
248
  **Method signature:**
249
 
250
+ - **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent`**
251
  - `messages`: A string or list of strings/message objects to send to the LLM
252
  - `system_prompt`: Optional system prompt to guide the LLM's behavior
253
  - `temperature`: Optional sampling temperature (controls randomness)
254
  - `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
255
+ - `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object)
256
  - Returns the LLM's response as TextContent or ImageContent
257
 
258
  When providing a simple string, it's treated as a user message. For more complex scenarios, you can provide a list of messages with different roles.
docs/servers/proxy.mdx CHANGED
@@ -106,7 +106,7 @@ proxy = FastMCP.as_proxy(
106
 
107
  ### Configuration-Based Proxies
108
 
109
- <VersionBadge version="2.3.6" />
110
 
111
  You can create a proxy directly from a configuration dictionary that follows the MCPConfig schema. This is useful for quickly setting up proxies to remote servers without manually configuring each connection detail.
112
 
 
106
 
107
  ### Configuration-Based Proxies
108
 
109
+ <VersionBadge version="2.4.0" />
110
 
111
  You can create a proxy directly from a configuration dictionary that follows the MCPConfig schema. This is useful for quickly setting up proxies to remote servers without manually configuring each connection detail.
112
 
src/fastmcp/client/client.py CHANGED
@@ -210,6 +210,23 @@ class Client:
210
  result = await self.session.send_ping()
211
  return isinstance(result, mcp.types.EmptyResult)
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  async def progress(
214
  self,
215
  progress_token: str | int,
 
210
  result = await self.session.send_ping()
211
  return isinstance(result, mcp.types.EmptyResult)
212
 
213
+ async def cancel(
214
+ self,
215
+ request_id: str | int,
216
+ reason: str | None = None,
217
+ ) -> None:
218
+ """Send a cancellation notification for an in-progress request."""
219
+ notification = mcp.types.ClientNotification(
220
+ mcp.types.CancelledNotification(
221
+ method="notifications/cancelled",
222
+ params=mcp.types.CancelledNotificationParams(
223
+ requestId=request_id,
224
+ reason=reason,
225
+ ),
226
+ )
227
+ )
228
+ await self.session.send_notification(notification)
229
+
230
  async def progress(
231
  self,
232
  progress_token: str | int,
src/fastmcp/client/transports.py CHANGED
@@ -535,8 +535,12 @@ class MCPConfigTransport(ClientTransport):
535
  config = MCPConfig.from_dict(config)
536
  self.config = config
537
 
 
 
 
 
538
  # if there's exactly one server, create a client for that server
539
- if len(self.config.mcpServers) == 1:
540
  self.transport = list(self.config.mcpServers.values())[0].to_transport()
541
 
542
  # otherwise create a composite client
 
535
  config = MCPConfig.from_dict(config)
536
  self.config = config
537
 
538
+ # if there are no servers, raise an error
539
+ if len(self.config.mcpServers) == 0:
540
+ raise ValueError("No MCP servers defined in the config")
541
+
542
  # if there's exactly one server, create a client for that server
543
+ elif len(self.config.mcpServers) == 1:
544
  self.transport = list(self.config.mcpServers.values())[0].to_transport()
545
 
546
  # otherwise create a composite client
src/fastmcp/server/context.py CHANGED
@@ -12,6 +12,8 @@ from mcp.shared.context import RequestContext
12
  from mcp.types import (
13
  CreateMessageResult,
14
  ImageContent,
 
 
15
  Root,
16
  SamplingMessage,
17
  TextContent,
@@ -200,6 +202,7 @@ class Context:
200
  system_prompt: str | None = None,
201
  temperature: float | None = None,
202
  max_tokens: int | None = None,
 
203
  ) -> TextContent | ImageContent:
204
  """
205
  Send a sampling request to the client and await the response.
@@ -231,6 +234,7 @@ class Context:
231
  system_prompt=system_prompt,
232
  temperature=temperature,
233
  max_tokens=max_tokens,
 
234
  )
235
 
236
  return result.content
@@ -248,3 +252,45 @@ class Context:
248
  )
249
 
250
  return fastmcp.server.dependencies.get_http_request()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from mcp.types import (
13
  CreateMessageResult,
14
  ImageContent,
15
+ ModelHint,
16
+ ModelPreferences,
17
  Root,
18
  SamplingMessage,
19
  TextContent,
 
202
  system_prompt: str | None = None,
203
  temperature: float | None = None,
204
  max_tokens: int | None = None,
205
+ model_preferences: ModelPreferences | str | list[str] | None = None,
206
  ) -> TextContent | ImageContent:
207
  """
208
  Send a sampling request to the client and await the response.
 
234
  system_prompt=system_prompt,
235
  temperature=temperature,
236
  max_tokens=max_tokens,
237
+ model_preferences=self._parse_model_preferences(model_preferences),
238
  )
239
 
240
  return result.content
 
252
  )
253
 
254
  return fastmcp.server.dependencies.get_http_request()
255
+
256
+ def _parse_model_preferences(
257
+ self, model_preferences: ModelPreferences | str | list[str] | None
258
+ ) -> ModelPreferences | None:
259
+ """
260
+ Validates and converts user input for model_preferences into a ModelPreferences object.
261
+
262
+ Args:
263
+ model_preferences (ModelPreferences | str | list[str] | None):
264
+ The model preferences to use. Accepts:
265
+ - ModelPreferences (returns as-is)
266
+ - str (single model hint)
267
+ - list[str] (multiple model hints)
268
+ - None (no preferences)
269
+
270
+ Returns:
271
+ ModelPreferences | None: The parsed ModelPreferences object, or None if not provided.
272
+
273
+ Raises:
274
+ ValueError: If the input is not a supported type or contains invalid values.
275
+ """
276
+ if model_preferences is None:
277
+ return None
278
+ elif isinstance(model_preferences, ModelPreferences):
279
+ return model_preferences
280
+ elif isinstance(model_preferences, str):
281
+ # Single model hint
282
+ return ModelPreferences(hints=[ModelHint(name=model_preferences)])
283
+ elif isinstance(model_preferences, list):
284
+ # List of model hints (strings)
285
+ if not all(isinstance(h, str) for h in model_preferences):
286
+ raise ValueError(
287
+ "All elements of model_preferences list must be"
288
+ " strings (model name hints)."
289
+ )
290
+ return ModelPreferences(
291
+ hints=[ModelHint(name=h) for h in model_preferences]
292
+ )
293
+ else:
294
+ raise ValueError(
295
+ "model_preferences must be one of: ModelPreferences, str, list[str], or None."
296
+ )
src/fastmcp/server/server.py CHANGED
@@ -998,7 +998,7 @@ class FastMCP(Generic[LifespanResultT]):
998
  from fastmcp.server.proxy import FastMCPProxy
999
 
1000
  if tool_separator is not None:
1001
- # Deprecated since 2.3.6
1002
  warnings.warn(
1003
  "The tool_separator parameter is deprecated and will be removed in a future version. "
1004
  "Tools are now prefixed using 'prefix_toolname' format.",
@@ -1007,7 +1007,7 @@ class FastMCP(Generic[LifespanResultT]):
1007
  )
1008
 
1009
  if resource_separator is not None:
1010
- # Deprecated since 2.3.6
1011
  warnings.warn(
1012
  "The resource_separator parameter is deprecated and ignored. "
1013
  "Resource prefixes are now added using the protocol://prefix/path format.",
@@ -1016,7 +1016,7 @@ class FastMCP(Generic[LifespanResultT]):
1016
  )
1017
 
1018
  if prompt_separator is not None:
1019
- # Deprecated since 2.3.6
1020
  warnings.warn(
1021
  "The prompt_separator parameter is deprecated and will be removed in a future version. "
1022
  "Prompts are now prefixed using 'prefix_promptname' format.",
@@ -1083,7 +1083,7 @@ class FastMCP(Generic[LifespanResultT]):
1083
  prompt_separator: Deprecated. Separator for prompt names.
1084
  """
1085
  if tool_separator is not None:
1086
- # Deprecated since 2.3.6
1087
  warnings.warn(
1088
  "The tool_separator parameter is deprecated and will be removed in a future version. "
1089
  "Tools are now prefixed using 'prefix_toolname' format.",
@@ -1092,7 +1092,7 @@ class FastMCP(Generic[LifespanResultT]):
1092
  )
1093
 
1094
  if resource_separator is not None:
1095
- # Deprecated since 2.3.6
1096
  warnings.warn(
1097
  "The resource_separator parameter is deprecated and ignored. "
1098
  "Resource prefixes are now added using the protocol://prefix/path format.",
@@ -1101,7 +1101,7 @@ class FastMCP(Generic[LifespanResultT]):
1101
  )
1102
 
1103
  if prompt_separator is not None:
1104
- # Deprecated since 2.3.6
1105
  warnings.warn(
1106
  "The prompt_separator parameter is deprecated and will be removed in a future version. "
1107
  "Prompts are now prefixed using 'prefix_promptname' format.",
 
998
  from fastmcp.server.proxy import FastMCPProxy
999
 
1000
  if tool_separator is not None:
1001
+ # Deprecated since 2.4.0
1002
  warnings.warn(
1003
  "The tool_separator parameter is deprecated and will be removed in a future version. "
1004
  "Tools are now prefixed using 'prefix_toolname' format.",
 
1007
  )
1008
 
1009
  if resource_separator is not None:
1010
+ # Deprecated since 2.4.0
1011
  warnings.warn(
1012
  "The resource_separator parameter is deprecated and ignored. "
1013
  "Resource prefixes are now added using the protocol://prefix/path format.",
 
1016
  )
1017
 
1018
  if prompt_separator is not None:
1019
+ # Deprecated since 2.4.0
1020
  warnings.warn(
1021
  "The prompt_separator parameter is deprecated and will be removed in a future version. "
1022
  "Prompts are now prefixed using 'prefix_promptname' format.",
 
1083
  prompt_separator: Deprecated. Separator for prompt names.
1084
  """
1085
  if tool_separator is not None:
1086
+ # Deprecated since 2.4.0
1087
  warnings.warn(
1088
  "The tool_separator parameter is deprecated and will be removed in a future version. "
1089
  "Tools are now prefixed using 'prefix_toolname' format.",
 
1092
  )
1093
 
1094
  if resource_separator is not None:
1095
+ # Deprecated since 2.4.0
1096
  warnings.warn(
1097
  "The resource_separator parameter is deprecated and ignored. "
1098
  "Resource prefixes are now added using the protocol://prefix/path format.",
 
1101
  )
1102
 
1103
  if prompt_separator is not None:
1104
+ # Deprecated since 2.4.0
1105
  warnings.warn(
1106
  "The prompt_separator parameter is deprecated and will be removed in a future version. "
1107
  "Prompts are now prefixed using 'prefix_promptname' format.",
src/fastmcp/utilities/mcp_config.py CHANGED
@@ -32,11 +32,12 @@ def infer_transport_type_from_url(
32
  return "streamable-http"
33
 
34
 
35
- class LocalMCPServer(BaseModel):
36
  command: str
37
  args: list[str] = Field(default_factory=list)
38
  env: dict[str, Any] = Field(default_factory=dict)
39
  cwd: str | None = None
 
40
 
41
  def to_transport(self) -> StdioTransport:
42
  from fastmcp.client.transports import StdioTransport
@@ -51,8 +52,8 @@ class LocalMCPServer(BaseModel):
51
 
52
  class RemoteMCPServer(BaseModel):
53
  url: str
54
- transport: Literal["streamable-http", "sse", "http"] | None = None
55
  headers: dict[str, str] = Field(default_factory=dict)
 
56
 
57
  def to_transport(self) -> StreamableHttpTransport | SSETransport:
58
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
@@ -69,7 +70,7 @@ class RemoteMCPServer(BaseModel):
69
 
70
 
71
  class MCPConfig(BaseModel):
72
- mcpServers: dict[str, LocalMCPServer | RemoteMCPServer]
73
 
74
  @classmethod
75
  def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
 
32
  return "streamable-http"
33
 
34
 
35
+ class StdioMCPServer(BaseModel):
36
  command: str
37
  args: list[str] = Field(default_factory=list)
38
  env: dict[str, Any] = Field(default_factory=dict)
39
  cwd: str | None = None
40
+ transport: Literal["stdio"] = "stdio"
41
 
42
  def to_transport(self) -> StdioTransport:
43
  from fastmcp.client.transports import StdioTransport
 
52
 
53
  class RemoteMCPServer(BaseModel):
54
  url: str
 
55
  headers: dict[str, str] = Field(default_factory=dict)
56
+ transport: Literal["streamable-http", "sse", "http"] | None = None
57
 
58
  def to_transport(self) -> StreamableHttpTransport | SSETransport:
59
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
 
70
 
71
 
72
  class MCPConfig(BaseModel):
73
+ mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
74
 
75
  @classmethod
76
  def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
tests/client/test_client.py CHANGED
@@ -673,6 +673,18 @@ class TestInferTransport:
673
  assert transport.transport.command == "echo"
674
  assert transport.transport.args == ["hello"]
675
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  def test_infer_composite_client(self):
677
  config = {
678
  "mcpServers": {
 
673
  assert transport.transport.command == "echo"
674
  assert transport.transport.args == ["hello"]
675
 
676
+ def test_config_with_no_servers(self):
677
+ """Test that an empty MCPConfig raises a ValueError."""
678
+ config = {"mcpServers": {}}
679
+ with pytest.raises(ValueError, match="No MCP servers defined in the config"):
680
+ infer_transport(config)
681
+
682
+ def test_mcpconfigtransport_with_no_servers(self):
683
+ """Test that MCPConfigTransport raises a ValueError when initialized with an empty config."""
684
+ config = {"mcpServers": {}}
685
+ with pytest.raises(ValueError, match="No MCP servers defined in the config"):
686
+ MCPConfigTransport(config=config)
687
+
688
  def test_infer_composite_client(self):
689
  config = {
690
  "mcpServers": {
tests/server/test_context.py CHANGED
@@ -2,9 +2,11 @@ import warnings
2
  from unittest.mock import MagicMock, patch
3
 
4
  import pytest
 
5
  from starlette.requests import Request
6
 
7
  from fastmcp.server.context import Context
 
8
 
9
 
10
  class TestContextDeprecations:
@@ -57,3 +59,30 @@ class TestContextDeprecations:
57
  assert "https://gofastmcp.com/patterns/http-requests" in str(
58
  warning.message
59
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from unittest.mock import MagicMock, patch
3
 
4
  import pytest
5
+ from mcp.types import ModelPreferences
6
  from starlette.requests import Request
7
 
8
  from fastmcp.server.context import Context
9
+ from fastmcp.server.server import FastMCP
10
 
11
 
12
  class TestContextDeprecations:
 
59
  assert "https://gofastmcp.com/patterns/http-requests" in str(
60
  warning.message
61
  )
62
+
63
+
64
+ @pytest.fixture
65
+ def context():
66
+ return Context(fastmcp=FastMCP())
67
+
68
+
69
+ class TestParseModelPreferences:
70
+ def test_parse_model_preferences_string(self, context):
71
+ mp = context._parse_model_preferences("claude-3-sonnet")
72
+ assert isinstance(mp, ModelPreferences)
73
+ assert mp.hints is not None
74
+ assert mp.hints[0].name == "claude-3-sonnet"
75
+
76
+ def test_parse_model_preferences_list(self, context):
77
+ mp = context._parse_model_preferences(["claude-3-sonnet", "claude"])
78
+ assert isinstance(mp, ModelPreferences)
79
+ assert mp.hints is not None
80
+ assert [h.name for h in mp.hints] == ["claude-3-sonnet", "claude"]
81
+
82
+ def test_parse_model_preferences_object(self, context):
83
+ obj = ModelPreferences(hints=[])
84
+ assert context._parse_model_preferences(obj) is obj
85
+
86
+ def test_parse_model_preferences_invalid_type(self, context):
87
+ with pytest.raises(ValueError):
88
+ context._parse_model_preferences(123)
tests/utilities/test_mcp_config.py CHANGED
@@ -9,7 +9,7 @@ from fastmcp.client.transports import (
9
  StdioTransport,
10
  StreamableHttpTransport,
11
  )
12
- from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer
13
 
14
 
15
  def test_parse_single_stdio_config():
@@ -89,7 +89,7 @@ def test_parse_multiple_servers():
89
  assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
90
  assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
91
 
92
- assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer)
93
  assert isinstance(
94
  mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
95
  )
 
9
  StdioTransport,
10
  StreamableHttpTransport,
11
  )
12
+ from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer
13
 
14
 
15
  def test_parse_single_stdio_config():
 
89
  assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
90
  assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
91
 
92
+ assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer)
93
  assert isinstance(
94
  mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
95
  )