Merge branch 'main' into custom-tags

This commit is contained in:
Jeremiah Lowin 2025-05-23 08:27:41 -04:00 committed by GitHub
commit ef8bb6f631
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 129 additions and 19 deletions

View file

@ -91,7 +91,7 @@ For more control over connection details (like headers for SSE, environment vari
### Multi-Server Clients
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
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.

View file

@ -323,7 +323,7 @@ Communication happens through efficient in-memory queues, making it very fast an
### MCPConfig Transport
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
- **Class:** `fastmcp.client.transports.MCPConfigTransport`
- **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema

View file

@ -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.
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
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.

View file

@ -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.

View file

@ -106,7 +106,7 @@ proxy = FastMCP.as_proxy(
### Configuration-Based Proxies
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
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.

View file

@ -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,

View file

@ -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

View file

@ -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."
)

View file

@ -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.",

View file

@ -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:

View file

@ -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": {

View file

@ -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)

View file

@ -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
)