diff --git a/docs/python-sdk/fastmcp-mcp_config.mdx b/docs/python-sdk/fastmcp-mcp_config.mdx index 71046dd4ac..6ecd62b70c 100644 --- a/docs/python-sdk/fastmcp-mcp_config.mdx +++ b/docs/python-sdk/fastmcp-mcp_config.mdx @@ -42,7 +42,7 @@ infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] Infer the appropriate transport type from the given URL. -### `update_config_file` +### `update_config_file` ```python update_config_file(file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes) -> None @@ -57,7 +57,7 @@ worry about transforming server objects here. ## Classes -### `StdioMCPServer` +### `StdioMCPServer` MCP server configuration for stdio transport. @@ -67,19 +67,19 @@ This is the canonical configuration format for MCP servers using stdio transport **Methods:** -#### `to_transport` +#### `to_transport` ```python to_transport(self) -> StdioTransport ``` -### `TransformingStdioMCPServer` +### `TransformingStdioMCPServer` A Stdio server with tool transforms. -### `RemoteMCPServer` +### `RemoteMCPServer` MCP server configuration for HTTP/SSE transport. @@ -89,19 +89,19 @@ This is the canonical configuration format for MCP servers using remote transpor **Methods:** -#### `to_transport` +#### `to_transport` ```python to_transport(self) -> StreamableHttpTransport | SSETransport ``` -### `TransformingRemoteMCPServer` +### `TransformingRemoteMCPServer` A Remote server with tool transforms. -### `MCPConfig` +### `MCPConfig` A configuration object for MCP Servers that conforms to the canonical MCP configuration format @@ -113,7 +113,7 @@ For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class. **Methods:** -#### `wrap_servers_at_root` +#### `wrap_servers_at_root` ```python wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] @@ -122,7 +122,7 @@ wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] If there's no mcpServers key but there are server configs at root, wrap them. -#### `add_server` +#### `add_server` ```python add_server(self, name: str, server: MCPServerTypes) -> None @@ -131,7 +131,7 @@ add_server(self, name: str, server: MCPServerTypes) -> None Add or update a server in the configuration. -#### `from_dict` +#### `from_dict` ```python from_dict(cls, config: dict[str, Any]) -> Self @@ -140,7 +140,7 @@ from_dict(cls, config: dict[str, Any]) -> Self Parse MCP configuration from dictionary format. -#### `to_dict` +#### `to_dict` ```python to_dict(self) -> dict[str, Any] @@ -149,7 +149,7 @@ to_dict(self) -> dict[str, Any] Convert MCPConfig to dictionary format, preserving all fields. -#### `write_to_file` +#### `write_to_file` ```python write_to_file(self, file_path: Path) -> None @@ -158,7 +158,7 @@ write_to_file(self, file_path: Path) -> None Write configuration to JSON file. -#### `from_file` +#### `from_file` ```python from_file(cls, file_path: Path) -> Self @@ -167,7 +167,7 @@ from_file(cls, file_path: Path) -> Self Load configuration from JSON file. -### `CanonicalMCPConfig` +### `CanonicalMCPConfig` Canonical MCP configuration format. @@ -178,7 +178,7 @@ The format is designed to be client-agnostic and extensible for future use cases **Methods:** -#### `add_server` +#### `add_server` ```python add_server(self, name: str, server: CanonicalMCPServerTypes) -> None diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py index c7dac539f2..d4dbf2df52 100644 --- a/src/fastmcp/mcp_config.py +++ b/src/fastmcp/mcp_config.py @@ -76,7 +76,7 @@ def infer_transport_type_from_url( class _TransformingMCPServerMixin(FastMCPBaseModel): """A mixin that enables wrapping an MCP Server with tool transforms.""" - tools: dict[str, ToolTransformConfig] = Field(...) + tools: dict[str, ToolTransformConfig] = Field(default_factory=dict) """The multi-tool transform to apply to the tools.""" include_tags: set[str] | None = Field( @@ -89,6 +89,27 @@ class _TransformingMCPServerMixin(FastMCPBaseModel): description="The tags to exclude in the proxy.", ) + @model_validator(mode="before") + @classmethod + def _require_at_least_one_transform_field( + cls, values: dict[str, Any] + ) -> dict[str, Any]: + """Reject if none of the transforming fields are set. + + This ensures that plain server configs (without tools, include_tags, + or exclude_tags) fall through to the base server types during union + validation, avoiding unnecessary proxy wrapping. + """ + if isinstance(values, dict): + has_tools = bool(values.get("tools")) + has_include = values.get("include_tags") is not None + has_exclude = values.get("exclude_tags") is not None + if not (has_tools or has_include or has_exclude): + raise ValueError( + "At least one of 'tools', 'include_tags', or 'exclude_tags' is required" + ) + return values + def _to_server_and_underlying_transport( self, server_name: str | None = None, diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index ee375dd022..0a6c22ab76 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -150,6 +150,11 @@ def test_parse_mcpservers_discriminator(): "args": ["hello"], }, "test_server_two": {"command": "echo", "args": ["hello"], "tools": {}}, + "test_server_three": { + "command": "echo", + "args": ["hello"], + "include_tags": ["my_tag"], + }, } mcp_config = MCPConfig.from_dict(config) @@ -157,8 +162,13 @@ def test_parse_mcpservers_discriminator(): test_server: MCPServerTypes = mcp_config.mcpServers["test_server"] assert isinstance(test_server, StdioMCPServer) + # Empty tools dict with no tags is not a meaningful transform test_server_two: MCPServerTypes = mcp_config.mcpServers["test_server_two"] - assert isinstance(test_server_two, TransformingStdioMCPServer) + assert isinstance(test_server_two, StdioMCPServer) + + # include_tags alone triggers transforming type + test_server_three: MCPServerTypes = mcp_config.mcpServers["test_server_three"] + assert isinstance(test_server_three, TransformingStdioMCPServer) canonical_mcp_config = CanonicalMCPConfig.from_dict(config) @@ -738,6 +748,48 @@ def subtract(a: int, b: int) -> int: assert "test_2_subtract" in tools_by_name +@pytest.mark.flaky(retries=3) +async def test_single_server_config_include_tags_filtering(tmp_path: Path): + """include_tags should filter tools even with a single server in the config.""" + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool(tags={"keep"}) + def add(a: int, b: int) -> int: + return a + b + + @mcp.tool + def subtract(a: int, b: int) -> int: + return a - b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "test": { + "command": "python", + "args": [str(script_path)], + "include_tags": ["keep"], + }, + } + } + + client = Client(config) + + async with client: + tools = await client.list_tools() + tool_names = {tool.name for tool in tools} + assert "add" in tool_names + assert "subtract" not in tool_names + + async def test_multi_client_with_elicitation(tmp_path: Path): """ Tests that elicitation is properly forwarded to the ultimate client.