William Easton Jeremiah Lowin commited on
Commit
bacf327
·
unverified ·
1 Parent(s): c324614

[🐶] Transform MCP Server Tools (#1132)

Browse files

Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>

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
- composite_server = FastMCP()
791
-
792
- for name, server in self.config.mcpServers.items():
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 AnyUrl, BaseModel, ConfigDict, Field
 
 
 
 
 
 
 
 
 
 
 
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
- """Canonical MCP configuration format.
 
 
167
 
168
- This defines the standard configuration format for Model Context Protocol servers.
169
- The format is designed to be client-agnostic and extensible for future use cases.
170
  """
171
 
172
- mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
173
 
174
  model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  @classmethod
177
- def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
178
  """Parse MCP configuration from dictionary format."""
179
- # Handle case where config is just the mcpServers object
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
- # Start with all extra fields at the top level
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
- with open(file_path, "w") as f:
229
- json.dump(self.to_dict(), f, indent=2)
230
 
231
  @classmethod
232
- def from_file(cls, file_path: Path) -> MCPConfig:
233
  """Load configuration from JSON file."""
234
- if not file_path.exists():
235
- return cls(mcpServers={})
236
- with open(file_path) as f:
237
- content = f.read().strip()
238
- if not content:
239
- return cls(mcpServers={})
240
- data = json.loads(content)
241
- return cls.from_dict(data)
242
-
243
- def add_server(self, name: str, server: StdioMCPServer | RemoteMCPServer) -> None:
 
 
 
 
 
 
 
 
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: StdioMCPServer | RemoteMCPServer,
257
  ) -> None:
258
- """Update MCP configuration file with new server, preserving existing fields."""
 
 
 
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 server_name in config.mcpServers:
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
- merged_dict = {**existing_dict, **new_dict}
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, merged_server)
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
- return all_tools
 
 
 
 
 
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(client_factory=self.client_factory)
 
 
 
 
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
- return all_tools
 
 
 
 
 
 
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 NotSet, NotSetT, get_cached_typeadapter
 
 
 
 
 
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 MCPConfig, RemoteMCPServer, StdioMCPServer
 
 
 
 
 
 
 
 
 
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)