Spaces:
Running
Running
William Easton Jeremiah Lowin commited on
[🐶] Transform MCP Server Tools (#1132)
Browse filesCo-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
- docs/clients/transports.mdx +72 -0
- docs/patterns/tool-transformation.mdx +35 -0
- src/fastmcp/client/transports.py +8 -10
- src/fastmcp/mcp_config.py +114 -79
- src/fastmcp/server/proxy.py +14 -2
- src/fastmcp/server/server.py +15 -1
- src/fastmcp/tools/tool_manager.py +38 -2
- src/fastmcp/tools/tool_transform.py +96 -2
- src/fastmcp/utilities/mcp_config.py +26 -0
- tests/server/proxy/test_proxy_server.py +27 -0
- tests/server/test_tool_transformation.py +40 -0
- tests/{utilities/test_mcp_config.py → test_mcp_config.py} +281 -1
- tests/tools/test_tool_manager.py +80 -0
docs/clients/transports.mdx
CHANGED
|
@@ -308,3 +308,75 @@ async with client:
|
|
| 308 |
answer = await client.call_tool("assistant_ask", {"question": "What?"})
|
| 309 |
```
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
answer = await client.call_tool("assistant_ask", {"question": "What?"})
|
| 309 |
```
|
| 310 |
|
| 311 |
+
### Tool Transformation with FastMCP and MCPConfig
|
| 312 |
+
|
| 313 |
+
FastMCP supports basic tool transformations to be defined alongside the MCP Servers in the MCPConfig file.
|
| 314 |
+
|
| 315 |
+
```python
|
| 316 |
+
config = {
|
| 317 |
+
"mcpServers": {
|
| 318 |
+
"weather": {
|
| 319 |
+
"url": "https://weather.example.com/mcp",
|
| 320 |
+
"transport": "http",
|
| 321 |
+
"tools": { } # <--- This is the tool transformation section
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
With these transformations, you can transform (change) the name, title, description, tags, enablement, and arguments of a tool.
|
| 328 |
+
|
| 329 |
+
For each argument the tool takes, you can transform (change) the name, description, default, visibility, whether it's required, and you can provide example values.
|
| 330 |
+
|
| 331 |
+
In the following example, we're transforming the `weather_get_forecast` tool to only retrieve the weather for `Miami` and hiding the `city` argument from the client.
|
| 332 |
+
|
| 333 |
+
```python
|
| 334 |
+
tool_transformations = {
|
| 335 |
+
"weather_get_forecast": {
|
| 336 |
+
"name": "miami_weather",
|
| 337 |
+
"description": "Get the weather for Miami",
|
| 338 |
+
"arguments": {
|
| 339 |
+
"city": {
|
| 340 |
+
"name": "city",
|
| 341 |
+
"default": "Miami",
|
| 342 |
+
"hide": True,
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
config = {
|
| 349 |
+
"mcpServers": {
|
| 350 |
+
"weather": {
|
| 351 |
+
"url": "https://weather.example.com/mcp",
|
| 352 |
+
"transport": "http",
|
| 353 |
+
"tools": tool_transformations
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
#### Allowlisting and Blocklisting Tools
|
| 360 |
+
|
| 361 |
+
Tools can be allowlisted or blocklisted from the client by applying `tags` to the tools on the server. In the following example, we're allowlisting only tools marked with the `forecast` tag, all other tools will be unavailable to the client.
|
| 362 |
+
|
| 363 |
+
```python
|
| 364 |
+
tool_transformations = {
|
| 365 |
+
"weather_get_forecast": {
|
| 366 |
+
"enabled": True,
|
| 367 |
+
"tags": ["forecast"]
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
config = {
|
| 373 |
+
"mcpServers": {
|
| 374 |
+
"weather": {
|
| 375 |
+
"url": "https://weather.example.com/mcp",
|
| 376 |
+
"transport": "http",
|
| 377 |
+
"tools": tool_transformations,
|
| 378 |
+
"include_tags": ["forecast"]
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
```
|
docs/patterns/tool-transformation.mdx
CHANGED
|
@@ -441,6 +441,41 @@ mcp.add_tool(new_tool)
|
|
| 441 |
In the above example, `**kwargs` receives the renamed argument `b`, not the original argument `y`. It is therefore recommended to use with `forward()`, not `forward_raw()`.
|
| 442 |
</Tip>
|
| 443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
## Output Schema Control
|
| 445 |
|
| 446 |
<VersionBadge version="2.10.0" />
|
|
|
|
| 441 |
In the above example, `**kwargs` receives the renamed argument `b`, not the original argument `y`. It is therefore recommended to use with `forward()`, not `forward_raw()`.
|
| 442 |
</Tip>
|
| 443 |
|
| 444 |
+
## Modifying MCP Tools with MCPConfig
|
| 445 |
+
|
| 446 |
+
When running MCP Servers under FastMCP with `MCPConfig`, you can also apply a subset of tool transformations
|
| 447 |
+
directly in the MCPConfig json file.
|
| 448 |
+
|
| 449 |
+
```json
|
| 450 |
+
{
|
| 451 |
+
"mcpServers": {
|
| 452 |
+
"weather": {
|
| 453 |
+
"url": "https://weather.example.com/mcp",
|
| 454 |
+
"transport": "http",
|
| 455 |
+
"tools": {
|
| 456 |
+
"weather_get_forecast": {
|
| 457 |
+
"name": "miami_weather",
|
| 458 |
+
"description": "Get the weather for Miami",
|
| 459 |
+
"arguments": {
|
| 460 |
+
"city": {
|
| 461 |
+
"name": "city",
|
| 462 |
+
"default": "Miami",
|
| 463 |
+
"hide": True,
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
```
|
| 472 |
+
|
| 473 |
+
The `tools` section is a dictionary of tool names to tool configurations. Each tool configuration is a
|
| 474 |
+
dictionary of tool properties.
|
| 475 |
+
|
| 476 |
+
See the [MCPConfigTransport](/clients/transports#tool-transformation-with-fastmcp-and-mcpconfig) documentation for more details.
|
| 477 |
+
|
| 478 |
+
|
| 479 |
## Output Schema Control
|
| 480 |
|
| 481 |
<VersionBadge version="2.10.0" />
|
src/fastmcp/client/transports.py
CHANGED
|
@@ -732,7 +732,7 @@ class MCPConfigTransport(ClientTransport):
|
|
| 732 |
|
| 733 |
1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
|
| 734 |
2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
|
| 735 |
-
all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
|
| 736 |
|
| 737 |
In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
|
| 738 |
and resources with the pattern `protocol://{server_name}/path/to/resource`.
|
|
@@ -772,7 +772,9 @@ class MCPConfigTransport(ClientTransport):
|
|
| 772 |
```
|
| 773 |
"""
|
| 774 |
|
| 775 |
-
def __init__(self, config: MCPConfig | dict):
|
|
|
|
|
|
|
| 776 |
if isinstance(config, dict):
|
| 777 |
config = MCPConfig.from_dict(config)
|
| 778 |
self.config = config
|
|
@@ -787,15 +789,11 @@ class MCPConfigTransport(ClientTransport):
|
|
| 787 |
|
| 788 |
# otherwise create a composite client
|
| 789 |
else:
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
composite_server.mount(
|
| 794 |
-
prefix=name,
|
| 795 |
-
server=FastMCP.as_proxy(backend=server.to_transport()),
|
| 796 |
)
|
| 797 |
-
|
| 798 |
-
self.transport = FastMCPTransport(mcp=composite_server)
|
| 799 |
|
| 800 |
@contextlib.asynccontextmanager
|
| 801 |
async def connect_session(
|
|
|
|
| 732 |
|
| 733 |
1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
|
| 734 |
2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
|
| 735 |
+
all servers on a single FastMCP instance, with each server's name, by default, used as its mounting prefix.
|
| 736 |
|
| 737 |
In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
|
| 738 |
and resources with the pattern `protocol://{server_name}/path/to/resource`.
|
|
|
|
| 772 |
```
|
| 773 |
"""
|
| 774 |
|
| 775 |
+
def __init__(self, config: MCPConfig | dict, name_as_prefix: bool = True):
|
| 776 |
+
from fastmcp.utilities.mcp_config import composite_server_from_mcp_config
|
| 777 |
+
|
| 778 |
if isinstance(config, dict):
|
| 779 |
config = MCPConfig.from_dict(config)
|
| 780 |
self.config = config
|
|
|
|
| 789 |
|
| 790 |
# otherwise create a composite client
|
| 791 |
else:
|
| 792 |
+
self.transport = FastMCPTransport(
|
| 793 |
+
mcp=composite_server_from_mcp_config(
|
| 794 |
+
self.config, name_as_prefix=name_as_prefix
|
|
|
|
|
|
|
|
|
|
| 795 |
)
|
| 796 |
+
)
|
|
|
|
| 797 |
|
| 798 |
@contextlib.asynccontextmanager
|
| 799 |
async def connect_session(
|
src/fastmcp/mcp_config.py
CHANGED
|
@@ -23,17 +23,29 @@ Example configuration:
|
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
import datetime
|
| 26 |
-
import json
|
| 27 |
import re
|
| 28 |
from pathlib import Path
|
| 29 |
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
| 30 |
from urllib.parse import urlparse
|
| 31 |
|
| 32 |
import httpx
|
| 33 |
-
from pydantic import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
if TYPE_CHECKING:
|
| 36 |
from fastmcp.client.transports import (
|
|
|
|
|
|
|
| 37 |
SSETransport,
|
| 38 |
StdioTransport,
|
| 39 |
StreamableHttpTransport,
|
|
@@ -60,6 +72,39 @@ def infer_transport_type_from_url(
|
|
| 60 |
return "http"
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
class StdioMCPServer(BaseModel):
|
| 64 |
"""MCP server configuration for stdio transport.
|
| 65 |
|
|
@@ -101,6 +146,10 @@ class StdioMCPServer(BaseModel):
|
|
| 101 |
)
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
class RemoteMCPServer(BaseModel):
|
| 105 |
"""MCP server configuration for HTTP/SSE transport.
|
| 106 |
|
|
@@ -162,120 +211,106 @@ class RemoteMCPServer(BaseModel):
|
|
| 162 |
)
|
| 163 |
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
class MCPConfig(BaseModel):
|
| 166 |
-
"""
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
-
The format is designed to be client-agnostic and extensible for future use cases.
|
| 170 |
"""
|
| 171 |
|
| 172 |
-
mcpServers: dict[str,
|
| 173 |
|
| 174 |
model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
@classmethod
|
| 177 |
-
def from_dict(cls, config: dict[str, Any]) ->
|
| 178 |
"""Parse MCP configuration from dictionary format."""
|
| 179 |
-
|
| 180 |
-
if "mcpServers" not in config and any(
|
| 181 |
-
isinstance(v, dict) and ("command" in v or "url" in v)
|
| 182 |
-
for v in config.values()
|
| 183 |
-
):
|
| 184 |
-
# This looks like a bare mcpServers object
|
| 185 |
-
servers_dict = config
|
| 186 |
-
else:
|
| 187 |
-
# Standard format with mcpServers wrapper
|
| 188 |
-
servers_dict = config.get("mcpServers", {})
|
| 189 |
-
|
| 190 |
-
# Parse each server configuration
|
| 191 |
-
parsed_servers = {}
|
| 192 |
-
for name, server_config in servers_dict.items():
|
| 193 |
-
if not isinstance(server_config, dict):
|
| 194 |
-
continue
|
| 195 |
-
|
| 196 |
-
# Determine if this is stdio or remote based on fields
|
| 197 |
-
if "command" in server_config:
|
| 198 |
-
parsed_servers[name] = StdioMCPServer.model_validate(server_config)
|
| 199 |
-
elif "url" in server_config:
|
| 200 |
-
parsed_servers[name] = RemoteMCPServer.model_validate(server_config)
|
| 201 |
-
else:
|
| 202 |
-
# Skip invalid server configs but preserve them as raw dicts
|
| 203 |
-
# This allows for forward compatibility with unknown server types
|
| 204 |
-
continue
|
| 205 |
-
|
| 206 |
-
# Create config with any extra top-level fields preserved
|
| 207 |
-
config_data = {k: v for k, v in config.items() if k != "mcpServers"}
|
| 208 |
-
config_data["mcpServers"] = parsed_servers
|
| 209 |
-
|
| 210 |
-
return cls.model_validate(config_data)
|
| 211 |
|
| 212 |
def to_dict(self) -> dict[str, Any]:
|
| 213 |
"""Convert MCPConfig to dictionary format, preserving all fields."""
|
| 214 |
-
|
| 215 |
-
result = self.model_dump(exclude={"mcpServers"}, exclude_none=True)
|
| 216 |
-
|
| 217 |
-
# Add mcpServers with all fields preserved
|
| 218 |
-
result["mcpServers"] = {
|
| 219 |
-
name: server.model_dump(exclude_none=True)
|
| 220 |
-
for name, server in self.mcpServers.items()
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
return result
|
| 224 |
|
| 225 |
def write_to_file(self, file_path: Path) -> None:
|
| 226 |
"""Write configuration to JSON file."""
|
| 227 |
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 228 |
-
|
| 229 |
-
json.dump(self.to_dict(), f, indent=2)
|
| 230 |
|
| 231 |
@classmethod
|
| 232 |
-
def from_file(cls, file_path: Path) ->
|
| 233 |
"""Load configuration from JSON file."""
|
| 234 |
-
if
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
"""Add or update a server in the configuration."""
|
| 245 |
self.mcpServers[name] = server
|
| 246 |
|
| 247 |
-
def remove_server(self, name: str) -> None:
|
| 248 |
-
"""Remove a server from the configuration."""
|
| 249 |
-
if name in self.mcpServers:
|
| 250 |
-
del self.mcpServers[name]
|
| 251 |
-
|
| 252 |
|
| 253 |
def update_config_file(
|
| 254 |
file_path: Path,
|
| 255 |
server_name: str,
|
| 256 |
-
server_config:
|
| 257 |
) -> None:
|
| 258 |
-
"""Update MCP configuration file
|
|
|
|
|
|
|
|
|
|
| 259 |
config = MCPConfig.from_file(file_path)
|
| 260 |
|
| 261 |
# If updating an existing server, merge with existing configuration
|
| 262 |
# to preserve any unknown fields
|
| 263 |
-
if
|
| 264 |
-
existing_server = config.mcpServers[server_name]
|
| 265 |
# Get the raw dict representation of both servers
|
| 266 |
existing_dict = existing_server.model_dump()
|
|
|
|
| 267 |
new_dict = server_config.model_dump(exclude_none=True)
|
| 268 |
|
| 269 |
# Merge, with new values taking precedence
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
# Create new server instance with merged data
|
| 273 |
-
if "command" in merged_dict:
|
| 274 |
-
merged_server = StdioMCPServer.model_validate(merged_dict)
|
| 275 |
-
else:
|
| 276 |
-
merged_server = RemoteMCPServer.model_validate(merged_dict)
|
| 277 |
|
| 278 |
-
config.add_server(server_name,
|
| 279 |
else:
|
| 280 |
config.add_server(server_name, server_config)
|
| 281 |
|
|
|
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
import datetime
|
|
|
|
| 26 |
import re
|
| 27 |
from pathlib import Path
|
| 28 |
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
| 29 |
from urllib.parse import urlparse
|
| 30 |
|
| 31 |
import httpx
|
| 32 |
+
from pydantic import (
|
| 33 |
+
AnyUrl,
|
| 34 |
+
BaseModel,
|
| 35 |
+
ConfigDict,
|
| 36 |
+
Field,
|
| 37 |
+
ValidationInfo,
|
| 38 |
+
model_validator,
|
| 39 |
+
)
|
| 40 |
+
from typing_extensions import Self, override
|
| 41 |
+
|
| 42 |
+
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 43 |
+
from fastmcp.utilities.types import FastMCPBaseModel
|
| 44 |
|
| 45 |
if TYPE_CHECKING:
|
| 46 |
from fastmcp.client.transports import (
|
| 47 |
+
ClientTransport,
|
| 48 |
+
FastMCPTransport,
|
| 49 |
SSETransport,
|
| 50 |
StdioTransport,
|
| 51 |
StreamableHttpTransport,
|
|
|
|
| 72 |
return "http"
|
| 73 |
|
| 74 |
|
| 75 |
+
class _TransformingMCPServerMixin(FastMCPBaseModel):
|
| 76 |
+
"""A mixin that enables wrapping an MCP Server with tool transforms."""
|
| 77 |
+
|
| 78 |
+
tools: dict[str, ToolTransformConfig] = Field(...)
|
| 79 |
+
"""The multi-tool transform to apply to the tools."""
|
| 80 |
+
|
| 81 |
+
include_tags: set[str] | None = Field(
|
| 82 |
+
default=None,
|
| 83 |
+
description="The tags to include in the proxy.",
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
exclude_tags: set[str] | None = Field(
|
| 87 |
+
default=None,
|
| 88 |
+
description="The tags to exclude in the proxy.",
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def to_transport(self) -> FastMCPTransport:
|
| 92 |
+
"""Get the transport for the server."""
|
| 93 |
+
from fastmcp.client.transports import FastMCPTransport
|
| 94 |
+
from fastmcp.server.server import FastMCP
|
| 95 |
+
|
| 96 |
+
transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType]
|
| 97 |
+
|
| 98 |
+
wrapped_mcp_server = FastMCP.as_proxy(
|
| 99 |
+
transport,
|
| 100 |
+
tool_transformations=self.tools,
|
| 101 |
+
include_tags=self.include_tags,
|
| 102 |
+
exclude_tags=self.exclude_tags,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
return FastMCPTransport(wrapped_mcp_server)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
class StdioMCPServer(BaseModel):
|
| 109 |
"""MCP server configuration for stdio transport.
|
| 110 |
|
|
|
|
| 146 |
)
|
| 147 |
|
| 148 |
|
| 149 |
+
class TransformingStdioMCPServer(_TransformingMCPServerMixin, StdioMCPServer):
|
| 150 |
+
"""A Stdio server with tool transforms."""
|
| 151 |
+
|
| 152 |
+
|
| 153 |
class RemoteMCPServer(BaseModel):
|
| 154 |
"""MCP server configuration for HTTP/SSE transport.
|
| 155 |
|
|
|
|
| 211 |
)
|
| 212 |
|
| 213 |
|
| 214 |
+
class TransformingRemoteMCPServer(_TransformingMCPServerMixin, RemoteMCPServer):
|
| 215 |
+
"""A Remote server with tool transforms."""
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
TransformingMCPServerTypes = TransformingStdioMCPServer | TransformingRemoteMCPServer
|
| 219 |
+
|
| 220 |
+
CanonicalMCPServerTypes = StdioMCPServer | RemoteMCPServer
|
| 221 |
+
|
| 222 |
+
MCPServerTypes = TransformingMCPServerTypes | CanonicalMCPServerTypes
|
| 223 |
+
|
| 224 |
+
|
| 225 |
class MCPConfig(BaseModel):
|
| 226 |
+
"""A configuration object for MCP Servers that conforms to the canonical MCP configuration format
|
| 227 |
+
while adding additional fields for enabling FastMCP-specific features like tool transformations
|
| 228 |
+
and filtering by tags.
|
| 229 |
|
| 230 |
+
For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class.
|
|
|
|
| 231 |
"""
|
| 232 |
|
| 233 |
+
mcpServers: dict[str, MCPServerTypes]
|
| 234 |
|
| 235 |
model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields
|
| 236 |
|
| 237 |
+
@model_validator(mode="before")
|
| 238 |
+
def validate_mcp_servers(self, info: ValidationInfo) -> dict[str, Any]:
|
| 239 |
+
"""Validate the MCP servers."""
|
| 240 |
+
if not isinstance(self, dict):
|
| 241 |
+
raise ValueError("MCPConfig format requires a dictionary of servers.")
|
| 242 |
+
|
| 243 |
+
if "mcpServers" not in self:
|
| 244 |
+
self = {"mcpServers": self}
|
| 245 |
+
|
| 246 |
+
return self
|
| 247 |
+
|
| 248 |
+
def add_server(self, name: str, server: MCPServerTypes) -> None:
|
| 249 |
+
"""Add or update a server in the configuration."""
|
| 250 |
+
self.mcpServers[name] = server
|
| 251 |
+
|
| 252 |
@classmethod
|
| 253 |
+
def from_dict(cls, config: dict[str, Any]) -> Self:
|
| 254 |
"""Parse MCP configuration from dictionary format."""
|
| 255 |
+
return cls.model_validate(config)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
def to_dict(self) -> dict[str, Any]:
|
| 258 |
"""Convert MCPConfig to dictionary format, preserving all fields."""
|
| 259 |
+
return self.model_dump(exclude_none=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
def write_to_file(self, file_path: Path) -> None:
|
| 262 |
"""Write configuration to JSON file."""
|
| 263 |
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 264 |
+
file_path.write_text(self.model_dump_json(indent=2))
|
|
|
|
| 265 |
|
| 266 |
@classmethod
|
| 267 |
+
def from_file(cls, file_path: Path) -> Self:
|
| 268 |
"""Load configuration from JSON file."""
|
| 269 |
+
if file_path.exists():
|
| 270 |
+
if content := file_path.read_text().strip():
|
| 271 |
+
return cls.model_validate_json(content)
|
| 272 |
+
|
| 273 |
+
return cls(mcpServers={})
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class CanonicalMCPConfig(MCPConfig):
|
| 277 |
+
"""Canonical MCP configuration format.
|
| 278 |
+
|
| 279 |
+
This defines the standard configuration format for Model Context Protocol servers.
|
| 280 |
+
The format is designed to be client-agnostic and extensible for future use cases.
|
| 281 |
+
"""
|
| 282 |
+
|
| 283 |
+
mcpServers: dict[str, CanonicalMCPServerTypes]
|
| 284 |
+
|
| 285 |
+
@override
|
| 286 |
+
def add_server(self, name: str, server: CanonicalMCPServerTypes) -> None:
|
| 287 |
"""Add or update a server in the configuration."""
|
| 288 |
self.mcpServers[name] = server
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
def update_config_file(
|
| 292 |
file_path: Path,
|
| 293 |
server_name: str,
|
| 294 |
+
server_config: CanonicalMCPServerTypes,
|
| 295 |
) -> None:
|
| 296 |
+
"""Update an MCP configuration file from a server object, preserving existing fields.
|
| 297 |
+
|
| 298 |
+
This is used for updating the mcpServer configurations of third-party tools so we do not
|
| 299 |
+
worry about transforming server objects here."""
|
| 300 |
config = MCPConfig.from_file(file_path)
|
| 301 |
|
| 302 |
# If updating an existing server, merge with existing configuration
|
| 303 |
# to preserve any unknown fields
|
| 304 |
+
if existing_server := config.mcpServers.get(server_name):
|
|
|
|
| 305 |
# Get the raw dict representation of both servers
|
| 306 |
existing_dict = existing_server.model_dump()
|
| 307 |
+
|
| 308 |
new_dict = server_config.model_dump(exclude_none=True)
|
| 309 |
|
| 310 |
# Merge, with new values taking precedence
|
| 311 |
+
merged_config = server_config.model_validate({**existing_dict, **new_dict})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
+
config.add_server(server_name, merged_config)
|
| 314 |
else:
|
| 315 |
config.add_server(server_name, server_config)
|
| 316 |
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -36,6 +36,9 @@ from fastmcp.server.dependencies import get_context
|
|
| 36 |
from fastmcp.server.server import FastMCP
|
| 37 |
from fastmcp.tools.tool import Tool, ToolResult
|
| 38 |
from fastmcp.tools.tool_manager import ToolManager
|
|
|
|
|
|
|
|
|
|
| 39 |
from fastmcp.utilities.components import MirroredComponent
|
| 40 |
from fastmcp.utilities.logging import get_logger
|
| 41 |
|
|
@@ -71,7 +74,12 @@ class ProxyToolManager(ToolManager):
|
|
| 71 |
else:
|
| 72 |
raise e
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
async def list_tools(self) -> list[Tool]:
|
| 77 |
"""Gets the filtered list of tools including local, mounted, and proxy tools."""
|
|
@@ -469,7 +477,11 @@ class FastMCPProxy(FastMCP):
|
|
| 469 |
raise ValueError("Must specify 'client_factory'")
|
| 470 |
|
| 471 |
# Replace the default managers with our specialized proxy managers.
|
| 472 |
-
self._tool_manager = ProxyToolManager(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
self._resource_manager = ProxyResourceManager(
|
| 474 |
client_factory=self.client_factory
|
| 475 |
)
|
|
|
|
| 36 |
from fastmcp.server.server import FastMCP
|
| 37 |
from fastmcp.tools.tool import Tool, ToolResult
|
| 38 |
from fastmcp.tools.tool_manager import ToolManager
|
| 39 |
+
from fastmcp.tools.tool_transform import (
|
| 40 |
+
apply_transformations_to_tools,
|
| 41 |
+
)
|
| 42 |
from fastmcp.utilities.components import MirroredComponent
|
| 43 |
from fastmcp.utilities.logging import get_logger
|
| 44 |
|
|
|
|
| 74 |
else:
|
| 75 |
raise e
|
| 76 |
|
| 77 |
+
transformed_tools = apply_transformations_to_tools(
|
| 78 |
+
tools=all_tools,
|
| 79 |
+
transformations=self.transformations,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
return transformed_tools
|
| 83 |
|
| 84 |
async def list_tools(self) -> list[Tool]:
|
| 85 |
"""Gets the filtered list of tools including local, mounted, and proxy tools."""
|
|
|
|
| 477 |
raise ValueError("Must specify 'client_factory'")
|
| 478 |
|
| 479 |
# Replace the default managers with our specialized proxy managers.
|
| 480 |
+
self._tool_manager = ProxyToolManager(
|
| 481 |
+
client_factory=self.client_factory,
|
| 482 |
+
# Propagate the transformations from the base class tool manager
|
| 483 |
+
transformations=self._tool_manager.transformations,
|
| 484 |
+
)
|
| 485 |
self._resource_manager = ProxyResourceManager(
|
| 486 |
client_factory=self.client_factory
|
| 487 |
)
|
src/fastmcp/server/server.py
CHANGED
|
@@ -26,6 +26,7 @@ from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
|
| 26 |
from mcp.server.stdio import stdio_server
|
| 27 |
from mcp.types import (
|
| 28 |
AnyFunction,
|
|
|
|
| 29 |
ContentBlock,
|
| 30 |
GetPromptResult,
|
| 31 |
ToolAnnotations,
|
|
@@ -60,6 +61,7 @@ from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
| 60 |
from fastmcp.settings import Settings
|
| 61 |
from fastmcp.tools import ToolManager
|
| 62 |
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
|
|
|
| 63 |
from fastmcp.utilities.cache import TimedCache
|
| 64 |
from fastmcp.utilities.cli import log_server_banner
|
| 65 |
from fastmcp.utilities.components import FastMCPComponent
|
|
@@ -138,6 +140,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 138 |
resource_prefix_format: Literal["protocol", "path"] | None = None,
|
| 139 |
mask_error_details: bool | None = None,
|
| 140 |
tools: list[Tool | Callable[..., Any]] | None = None,
|
|
|
|
| 141 |
dependencies: list[str] | None = None,
|
| 142 |
include_tags: set[str] | None = None,
|
| 143 |
exclude_tags: set[str] | None = None,
|
|
@@ -167,6 +170,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 167 |
self._tool_manager = ToolManager(
|
| 168 |
duplicate_behavior=on_duplicate_tools,
|
| 169 |
mask_error_details=mask_error_details,
|
|
|
|
| 170 |
)
|
| 171 |
self._resource_manager = ResourceManager(
|
| 172 |
duplicate_behavior=on_duplicate_resources,
|
|
@@ -650,7 +654,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 650 |
key=context.message.name, arguments=context.message.arguments or {}
|
| 651 |
)
|
| 652 |
|
| 653 |
-
mw_context = MiddlewareContext(
|
| 654 |
message=mcp.types.CallToolRequestParams(name=key, arguments=arguments),
|
| 655 |
source="client",
|
| 656 |
type="request",
|
|
@@ -806,6 +810,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 806 |
except RuntimeError:
|
| 807 |
pass # No context available
|
| 808 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 809 |
@overload
|
| 810 |
def tool(
|
| 811 |
self,
|
|
|
|
| 26 |
from mcp.server.stdio import stdio_server
|
| 27 |
from mcp.types import (
|
| 28 |
AnyFunction,
|
| 29 |
+
CallToolRequestParams,
|
| 30 |
ContentBlock,
|
| 31 |
GetPromptResult,
|
| 32 |
ToolAnnotations,
|
|
|
|
| 61 |
from fastmcp.settings import Settings
|
| 62 |
from fastmcp.tools import ToolManager
|
| 63 |
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
| 64 |
+
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 65 |
from fastmcp.utilities.cache import TimedCache
|
| 66 |
from fastmcp.utilities.cli import log_server_banner
|
| 67 |
from fastmcp.utilities.components import FastMCPComponent
|
|
|
|
| 140 |
resource_prefix_format: Literal["protocol", "path"] | None = None,
|
| 141 |
mask_error_details: bool | None = None,
|
| 142 |
tools: list[Tool | Callable[..., Any]] | None = None,
|
| 143 |
+
tool_transformations: dict[str, ToolTransformConfig] | None = None,
|
| 144 |
dependencies: list[str] | None = None,
|
| 145 |
include_tags: set[str] | None = None,
|
| 146 |
exclude_tags: set[str] | None = None,
|
|
|
|
| 170 |
self._tool_manager = ToolManager(
|
| 171 |
duplicate_behavior=on_duplicate_tools,
|
| 172 |
mask_error_details=mask_error_details,
|
| 173 |
+
transformations=tool_transformations,
|
| 174 |
)
|
| 175 |
self._resource_manager = ResourceManager(
|
| 176 |
duplicate_behavior=on_duplicate_resources,
|
|
|
|
| 654 |
key=context.message.name, arguments=context.message.arguments or {}
|
| 655 |
)
|
| 656 |
|
| 657 |
+
mw_context = MiddlewareContext[CallToolRequestParams](
|
| 658 |
message=mcp.types.CallToolRequestParams(name=key, arguments=arguments),
|
| 659 |
source="client",
|
| 660 |
type="request",
|
|
|
|
| 810 |
except RuntimeError:
|
| 811 |
pass # No context available
|
| 812 |
|
| 813 |
+
def add_tool_transformation(
|
| 814 |
+
self, tool_name: str, transformation: ToolTransformConfig
|
| 815 |
+
) -> None:
|
| 816 |
+
"""Add a tool transformation."""
|
| 817 |
+
self._tool_manager.add_tool_transformation(tool_name, transformation)
|
| 818 |
+
|
| 819 |
+
def remove_tool_transformation(self, tool_name: str) -> None:
|
| 820 |
+
"""Remove a tool transformation."""
|
| 821 |
+
self._tool_manager.remove_tool_transformation(tool_name)
|
| 822 |
+
|
| 823 |
@overload
|
| 824 |
def tool(
|
| 825 |
self,
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -10,6 +10,10 @@ from fastmcp import settings
|
|
| 10 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 11 |
from fastmcp.settings import DuplicateBehavior
|
| 12 |
from fastmcp.tools.tool import Tool, ToolResult
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
|
| 15 |
if TYPE_CHECKING:
|
|
@@ -25,10 +29,12 @@ class ToolManager:
|
|
| 25 |
self,
|
| 26 |
duplicate_behavior: DuplicateBehavior | None = None,
|
| 27 |
mask_error_details: bool | None = None,
|
|
|
|
| 28 |
):
|
| 29 |
self._tools: dict[str, Tool] = {}
|
| 30 |
self._mounted_servers: list[MountedServer] = []
|
| 31 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
|
|
|
| 32 |
|
| 33 |
# Default to "warn" if None is provided
|
| 34 |
if duplicate_behavior is None:
|
|
@@ -82,7 +88,13 @@ class ToolManager:
|
|
| 82 |
|
| 83 |
# Finally, add local tools, which always take precedence
|
| 84 |
all_tools.update(self._tools)
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
async def has_tool(self, key: str) -> bool:
|
| 88 |
"""Check if a tool exists."""
|
|
@@ -109,6 +121,15 @@ class ToolManager:
|
|
| 109 |
tools_dict = await self._load_tools(via_server=True)
|
| 110 |
return list(tools_dict.values())
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
def add_tool_from_fn(
|
| 113 |
self,
|
| 114 |
fn: Callable[..., Any],
|
|
@@ -155,6 +176,21 @@ class ToolManager:
|
|
| 155 |
self._tools[tool.key] = tool
|
| 156 |
return tool
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def remove_tool(self, key: str) -> None:
|
| 159 |
"""Remove a tool from the server.
|
| 160 |
|
|
@@ -175,7 +211,7 @@ class ToolManager:
|
|
| 175 |
filtered protocol path.
|
| 176 |
"""
|
| 177 |
# 1. Check local tools first. The server will have already applied its filter.
|
| 178 |
-
if key in self._tools:
|
| 179 |
tool = await self.get_tool(key)
|
| 180 |
if not tool:
|
| 181 |
raise NotFoundError(f"Tool {key!r} not found")
|
|
|
|
| 10 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 11 |
from fastmcp.settings import DuplicateBehavior
|
| 12 |
from fastmcp.tools.tool import Tool, ToolResult
|
| 13 |
+
from fastmcp.tools.tool_transform import (
|
| 14 |
+
ToolTransformConfig,
|
| 15 |
+
apply_transformations_to_tools,
|
| 16 |
+
)
|
| 17 |
from fastmcp.utilities.logging import get_logger
|
| 18 |
|
| 19 |
if TYPE_CHECKING:
|
|
|
|
| 29 |
self,
|
| 30 |
duplicate_behavior: DuplicateBehavior | None = None,
|
| 31 |
mask_error_details: bool | None = None,
|
| 32 |
+
transformations: dict[str, ToolTransformConfig] | None = None,
|
| 33 |
):
|
| 34 |
self._tools: dict[str, Tool] = {}
|
| 35 |
self._mounted_servers: list[MountedServer] = []
|
| 36 |
self.mask_error_details = mask_error_details or settings.mask_error_details
|
| 37 |
+
self.transformations = transformations or {}
|
| 38 |
|
| 39 |
# Default to "warn" if None is provided
|
| 40 |
if duplicate_behavior is None:
|
|
|
|
| 88 |
|
| 89 |
# Finally, add local tools, which always take precedence
|
| 90 |
all_tools.update(self._tools)
|
| 91 |
+
|
| 92 |
+
transformed_tools = apply_transformations_to_tools(
|
| 93 |
+
tools=all_tools,
|
| 94 |
+
transformations=self.transformations,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return transformed_tools
|
| 98 |
|
| 99 |
async def has_tool(self, key: str) -> bool:
|
| 100 |
"""Check if a tool exists."""
|
|
|
|
| 121 |
tools_dict = await self._load_tools(via_server=True)
|
| 122 |
return list(tools_dict.values())
|
| 123 |
|
| 124 |
+
@property
|
| 125 |
+
def _tools_transformed(self) -> list[str]:
|
| 126 |
+
"""Get the local tools."""
|
| 127 |
+
|
| 128 |
+
return [
|
| 129 |
+
transformation.name or tool_name
|
| 130 |
+
for tool_name, transformation in self.transformations.items()
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
def add_tool_from_fn(
|
| 134 |
self,
|
| 135 |
fn: Callable[..., Any],
|
|
|
|
| 176 |
self._tools[tool.key] = tool
|
| 177 |
return tool
|
| 178 |
|
| 179 |
+
def add_tool_transformation(
|
| 180 |
+
self, tool_name: str, transformation: ToolTransformConfig
|
| 181 |
+
) -> None:
|
| 182 |
+
"""Add a tool transformation."""
|
| 183 |
+
self.transformations[tool_name] = transformation
|
| 184 |
+
|
| 185 |
+
def get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None:
|
| 186 |
+
"""Get a tool transformation."""
|
| 187 |
+
return self.transformations.get(tool_name)
|
| 188 |
+
|
| 189 |
+
def remove_tool_transformation(self, tool_name: str) -> None:
|
| 190 |
+
"""Remove a tool transformation."""
|
| 191 |
+
if tool_name in self.transformations:
|
| 192 |
+
del self.transformations[tool_name]
|
| 193 |
+
|
| 194 |
def remove_tool(self, key: str) -> None:
|
| 195 |
"""Remove a tool from the server.
|
| 196 |
|
|
|
|
| 211 |
filtered protocol path.
|
| 212 |
"""
|
| 213 |
# 1. Check local tools first. The server will have already applied its filter.
|
| 214 |
+
if key in self._tools or key in self._tools_transformed:
|
| 215 |
tool = await self.get_tool(key)
|
| 216 |
if not tool:
|
| 217 |
raise NotFoundError(f"Tool {key!r} not found")
|
src/fastmcp/tools/tool_transform.py
CHANGED
|
@@ -4,14 +4,22 @@ import inspect
|
|
| 4 |
from collections.abc import Callable
|
| 5 |
from contextvars import ContextVar
|
| 6 |
from dataclasses import dataclass
|
| 7 |
-
from typing import Any, Literal
|
| 8 |
|
| 9 |
from mcp.types import ToolAnnotations
|
| 10 |
from pydantic import ConfigDict
|
|
|
|
|
|
|
| 11 |
|
| 12 |
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _convert_to_content
|
|
|
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
-
from fastmcp.utilities.types import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
logger = get_logger(__name__)
|
| 17 |
|
|
@@ -193,6 +201,30 @@ class ArgTransform:
|
|
| 193 |
)
|
| 194 |
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
class TransformedTool(Tool):
|
| 197 |
"""A tool that is transformed from another tool.
|
| 198 |
|
|
@@ -798,3 +830,65 @@ class TransformedTool(Tool):
|
|
| 798 |
return any(
|
| 799 |
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
| 800 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from collections.abc import Callable
|
| 5 |
from contextvars import ContextVar
|
| 6 |
from dataclasses import dataclass
|
| 7 |
+
from typing import Annotated, Any, Literal
|
| 8 |
|
| 9 |
from mcp.types import ToolAnnotations
|
| 10 |
from pydantic import ConfigDict
|
| 11 |
+
from pydantic.fields import Field
|
| 12 |
+
from pydantic.functional_validators import BeforeValidator
|
| 13 |
|
| 14 |
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _convert_to_content
|
| 15 |
+
from fastmcp.utilities.components import FastMCPComponent, _convert_set_default_none
|
| 16 |
from fastmcp.utilities.logging import get_logger
|
| 17 |
+
from fastmcp.utilities.types import (
|
| 18 |
+
FastMCPBaseModel,
|
| 19 |
+
NotSet,
|
| 20 |
+
NotSetT,
|
| 21 |
+
get_cached_typeadapter,
|
| 22 |
+
)
|
| 23 |
|
| 24 |
logger = get_logger(__name__)
|
| 25 |
|
|
|
|
| 201 |
)
|
| 202 |
|
| 203 |
|
| 204 |
+
class ArgTransformConfig(FastMCPBaseModel):
|
| 205 |
+
"""A model for requesting a single argument transform."""
|
| 206 |
+
|
| 207 |
+
name: str | None = Field(default=None, description="The new name for the argument.")
|
| 208 |
+
description: str | None = Field(
|
| 209 |
+
default=None, description="The new description for the argument."
|
| 210 |
+
)
|
| 211 |
+
default: str | int | float | bool | None = Field(
|
| 212 |
+
default=None, description="The new default value for the argument."
|
| 213 |
+
)
|
| 214 |
+
hide: bool = Field(
|
| 215 |
+
default=False, description="Whether to hide the argument from the tool."
|
| 216 |
+
)
|
| 217 |
+
required: Literal[True] | None = Field(
|
| 218 |
+
default=None, description="Whether the argument is required."
|
| 219 |
+
)
|
| 220 |
+
examples: Any | None = Field(default=None, description="Examples of the argument.")
|
| 221 |
+
|
| 222 |
+
def to_arg_transform(self) -> ArgTransform:
|
| 223 |
+
"""Convert the argument transform to a FastMCP argument transform."""
|
| 224 |
+
|
| 225 |
+
return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
class TransformedTool(Tool):
|
| 229 |
"""A tool that is transformed from another tool.
|
| 230 |
|
|
|
|
| 830 |
return any(
|
| 831 |
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
| 832 |
)
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
class ToolTransformConfig(FastMCPComponent):
|
| 836 |
+
"""Provides a way to transform a tool."""
|
| 837 |
+
|
| 838 |
+
name: str | None = Field(default=None, description="The new name for the tool.")
|
| 839 |
+
|
| 840 |
+
title: str | None = Field(
|
| 841 |
+
default=None,
|
| 842 |
+
description="The new title of the tool.",
|
| 843 |
+
)
|
| 844 |
+
description: str | None = Field(
|
| 845 |
+
default=None,
|
| 846 |
+
description="The new description of the tool.",
|
| 847 |
+
)
|
| 848 |
+
tags: Annotated[set[str], BeforeValidator(_convert_set_default_none)] = Field(
|
| 849 |
+
default_factory=set,
|
| 850 |
+
description="The new tags for the tool.",
|
| 851 |
+
)
|
| 852 |
+
|
| 853 |
+
enabled: bool = Field(
|
| 854 |
+
default=True,
|
| 855 |
+
description="Whether the tool is enabled.",
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
arguments: dict[str, ArgTransformConfig] = Field(
|
| 859 |
+
default_factory=dict,
|
| 860 |
+
description="A dictionary of argument transforms to apply to the tool.",
|
| 861 |
+
)
|
| 862 |
+
|
| 863 |
+
def apply(self, tool: Tool) -> TransformedTool:
|
| 864 |
+
"""Create a TransformedTool from a provided tool and this transformation configuration."""
|
| 865 |
+
|
| 866 |
+
tool_changes = self.model_dump(exclude_unset=True, exclude={"arguments"})
|
| 867 |
+
|
| 868 |
+
return TransformedTool.from_tool(
|
| 869 |
+
tool=tool,
|
| 870 |
+
**tool_changes,
|
| 871 |
+
transform_args={k: v.to_arg_transform() for k, v in self.arguments.items()},
|
| 872 |
+
)
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
def apply_transformations_to_tools(
|
| 876 |
+
tools: dict[str, Tool],
|
| 877 |
+
transformations: dict[str, ToolTransformConfig],
|
| 878 |
+
) -> dict[str, Tool]:
|
| 879 |
+
"""Apply a list of transformations to a list of tools. Tools that do not have any transforamtions
|
| 880 |
+
are left unchanged.
|
| 881 |
+
"""
|
| 882 |
+
|
| 883 |
+
transformed_tools = {}
|
| 884 |
+
|
| 885 |
+
for tool_name, tool in tools.items():
|
| 886 |
+
if transformation := transformations.get(tool_name):
|
| 887 |
+
transformed_tools[transformation.name or tool_name] = transformation.apply(
|
| 888 |
+
tool
|
| 889 |
+
)
|
| 890 |
+
continue
|
| 891 |
+
|
| 892 |
+
transformed_tools[tool_name] = tool
|
| 893 |
+
|
| 894 |
+
return transformed_tools
|
src/fastmcp/utilities/mcp_config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastmcp.mcp_config import MCPConfig
|
| 2 |
+
from fastmcp.server.server import FastMCP
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def composite_server_from_mcp_config(
|
| 6 |
+
config: MCPConfig, name_as_prefix: bool = True
|
| 7 |
+
) -> FastMCP:
|
| 8 |
+
"""A utility function to create a composite server from an MCPConfig."""
|
| 9 |
+
composite_server = FastMCP()
|
| 10 |
+
|
| 11 |
+
mount_mcp_config_into_server(config, composite_server, name_as_prefix)
|
| 12 |
+
|
| 13 |
+
return composite_server
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def mount_mcp_config_into_server(
|
| 17 |
+
config: MCPConfig,
|
| 18 |
+
server: FastMCP,
|
| 19 |
+
name_as_prefix: bool = True,
|
| 20 |
+
) -> None:
|
| 21 |
+
"""A utility function to mount the servers from an MCPConfig into a FastMCP server."""
|
| 22 |
+
for name, mcp_server in config.mcpServers.items():
|
| 23 |
+
server.mount(
|
| 24 |
+
prefix=name if name_as_prefix else None,
|
| 25 |
+
server=FastMCP.as_proxy(backend=mcp_server.to_transport()),
|
| 26 |
+
)
|
tests/server/proxy/test_proxy_server.py
CHANGED
|
@@ -12,6 +12,9 @@ from fastmcp.client import Client
|
|
| 12 |
from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
from fastmcp.server.proxy import FastMCPProxy, ProxyClient
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
USERS = [
|
| 17 |
{"id": "1", "name": "Alice", "active": True},
|
|
@@ -118,6 +121,30 @@ class TestTools:
|
|
| 118 |
assert "error_tool" in tools
|
| 119 |
assert "tool_without_description" in tools
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
async def test_tool_without_description(self, proxy_server):
|
| 122 |
tools = await proxy_server.get_tools()
|
| 123 |
assert tools["tool_without_description"].description is None
|
|
|
|
| 12 |
from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
from fastmcp.server.proxy import FastMCPProxy, ProxyClient
|
| 15 |
+
from fastmcp.tools.tool_transform import (
|
| 16 |
+
ToolTransformConfig,
|
| 17 |
+
)
|
| 18 |
|
| 19 |
USERS = [
|
| 20 |
{"id": "1", "name": "Alice", "active": True},
|
|
|
|
| 121 |
assert "error_tool" in tools
|
| 122 |
assert "tool_without_description" in tools
|
| 123 |
|
| 124 |
+
async def test_get_transformed_tools(
|
| 125 |
+
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
| 126 |
+
):
|
| 127 |
+
"""An explicit None description should change the tool description to None."""
|
| 128 |
+
|
| 129 |
+
fastmcp_server.add_tool_transformation(
|
| 130 |
+
"add", ToolTransformConfig(name="add_transformed")
|
| 131 |
+
)
|
| 132 |
+
tools = await proxy_server.get_tools()
|
| 133 |
+
assert "add_transformed" in tools
|
| 134 |
+
assert "add" not in tools
|
| 135 |
+
|
| 136 |
+
async def test_call_transformed_tools(
|
| 137 |
+
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
| 138 |
+
):
|
| 139 |
+
"""An explicit None description should change the tool description to None."""
|
| 140 |
+
|
| 141 |
+
fastmcp_server.add_tool_transformation(
|
| 142 |
+
"add", ToolTransformConfig(name="add_transformed")
|
| 143 |
+
)
|
| 144 |
+
async with Client(proxy_server) as client:
|
| 145 |
+
result = await client.call_tool("add_transformed", {"a": 1, "b": 2})
|
| 146 |
+
assert result.data == 3
|
| 147 |
+
|
| 148 |
async def test_tool_without_description(self, proxy_server):
|
| 149 |
tools = await proxy_server.get_tools()
|
| 150 |
assert tools["tool_without_description"].description is None
|
tests/server/test_tool_transformation.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastmcp import FastMCP
|
| 2 |
+
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
async def test_tool_transformation_in_tool_manager():
|
| 6 |
+
"""Test that tool transformations are applied in the tool manager."""
|
| 7 |
+
mcp = FastMCP("Test Server")
|
| 8 |
+
|
| 9 |
+
@mcp.tool()
|
| 10 |
+
def echo(message: str) -> str:
|
| 11 |
+
"""Echo back the message provided."""
|
| 12 |
+
return message
|
| 13 |
+
|
| 14 |
+
mcp.add_tool_transformation("echo", ToolTransformConfig(name="echo_transformed"))
|
| 15 |
+
|
| 16 |
+
tools_dict = await mcp._tool_manager.get_tools()
|
| 17 |
+
tools = list(tools_dict.values())
|
| 18 |
+
assert len(tools) == 1
|
| 19 |
+
assert "echo_transformed" in tools_dict
|
| 20 |
+
assert tools_dict["echo_transformed"].name == "echo_transformed"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def test_transformed_tool_filtering():
|
| 24 |
+
"""Test that tool transformations are applied in the tool manager."""
|
| 25 |
+
mcp = FastMCP("Test Server", include_tags={"enabled_tools"})
|
| 26 |
+
|
| 27 |
+
@mcp.tool()
|
| 28 |
+
def echo(message: str) -> str:
|
| 29 |
+
"""Echo back the message provided."""
|
| 30 |
+
return message
|
| 31 |
+
|
| 32 |
+
tools = list(await mcp._list_tools())
|
| 33 |
+
assert len(tools) == 0
|
| 34 |
+
|
| 35 |
+
mcp.add_tool_transformation(
|
| 36 |
+
"echo", ToolTransformConfig(name="echo_transformed", tags={"enabled_tools"})
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
tools = list(await mcp._list_tools())
|
| 40 |
+
assert len(tools) == 1
|
tests/{utilities/test_mcp_config.py → test_mcp_config.py}
RENAMED
|
@@ -1,16 +1,31 @@
|
|
| 1 |
import inspect
|
|
|
|
|
|
|
| 2 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from fastmcp.client.auth.bearer import BearerAuth
|
| 5 |
from fastmcp.client.auth.oauth import OAuthClientProvider
|
| 6 |
from fastmcp.client.client import Client
|
| 7 |
from fastmcp.client.logging import LogMessage
|
| 8 |
from fastmcp.client.transports import (
|
|
|
|
| 9 |
SSETransport,
|
| 10 |
StdioTransport,
|
| 11 |
StreamableHttpTransport,
|
| 12 |
)
|
| 13 |
-
from fastmcp.mcp_config import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def test_parse_single_stdio_config():
|
|
@@ -29,6 +44,74 @@ def test_parse_single_stdio_config():
|
|
| 29 |
assert transport.args == ["hello"]
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def test_parse_single_remote_config():
|
| 33 |
config = {
|
| 34 |
"mcpServers": {
|
|
@@ -244,6 +327,172 @@ async def test_multi_client_with_logging(tmp_path: Path):
|
|
| 244 |
assert MESSAGES[0].data == "test 42"
|
| 245 |
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
async def test_multi_client_with_elicitation(tmp_path: Path):
|
| 248 |
"""
|
| 249 |
Tests that elicitation is properly forwarded to the ultimate client.
|
|
@@ -284,3 +533,34 @@ async def test_multi_client_with_elicitation(tmp_path: Path):
|
|
| 284 |
async with Client(config, elicitation_handler=elicitation_handler) as client:
|
| 285 |
result = await client.call_tool("test_server_elicit_test", {})
|
| 286 |
assert result.data == 42
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import inspect
|
| 2 |
+
import tempfile
|
| 3 |
+
from collections.abc import AsyncGenerator
|
| 4 |
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
|
| 9 |
from fastmcp.client.auth.bearer import BearerAuth
|
| 10 |
from fastmcp.client.auth.oauth import OAuthClientProvider
|
| 11 |
from fastmcp.client.client import Client
|
| 12 |
from fastmcp.client.logging import LogMessage
|
| 13 |
from fastmcp.client.transports import (
|
| 14 |
+
MCPConfigTransport,
|
| 15 |
SSETransport,
|
| 16 |
StdioTransport,
|
| 17 |
StreamableHttpTransport,
|
| 18 |
)
|
| 19 |
+
from fastmcp.mcp_config import (
|
| 20 |
+
CanonicalMCPConfig,
|
| 21 |
+
CanonicalMCPServerTypes,
|
| 22 |
+
MCPConfig,
|
| 23 |
+
MCPServerTypes,
|
| 24 |
+
RemoteMCPServer,
|
| 25 |
+
StdioMCPServer,
|
| 26 |
+
TransformingStdioMCPServer,
|
| 27 |
+
)
|
| 28 |
+
from fastmcp.tools.tool import Tool as FastMCPTool
|
| 29 |
|
| 30 |
|
| 31 |
def test_parse_single_stdio_config():
|
|
|
|
| 44 |
assert transport.args == ["hello"]
|
| 45 |
|
| 46 |
|
| 47 |
+
def test_parse_extra_keys():
|
| 48 |
+
config = {
|
| 49 |
+
"mcpServers": {
|
| 50 |
+
"test_server": {
|
| 51 |
+
"command": "echo",
|
| 52 |
+
"args": ["hello"],
|
| 53 |
+
"leaf_extra": "leaf_extra",
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
"root_extra": "root_extra",
|
| 57 |
+
}
|
| 58 |
+
mcp_config = MCPConfig.from_dict(config)
|
| 59 |
+
|
| 60 |
+
serialized_mcp_config = mcp_config.to_dict()
|
| 61 |
+
assert serialized_mcp_config["root_extra"] == "root_extra"
|
| 62 |
+
assert (
|
| 63 |
+
serialized_mcp_config["mcpServers"]["test_server"]["leaf_extra"] == "leaf_extra"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_parse_mcpservers_at_root():
|
| 68 |
+
config = {
|
| 69 |
+
"test_server": {
|
| 70 |
+
"command": "echo",
|
| 71 |
+
"args": ["hello"],
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
mcp_config = MCPConfig.from_dict(config)
|
| 76 |
+
|
| 77 |
+
serialized_mcp_config = mcp_config.model_dump()
|
| 78 |
+
assert serialized_mcp_config["mcpServers"]["test_server"]["command"] == "echo"
|
| 79 |
+
assert serialized_mcp_config["mcpServers"]["test_server"]["args"] == ["hello"]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_parse_mcpservers_discriminator():
|
| 83 |
+
"""Test that the MCPConfig discriminator produces StdioMCPServer for a non-transforming server
|
| 84 |
+
and TransformingStdioMCPServer for a transforming server."""
|
| 85 |
+
|
| 86 |
+
config = {
|
| 87 |
+
"test_server": {
|
| 88 |
+
"command": "echo",
|
| 89 |
+
"args": ["hello"],
|
| 90 |
+
},
|
| 91 |
+
"test_server_two": {"command": "echo", "args": ["hello"], "tools": {}},
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
mcp_config = MCPConfig.from_dict(config)
|
| 95 |
+
|
| 96 |
+
test_server: MCPServerTypes = mcp_config.mcpServers["test_server"]
|
| 97 |
+
assert isinstance(test_server, StdioMCPServer)
|
| 98 |
+
|
| 99 |
+
test_server_two: MCPServerTypes = mcp_config.mcpServers["test_server_two"]
|
| 100 |
+
assert isinstance(test_server_two, TransformingStdioMCPServer)
|
| 101 |
+
|
| 102 |
+
canonical_mcp_config = CanonicalMCPConfig.from_dict(config)
|
| 103 |
+
|
| 104 |
+
canonical_test_server: CanonicalMCPServerTypes = canonical_mcp_config.mcpServers[
|
| 105 |
+
"test_server"
|
| 106 |
+
]
|
| 107 |
+
assert isinstance(canonical_test_server, StdioMCPServer)
|
| 108 |
+
|
| 109 |
+
canonical_test_server_two: CanonicalMCPServerTypes = (
|
| 110 |
+
canonical_mcp_config.mcpServers["test_server_two"]
|
| 111 |
+
)
|
| 112 |
+
assert isinstance(canonical_test_server_two, StdioMCPServer)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
def test_parse_single_remote_config():
|
| 116 |
config = {
|
| 117 |
"mcpServers": {
|
|
|
|
| 327 |
assert MESSAGES[0].data == "test 42"
|
| 328 |
|
| 329 |
|
| 330 |
+
async def test_multi_client_with_transforms(tmp_path: Path):
|
| 331 |
+
"""
|
| 332 |
+
Tests that transforms are properly applied to the tools.
|
| 333 |
+
"""
|
| 334 |
+
server_script = inspect.cleandoc("""
|
| 335 |
+
from fastmcp import FastMCP
|
| 336 |
+
|
| 337 |
+
mcp = FastMCP()
|
| 338 |
+
|
| 339 |
+
@mcp.tool
|
| 340 |
+
def add(a: int, b: int) -> int:
|
| 341 |
+
return a + b
|
| 342 |
+
|
| 343 |
+
if __name__ == '__main__':
|
| 344 |
+
mcp.run()
|
| 345 |
+
""")
|
| 346 |
+
|
| 347 |
+
script_path = tmp_path / "test.py"
|
| 348 |
+
script_path.write_text(server_script)
|
| 349 |
+
|
| 350 |
+
config = {
|
| 351 |
+
"mcpServers": {
|
| 352 |
+
"test_1": {
|
| 353 |
+
"command": "python",
|
| 354 |
+
"args": [str(script_path)],
|
| 355 |
+
"tools": {
|
| 356 |
+
"add": {
|
| 357 |
+
"name": "transformed_add",
|
| 358 |
+
"arguments": {
|
| 359 |
+
"a": {"name": "transformed_a"},
|
| 360 |
+
"b": {"name": "transformed_b"},
|
| 361 |
+
},
|
| 362 |
+
}
|
| 363 |
+
},
|
| 364 |
+
},
|
| 365 |
+
"test_2": {
|
| 366 |
+
"command": "python",
|
| 367 |
+
"args": [str(script_path)],
|
| 368 |
+
},
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
client = Client[MCPConfigTransport](config)
|
| 373 |
+
|
| 374 |
+
async with client:
|
| 375 |
+
tools = await client.list_tools()
|
| 376 |
+
tools_by_name = {tool.name: tool for tool in tools}
|
| 377 |
+
assert len(tools) == 2
|
| 378 |
+
assert "test_1_transformed_add" in tools_by_name
|
| 379 |
+
|
| 380 |
+
result = await client.call_tool(
|
| 381 |
+
"test_1_transformed_add", {"transformed_a": 1, "transformed_b": 2}
|
| 382 |
+
)
|
| 383 |
+
assert result.data == 3
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
async def test_canonical_multi_client_with_transforms(tmp_path: Path):
|
| 387 |
+
"""Test that transforms are not applied to servers in a canonical MCPConfig."""
|
| 388 |
+
server_script = inspect.cleandoc("""
|
| 389 |
+
from fastmcp import FastMCP
|
| 390 |
+
|
| 391 |
+
mcp = FastMCP()
|
| 392 |
+
|
| 393 |
+
@mcp.tool
|
| 394 |
+
def add(a: int, b: int) -> int:
|
| 395 |
+
return a + b
|
| 396 |
+
|
| 397 |
+
if __name__ == '__main__':
|
| 398 |
+
mcp.run()
|
| 399 |
+
""")
|
| 400 |
+
|
| 401 |
+
script_path = tmp_path / "test.py"
|
| 402 |
+
script_path.write_text(server_script)
|
| 403 |
+
|
| 404 |
+
config = CanonicalMCPConfig(
|
| 405 |
+
mcpServers={
|
| 406 |
+
"test_1": {
|
| 407 |
+
"command": "python",
|
| 408 |
+
"args": [str(script_path)],
|
| 409 |
+
"tools": { # <--- Will be ignored as its not valid for a canonical MCPConfig
|
| 410 |
+
"add": {
|
| 411 |
+
"name": "transformed_add",
|
| 412 |
+
"arguments": {
|
| 413 |
+
"a": {"name": "transformed_a"},
|
| 414 |
+
"b": {"name": "transformed_b"},
|
| 415 |
+
},
|
| 416 |
+
}
|
| 417 |
+
},
|
| 418 |
+
},
|
| 419 |
+
"test_2": {
|
| 420 |
+
"command": "python",
|
| 421 |
+
"args": [str(script_path)],
|
| 422 |
+
},
|
| 423 |
+
} # type: ignore[reportUnknownArgumentType]
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
client = Client(config)
|
| 427 |
+
|
| 428 |
+
async with client:
|
| 429 |
+
tools = await client.list_tools()
|
| 430 |
+
tools_by_name = {tool.name: tool for tool in tools}
|
| 431 |
+
assert len(tools) == 2
|
| 432 |
+
assert "test_1_transformed_add" not in tools_by_name
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
async def test_multi_client_transform_with_filtering(tmp_path: Path):
|
| 436 |
+
"""
|
| 437 |
+
Tests that tag-based filtering works when using a transforming MCPConfig.
|
| 438 |
+
"""
|
| 439 |
+
server_script = inspect.cleandoc("""
|
| 440 |
+
from fastmcp import FastMCP
|
| 441 |
+
|
| 442 |
+
mcp = FastMCP()
|
| 443 |
+
|
| 444 |
+
@mcp.tool
|
| 445 |
+
def add(a: int, b: int) -> int:
|
| 446 |
+
return a + b
|
| 447 |
+
|
| 448 |
+
@mcp.tool
|
| 449 |
+
def subtract(a: int, b: int) -> int:
|
| 450 |
+
return a - b
|
| 451 |
+
|
| 452 |
+
if __name__ == '__main__':
|
| 453 |
+
mcp.run()
|
| 454 |
+
""")
|
| 455 |
+
|
| 456 |
+
script_path = tmp_path / "test.py"
|
| 457 |
+
script_path.write_text(server_script)
|
| 458 |
+
|
| 459 |
+
config = {
|
| 460 |
+
"mcpServers": {
|
| 461 |
+
"test_1": {
|
| 462 |
+
"command": "python",
|
| 463 |
+
"args": [str(script_path)],
|
| 464 |
+
"tools": {
|
| 465 |
+
"add": {
|
| 466 |
+
"name": "transformed_add",
|
| 467 |
+
"tags": ["keep"],
|
| 468 |
+
"arguments": {
|
| 469 |
+
"a": {"name": "transformed_a"},
|
| 470 |
+
"b": {"name": "transformed_b"},
|
| 471 |
+
},
|
| 472 |
+
},
|
| 473 |
+
},
|
| 474 |
+
"include_tags": ["keep"],
|
| 475 |
+
},
|
| 476 |
+
"test_2": {
|
| 477 |
+
"command": "python",
|
| 478 |
+
"args": [str(script_path)],
|
| 479 |
+
},
|
| 480 |
+
}
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
client = Client[MCPConfigTransport](config)
|
| 484 |
+
|
| 485 |
+
async with client:
|
| 486 |
+
tools = await client.list_tools()
|
| 487 |
+
tools_by_name = {tool.name: tool for tool in tools}
|
| 488 |
+
assert len(tools) == 3
|
| 489 |
+
assert "test_1_transformed_add" in tools_by_name
|
| 490 |
+
assert "test_1_add" not in tools_by_name
|
| 491 |
+
assert "test_1_subtract" not in tools_by_name
|
| 492 |
+
assert "test_2_add" in tools_by_name
|
| 493 |
+
assert "test_2_subtract" in tools_by_name
|
| 494 |
+
|
| 495 |
+
|
| 496 |
async def test_multi_client_with_elicitation(tmp_path: Path):
|
| 497 |
"""
|
| 498 |
Tests that elicitation is properly forwarded to the ultimate client.
|
|
|
|
| 533 |
async with Client(config, elicitation_handler=elicitation_handler) as client:
|
| 534 |
result = await client.call_tool("test_server_elicit_test", {})
|
| 535 |
assert result.data == 42
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def sample_tool_fn(arg1: int, arg2: str) -> str:
|
| 539 |
+
return f"Hello, world! {arg1} {arg2}"
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
@pytest.fixture
|
| 543 |
+
def sample_tool() -> FastMCPTool:
|
| 544 |
+
return FastMCPTool.from_function(sample_tool_fn, name="sample_tool")
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
@pytest.fixture
|
| 548 |
+
async def test_script(tmp_path: Path) -> AsyncGenerator[Path, Any]:
|
| 549 |
+
with tempfile.NamedTemporaryFile() as f:
|
| 550 |
+
f.write(b"""
|
| 551 |
+
from fastmcp import FastMCP
|
| 552 |
+
|
| 553 |
+
mcp = FastMCP()
|
| 554 |
+
|
| 555 |
+
@mcp.tool
|
| 556 |
+
def fetch(url: str) -> str:
|
| 557 |
+
|
| 558 |
+
return f"Hello, world! {url}"
|
| 559 |
+
|
| 560 |
+
if __name__ == '__main__':
|
| 561 |
+
mcp.run()
|
| 562 |
+
""")
|
| 563 |
+
|
| 564 |
+
yield Path(f.name)
|
| 565 |
+
|
| 566 |
+
pass
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -12,6 +12,7 @@ from fastmcp import Context, FastMCP
|
|
| 12 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 13 |
from fastmcp.tools import FunctionTool, ToolManager
|
| 14 |
from fastmcp.tools.tool import Tool
|
|
|
|
| 15 |
from fastmcp.utilities.tests import caplog_for_fastmcp
|
| 16 |
from fastmcp.utilities.types import Image
|
| 17 |
|
|
@@ -262,6 +263,52 @@ class TestAddTools:
|
|
| 262 |
assert result.fn.__name__ == "replacement_fn"
|
| 263 |
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
class TestToolTags:
|
| 266 |
"""Test functionality related to tool tags."""
|
| 267 |
|
|
@@ -431,6 +478,39 @@ class TestCallTools:
|
|
| 431 |
with pytest.raises(NotFoundError, match="Tool 'unknown' not found"):
|
| 432 |
await manager.call_tool("unknown", {"a": 1})
|
| 433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
async def test_call_tool_with_list_int_input(self):
|
| 435 |
def sum_vals(vals: list[int]) -> int:
|
| 436 |
return sum(vals)
|
|
|
|
| 12 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 13 |
from fastmcp.tools import FunctionTool, ToolManager
|
| 14 |
from fastmcp.tools.tool import Tool
|
| 15 |
+
from fastmcp.tools.tool_transform import ArgTransformConfig, ToolTransformConfig
|
| 16 |
from fastmcp.utilities.tests import caplog_for_fastmcp
|
| 17 |
from fastmcp.utilities.types import Image
|
| 18 |
|
|
|
|
| 263 |
assert result.fn.__name__ == "replacement_fn"
|
| 264 |
|
| 265 |
|
| 266 |
+
class TestListTools:
|
| 267 |
+
async def test_list_tools_with_transformed_names(self):
|
| 268 |
+
"""Test listing tools with transformations."""
|
| 269 |
+
|
| 270 |
+
tool_manager = ToolManager()
|
| 271 |
+
|
| 272 |
+
def add(a: int, b: int) -> int:
|
| 273 |
+
return a + b
|
| 274 |
+
|
| 275 |
+
tool = Tool.from_function(add)
|
| 276 |
+
tool_manager.add_tool(tool)
|
| 277 |
+
|
| 278 |
+
tool_manager.add_tool_transformation(
|
| 279 |
+
"add", ToolTransformConfig(name="add_transformed")
|
| 280 |
+
)
|
| 281 |
+
tools = await tool_manager.list_tools()
|
| 282 |
+
tools_by_name = {tool.name: tool for tool in tools}
|
| 283 |
+
assert "add_transformed" in tools_by_name
|
| 284 |
+
assert "add" not in tools_by_name
|
| 285 |
+
|
| 286 |
+
async def test_list_tools_with_transforms(self):
|
| 287 |
+
"""Test listing tools with transformations."""
|
| 288 |
+
|
| 289 |
+
tool_manager = ToolManager()
|
| 290 |
+
|
| 291 |
+
def add(a: int, b: int) -> int:
|
| 292 |
+
"""Add two numbers."""
|
| 293 |
+
return a + b
|
| 294 |
+
|
| 295 |
+
tool = Tool.from_function(add)
|
| 296 |
+
tool_manager.add_tool(tool)
|
| 297 |
+
|
| 298 |
+
tool_manager.add_tool_transformation(
|
| 299 |
+
"add",
|
| 300 |
+
ToolTransformConfig(
|
| 301 |
+
name="add_transformed", description=None, tags={"enabled_tools"}
|
| 302 |
+
),
|
| 303 |
+
)
|
| 304 |
+
tools = await tool_manager.list_tools()
|
| 305 |
+
tools_by_name = {tool.name: tool for tool in tools}
|
| 306 |
+
assert "add_transformed" in tools_by_name
|
| 307 |
+
assert "add" not in tools_by_name
|
| 308 |
+
assert tools_by_name["add_transformed"].description is None
|
| 309 |
+
assert tools_by_name["add_transformed"].tags == {"enabled_tools"}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
class TestToolTags:
|
| 313 |
"""Test functionality related to tool tags."""
|
| 314 |
|
|
|
|
| 478 |
with pytest.raises(NotFoundError, match="Tool 'unknown' not found"):
|
| 479 |
await manager.call_tool("unknown", {"a": 1})
|
| 480 |
|
| 481 |
+
async def test_call_transformed_tool(self):
|
| 482 |
+
manager = ToolManager()
|
| 483 |
+
|
| 484 |
+
def add(a: int, b: int) -> int:
|
| 485 |
+
"""Add two numbers."""
|
| 486 |
+
return a + b
|
| 487 |
+
|
| 488 |
+
tool = Tool.from_function(add)
|
| 489 |
+
manager.add_tool(tool)
|
| 490 |
+
|
| 491 |
+
manager.add_tool_transformation(
|
| 492 |
+
"add",
|
| 493 |
+
ToolTransformConfig(
|
| 494 |
+
name="add_transformed",
|
| 495 |
+
description=None,
|
| 496 |
+
tags={"enabled_tools"},
|
| 497 |
+
arguments={
|
| 498 |
+
"a": ArgTransformConfig(
|
| 499 |
+
name="a_transformed", description=None, default=1
|
| 500 |
+
),
|
| 501 |
+
"b": ArgTransformConfig(
|
| 502 |
+
name="b_transformed", description=None, default=2
|
| 503 |
+
),
|
| 504 |
+
},
|
| 505 |
+
),
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
result = await manager.call_tool(
|
| 509 |
+
"add_transformed", {"a_transformed": 1, "b_transformed": 2}
|
| 510 |
+
)
|
| 511 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 512 |
+
assert result.structured_content == {"result": 3}
|
| 513 |
+
|
| 514 |
async def test_call_tool_with_list_int_input(self):
|
| 515 |
def sum_vals(vals: list[int]) -> int:
|
| 516 |
return sum(vals)
|