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.