diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 70c47bfa9..c53ecee03 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -91,7 +91,7 @@ For more control over connection details (like headers for SSE, environment vari ### Multi-Server Clients - + 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. diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index b89f46884..3669c8b25 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -323,7 +323,7 @@ Communication happens through efficient in-memory queues, making it very fast an ### MCPConfig Transport - + - **Class:** `fastmcp.client.transports.MCPConfigTransport` - **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index dc584bced..73b40affc 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -35,7 +35,7 @@ The choice of importing or mounting depends on your use case and requirements. 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. - + 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. diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 3a84286e8..555a0c105 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -228,8 +228,8 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict: # Create a sampling prompt asking for sentiment analysis 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}" - # Send the sampling request to the client's LLM - response = await ctx.sample(prompt) + # Send the sampling request to the client's LLM (provide a hint for the model you want to use) + response = await ctx.sample(prompt, model_preferences="claude-3-sonnet") # Process the LLM's response sentiment = response.text.strip().lower() @@ -247,11 +247,12 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict: **Method signature:** -- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None) -> TextContent | ImageContent`** +- **`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`** - `messages`: A string or list of strings/message objects to send to the LLM - `system_prompt`: Optional system prompt to guide the LLM's behavior - `temperature`: Optional sampling temperature (controls randomness) - `max_tokens`: Optional maximum number of tokens to generate (defaults to 512) + - `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object) - Returns the LLM's response as TextContent or ImageContent 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. diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index ef2899cd2..d78d6d694 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -106,7 +106,7 @@ proxy = FastMCP.as_proxy( ### Configuration-Based Proxies - + 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. diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 19552ea35..f6333fb08 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -210,6 +210,23 @@ class Client: result = await self.session.send_ping() return isinstance(result, mcp.types.EmptyResult) + async def cancel( + self, + request_id: str | int, + reason: str | None = None, + ) -> None: + """Send a cancellation notification for an in-progress request.""" + notification = mcp.types.ClientNotification( + mcp.types.CancelledNotification( + method="notifications/cancelled", + params=mcp.types.CancelledNotificationParams( + requestId=request_id, + reason=reason, + ), + ) + ) + await self.session.send_notification(notification) + async def progress( self, progress_token: str | int, diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7aacd5ad6..f82b238d0 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -535,8 +535,12 @@ class MCPConfigTransport(ClientTransport): config = MCPConfig.from_dict(config) self.config = config + # if there are no servers, raise an error + if len(self.config.mcpServers) == 0: + raise ValueError("No MCP servers defined in the config") + # if there's exactly one server, create a client for that server - if len(self.config.mcpServers) == 1: + elif len(self.config.mcpServers) == 1: self.transport = list(self.config.mcpServers.values())[0].to_transport() # otherwise create a composite client diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 4ecd992a7..7dc77f4d1 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -12,6 +12,8 @@ from mcp.shared.context import RequestContext from mcp.types import ( CreateMessageResult, ImageContent, + ModelHint, + ModelPreferences, Root, SamplingMessage, TextContent, @@ -200,6 +202,7 @@ class Context: system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, ) -> TextContent | ImageContent: """ Send a sampling request to the client and await the response. @@ -231,6 +234,7 @@ class Context: system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens, + model_preferences=self._parse_model_preferences(model_preferences), ) return result.content @@ -248,3 +252,45 @@ class Context: ) return fastmcp.server.dependencies.get_http_request() + + def _parse_model_preferences( + self, model_preferences: ModelPreferences | str | list[str] | None + ) -> ModelPreferences | None: + """ + Validates and converts user input for model_preferences into a ModelPreferences object. + + Args: + model_preferences (ModelPreferences | str | list[str] | None): + The model preferences to use. Accepts: + - ModelPreferences (returns as-is) + - str (single model hint) + - list[str] (multiple model hints) + - None (no preferences) + + Returns: + ModelPreferences | None: The parsed ModelPreferences object, or None if not provided. + + Raises: + ValueError: If the input is not a supported type or contains invalid values. + """ + if model_preferences is None: + return None + elif isinstance(model_preferences, ModelPreferences): + return model_preferences + elif isinstance(model_preferences, str): + # Single model hint + return ModelPreferences(hints=[ModelHint(name=model_preferences)]) + elif isinstance(model_preferences, list): + # List of model hints (strings) + if not all(isinstance(h, str) for h in model_preferences): + raise ValueError( + "All elements of model_preferences list must be" + " strings (model name hints)." + ) + return ModelPreferences( + hints=[ModelHint(name=h) for h in model_preferences] + ) + else: + raise ValueError( + "model_preferences must be one of: ModelPreferences, str, list[str], or None." + ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index e9ab55b5a..111d3eaa3 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -998,7 +998,7 @@ class FastMCP(Generic[LifespanResultT]): from fastmcp.server.proxy import FastMCPProxy if tool_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The tool_separator parameter is deprecated and will be removed in a future version. " "Tools are now prefixed using 'prefix_toolname' format.", @@ -1007,7 +1007,7 @@ class FastMCP(Generic[LifespanResultT]): ) if resource_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The resource_separator parameter is deprecated and ignored. " "Resource prefixes are now added using the protocol://prefix/path format.", @@ -1016,7 +1016,7 @@ class FastMCP(Generic[LifespanResultT]): ) if prompt_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The prompt_separator parameter is deprecated and will be removed in a future version. " "Prompts are now prefixed using 'prefix_promptname' format.", @@ -1083,7 +1083,7 @@ class FastMCP(Generic[LifespanResultT]): prompt_separator: Deprecated. Separator for prompt names. """ if tool_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The tool_separator parameter is deprecated and will be removed in a future version. " "Tools are now prefixed using 'prefix_toolname' format.", @@ -1092,7 +1092,7 @@ class FastMCP(Generic[LifespanResultT]): ) if resource_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The resource_separator parameter is deprecated and ignored. " "Resource prefixes are now added using the protocol://prefix/path format.", @@ -1101,7 +1101,7 @@ class FastMCP(Generic[LifespanResultT]): ) if prompt_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The prompt_separator parameter is deprecated and will be removed in a future version. " "Prompts are now prefixed using 'prefix_promptname' format.", diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py index 19905f701..a9f5abee6 100644 --- a/src/fastmcp/utilities/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -32,11 +32,12 @@ def infer_transport_type_from_url( return "streamable-http" -class LocalMCPServer(BaseModel): +class StdioMCPServer(BaseModel): command: str args: list[str] = Field(default_factory=list) env: dict[str, Any] = Field(default_factory=dict) cwd: str | None = None + transport: Literal["stdio"] = "stdio" def to_transport(self) -> StdioTransport: from fastmcp.client.transports import StdioTransport @@ -51,8 +52,8 @@ class LocalMCPServer(BaseModel): class RemoteMCPServer(BaseModel): url: str - transport: Literal["streamable-http", "sse", "http"] | None = None headers: dict[str, str] = Field(default_factory=dict) + transport: Literal["streamable-http", "sse", "http"] | None = None def to_transport(self) -> StreamableHttpTransport | SSETransport: from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -69,7 +70,7 @@ class RemoteMCPServer(BaseModel): class MCPConfig(BaseModel): - mcpServers: dict[str, LocalMCPServer | RemoteMCPServer] + mcpServers: dict[str, StdioMCPServer | RemoteMCPServer] @classmethod def from_dict(cls, config: dict[str, Any]) -> MCPConfig: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 0e97cb8d6..71625d45e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -673,6 +673,18 @@ class TestInferTransport: assert transport.transport.command == "echo" assert transport.transport.args == ["hello"] + def test_config_with_no_servers(self): + """Test that an empty MCPConfig raises a ValueError.""" + config = {"mcpServers": {}} + with pytest.raises(ValueError, match="No MCP servers defined in the config"): + infer_transport(config) + + def test_mcpconfigtransport_with_no_servers(self): + """Test that MCPConfigTransport raises a ValueError when initialized with an empty config.""" + config = {"mcpServers": {}} + with pytest.raises(ValueError, match="No MCP servers defined in the config"): + MCPConfigTransport(config=config) + def test_infer_composite_client(self): config = { "mcpServers": { diff --git a/tests/server/test_context.py b/tests/server/test_context.py index 4243ab4f9..a41b8b6e3 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -2,9 +2,11 @@ import warnings from unittest.mock import MagicMock, patch import pytest +from mcp.types import ModelPreferences from starlette.requests import Request from fastmcp.server.context import Context +from fastmcp.server.server import FastMCP class TestContextDeprecations: @@ -57,3 +59,30 @@ class TestContextDeprecations: assert "https://gofastmcp.com/patterns/http-requests" in str( warning.message ) + + +@pytest.fixture +def context(): + return Context(fastmcp=FastMCP()) + + +class TestParseModelPreferences: + def test_parse_model_preferences_string(self, context): + mp = context._parse_model_preferences("claude-3-sonnet") + assert isinstance(mp, ModelPreferences) + assert mp.hints is not None + assert mp.hints[0].name == "claude-3-sonnet" + + def test_parse_model_preferences_list(self, context): + mp = context._parse_model_preferences(["claude-3-sonnet", "claude"]) + assert isinstance(mp, ModelPreferences) + assert mp.hints is not None + assert [h.name for h in mp.hints] == ["claude-3-sonnet", "claude"] + + def test_parse_model_preferences_object(self, context): + obj = ModelPreferences(hints=[]) + assert context._parse_model_preferences(obj) is obj + + def test_parse_model_preferences_invalid_type(self, context): + with pytest.raises(ValueError): + context._parse_model_preferences(123) diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index 627a149f1..b7737da1d 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -9,7 +9,7 @@ from fastmcp.client.transports import ( StdioTransport, StreamableHttpTransport, ) -from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer +from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer def test_parse_single_stdio_config(): @@ -89,7 +89,7 @@ def test_parse_multiple_servers(): assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer) assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport) - assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer) + assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer) assert isinstance( mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport )