Jeremiah Lowin commited on
Commit
1f34d30
·
1 Parent(s): cf04a1f

Add tool transformation

Browse files
src/fastmcp/tools/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
  from .tool import Tool, FunctionTool
2
  from .tool_manager import ToolManager
 
3
 
4
- __all__ = ["Tool", "ToolManager", "FunctionTool"]
 
1
  from .tool import Tool, FunctionTool
2
  from .tool_manager import ToolManager
3
+ from .tool_transform import forward, forward_raw
4
 
5
+ __all__ = ["Tool", "ToolManager", "FunctionTool", "forward", "forward_raw"]
src/fastmcp/tools/tool.py CHANGED
@@ -4,6 +4,7 @@ import inspect
4
  import json
5
  from abc import ABC, abstractmethod
6
  from collections.abc import Callable
 
7
  from typing import TYPE_CHECKING, Annotated, Any
8
 
9
  import pydantic_core
@@ -24,7 +25,7 @@ from fastmcp.utilities.types import (
24
  )
25
 
26
  if TYPE_CHECKING:
27
- pass
28
 
29
  logger = get_logger(__name__)
30
 
@@ -94,6 +95,31 @@ class Tool(FastMCPBaseModel, ABC):
94
  """Run the tool with arguments."""
95
  raise NotImplementedError("Subclasses must implement run()")
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  class FunctionTool(Tool):
99
  fn: Callable[..., Any]
@@ -110,59 +136,17 @@ class FunctionTool(Tool):
110
  serializer: Callable[[Any], str] | None = None,
111
  ) -> FunctionTool:
112
  """Create a Tool from a function."""
113
- from fastmcp.server.context import Context
114
 
115
- # Reject functions with *args or **kwargs
116
- sig = inspect.signature(fn)
117
- for param in sig.parameters.values():
118
- if param.kind == inspect.Parameter.VAR_POSITIONAL:
119
- raise ValueError("Functions with *args are not supported as tools")
120
- if param.kind == inspect.Parameter.VAR_KEYWORD:
121
- raise ValueError("Functions with **kwargs are not supported as tools")
122
 
123
- if exclude_args:
124
- for arg_name in exclude_args:
125
- if arg_name not in sig.parameters:
126
- raise ValueError(
127
- f"Parameter '{arg_name}' in exclude_args does not exist in function."
128
- )
129
- param = sig.parameters[arg_name]
130
- if param.default == inspect.Parameter.empty:
131
- raise ValueError(
132
- f"Parameter '{arg_name}' in exclude_args must have a default value."
133
- )
134
-
135
- func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
136
-
137
- if func_name == "<lambda>":
138
  raise ValueError("You must provide a name for lambda functions")
139
 
140
- func_doc = description or fn.__doc__
141
-
142
- # if the fn is a callable class, we need to get the __call__ method from here out
143
- if not inspect.isroutine(fn):
144
- fn = fn.__call__
145
- # if the fn is a staticmethod, we need to work with the underlying function
146
- if isinstance(fn, staticmethod):
147
- fn = fn.__func__
148
-
149
- type_adapter = get_cached_typeadapter(fn)
150
- schema = type_adapter.json_schema()
151
-
152
- prune_params: list[str] = []
153
- context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
154
- if context_kwarg:
155
- prune_params.append(context_kwarg)
156
- if exclude_args:
157
- prune_params.extend(exclude_args)
158
-
159
- schema = compress_schema(schema, prune_params=prune_params)
160
-
161
  return cls(
162
- fn=fn,
163
- name=func_name,
164
- description=func_doc,
165
- parameters=schema,
166
  tags=tags or set(),
167
  annotations=annotations,
168
  serializer=serializer,
@@ -217,6 +201,76 @@ class FunctionTool(Tool):
217
  return _convert_to_content(result, serializer=self.serializer)
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  def _convert_to_content(
221
  result: Any,
222
  serializer: Callable[[Any], str] | None = None,
 
4
  import json
5
  from abc import ABC, abstractmethod
6
  from collections.abc import Callable
7
+ from dataclasses import dataclass
8
  from typing import TYPE_CHECKING, Annotated, Any
9
 
10
  import pydantic_core
 
25
  )
26
 
27
  if TYPE_CHECKING:
28
+ from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
29
 
30
  logger = get_logger(__name__)
31
 
 
95
  """Run the tool with arguments."""
96
  raise NotImplementedError("Subclasses must implement run()")
97
 
98
+ @classmethod
99
+ def from_tool(
100
+ cls,
101
+ tool: Tool,
102
+ transform_fn: Callable[..., Any] | None = None,
103
+ name: str | None = None,
104
+ transform_args: dict[str, str | ArgTransform | None] | None = None,
105
+ description: str | None = None,
106
+ tags: set[str] | None = None,
107
+ annotations: ToolAnnotations | None = None,
108
+ serializer: Callable[[Any], str] | None = None,
109
+ ) -> TransformedTool:
110
+ from fastmcp.tools.tool_transform import TransformedTool
111
+
112
+ return TransformedTool.from_tool(
113
+ tool=tool,
114
+ transform_fn=transform_fn,
115
+ name=name,
116
+ transform_args=transform_args,
117
+ description=description,
118
+ tags=tags,
119
+ annotations=annotations,
120
+ serializer=serializer,
121
+ )
122
+
123
 
124
  class FunctionTool(Tool):
125
  fn: Callable[..., Any]
 
136
  serializer: Callable[[Any], str] | None = None,
137
  ) -> FunctionTool:
138
  """Create a Tool from a function."""
 
139
 
140
+ parsed_fn = ParsedFunction.from_function(fn, exclude_args=exclude_args)
 
 
 
 
 
 
141
 
142
+ if name is None and parsed_fn.name == "<lambda>":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  raise ValueError("You must provide a name for lambda functions")
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  return cls(
146
+ fn=parsed_fn.fn,
147
+ name=name or parsed_fn.name,
148
+ description=description or parsed_fn.description,
149
+ parameters=parsed_fn.parameters,
150
  tags=tags or set(),
151
  annotations=annotations,
152
  serializer=serializer,
 
201
  return _convert_to_content(result, serializer=self.serializer)
202
 
203
 
204
+ @dataclass
205
+ class ParsedFunction:
206
+ fn: Callable[..., Any]
207
+ name: str
208
+ description: str | None
209
+ parameters: dict[str, Any]
210
+
211
+ @classmethod
212
+ def from_function(
213
+ cls,
214
+ fn: Callable[..., Any],
215
+ exclude_args: list[str] | None = None,
216
+ validate: bool = True,
217
+ ) -> ParsedFunction:
218
+ from fastmcp.server.context import Context
219
+
220
+ if validate:
221
+ sig = inspect.signature(fn)
222
+ # Reject functions with *args or **kwargs
223
+ for param in sig.parameters.values():
224
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
225
+ raise ValueError("Functions with *args are not supported as tools")
226
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
227
+ raise ValueError(
228
+ "Functions with **kwargs are not supported as tools"
229
+ )
230
+
231
+ # Reject exclude_args that don't exist in the function or don't have a default value
232
+ if exclude_args:
233
+ for arg_name in exclude_args:
234
+ if arg_name not in sig.parameters:
235
+ raise ValueError(
236
+ f"Parameter '{arg_name}' in exclude_args does not exist in function."
237
+ )
238
+ param = sig.parameters[arg_name]
239
+ if param.default == inspect.Parameter.empty:
240
+ raise ValueError(
241
+ f"Parameter '{arg_name}' in exclude_args must have a default value."
242
+ )
243
+
244
+ # collect name and doc before we potentially modify the function
245
+ fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__
246
+ fn_doc = fn.__doc__
247
+
248
+ # if the fn is a callable class, we need to get the __call__ method from here out
249
+ if not inspect.isroutine(fn):
250
+ fn = fn.__call__
251
+ # if the fn is a staticmethod, we need to work with the underlying function
252
+ if isinstance(fn, staticmethod):
253
+ fn = fn.__func__
254
+
255
+ type_adapter = get_cached_typeadapter(fn)
256
+ schema = type_adapter.json_schema()
257
+
258
+ prune_params: list[str] = []
259
+ context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
260
+ if context_kwarg:
261
+ prune_params.append(context_kwarg)
262
+ if exclude_args:
263
+ prune_params.extend(exclude_args)
264
+
265
+ schema = compress_schema(schema, prune_params=prune_params)
266
+ return cls(
267
+ fn=fn,
268
+ name=fn_name,
269
+ description=fn_doc,
270
+ parameters=schema,
271
+ )
272
+
273
+
274
  def _convert_to_content(
275
  result: Any,
276
  serializer: Callable[[Any], str] | None = None,
src/fastmcp/tools/tool_transform.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """# Tool Transformation
2
+
3
+ Transform existing tools with modified schemas, argument mappings, and custom behavior.
4
+ Use this for creating tool variants, adapting tools for different contexts, or adding
5
+ custom logic while preserving the original tool's functionality.
6
+
7
+ ## Quick Reference
8
+
9
+ ### Basic Argument Renaming
10
+ ```python
11
+ # Transform specific parent arguments (others pass through unchanged)
12
+ new_tool = Tool.from_tool(
13
+ original_tool,
14
+ transform_args={"old_param": "new_param"} # Only transforms this one arg
15
+ )
16
+ ```
17
+
18
+ ### Complex Transformations
19
+ ```python
20
+ from fastmcp.tools.tool_transform import ArgTransform
21
+
22
+ new_tool = Tool.from_tool(
23
+ original_tool,
24
+ transform_args={
25
+ "old_name": ArgTransform(name="new_name", description="Updated desc"),
26
+ "unwanted": ArgTransform(drop=True),
27
+ "simple": "renamed"
28
+ }
29
+ )
30
+ ```
31
+
32
+ ### Custom Transform Functions
33
+ ```python
34
+ async def my_transform(new_x: int, new_y: int) -> str:
35
+ # Use forward() with transformed argument names
36
+ result = await forward(new_x=new_x, new_y=new_y)
37
+ return f"Custom: {result}"
38
+
39
+ new_tool = Tool.from_tool(
40
+ original_tool,
41
+ transform_fn=my_transform,
42
+ transform_args={"x": "new_x", "y": "new_y"}
43
+ )
44
+ ```
45
+
46
+ ### Using **kwargs for Flexibility
47
+ ```python
48
+ async def flexible_transform(**kwargs) -> str:
49
+ # kwargs contains all transformed arguments
50
+ result = await forward(**kwargs)
51
+ return f"Got: {kwargs}"
52
+
53
+ new_tool = Tool.from_tool(
54
+ original_tool,
55
+ transform_fn=flexible_transform,
56
+ transform_args={"x": "input_x", "y": "input_y"}
57
+ )
58
+ ```
59
+
60
+ ## Key Functions
61
+
62
+ - `forward(**kwargs)`: Call parent tool with transformed argument names
63
+ - `forward_raw(**kwargs)`: Call parent tool with original argument names
64
+
65
+ ## Important Notes
66
+
67
+ - `transform_args` is optional - if empty/None, all parent arguments pass through unchanged
68
+ - Only arguments listed in `transform_args` are transformed, others remain as-is
69
+ - Functions with `**kwargs` receive both transformed and untransformed arguments
70
+
71
+ ## ArgTransform Options
72
+
73
+ - `name`: Rename the argument
74
+ - `description`: Change the description
75
+ - `default`: Add/change default value
76
+ - `drop=True`: Remove the argument entirely
77
+
78
+ ## Common Patterns
79
+
80
+ ```python
81
+ # Chain transformations (partial transforms at each step)
82
+ tool1 = Tool.from_tool(original, transform_args={"a": "x"}) # Only transforms 'a'
83
+ tool2 = Tool.from_tool(tool1, transform_args={"x": "final"}) # Only transforms 'x'
84
+
85
+ # Pure passthrough (no transform_args needed)
86
+ enhanced = Tool.from_tool(
87
+ original,
88
+ name="enhanced_version",
89
+ description="Better tool",
90
+ tags={"v2", "enhanced"}
91
+ # No transform_args = all parent args pass through unchanged
92
+ )
93
+
94
+ # Drop specific arguments
95
+ simplified = Tool.from_tool(
96
+ complex_tool,
97
+ transform_args={"complex_config": None} # Drops only this arg
98
+ )
99
+ ```
100
+ """
101
+
102
+ from __future__ import annotations
103
+
104
+ import inspect
105
+ from collections.abc import Callable
106
+ from contextvars import ContextVar
107
+ from dataclasses import dataclass
108
+ from types import EllipsisType
109
+ from typing import TYPE_CHECKING, Any
110
+
111
+ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
112
+
113
+ from fastmcp.tools.tool import ParsedFunction, Tool
114
+ from fastmcp.utilities.logging import get_logger
115
+
116
+ if TYPE_CHECKING:
117
+ pass
118
+
119
+ logger = get_logger(__name__)
120
+
121
+
122
+ # Context variable to store current transformed tool
123
+ _current_tool: ContextVar[TransformedTool | None] = ContextVar(
124
+ "_current_tool", default=None
125
+ )
126
+
127
+
128
+ async def forward(**kwargs) -> Any:
129
+ """Forward to parent tool with argument transformation applied.
130
+
131
+ This function can only be called from within a transformed tool's custom
132
+ function. It applies argument transformation (renaming, validation) before
133
+ calling the parent tool.
134
+
135
+ For example, if the parent tool has args `x` and `y`, but the transformed
136
+ tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to
137
+ `a` and `y` to `b`, then `forward(a=1, b=2)` will call the parent tool with
138
+ `x=1` and `y=2`.
139
+
140
+ Args:
141
+ **kwargs: Arguments to forward to the parent tool (using transformed names).
142
+
143
+ Returns:
144
+ The result from the parent tool execution.
145
+
146
+ Raises:
147
+ RuntimeError: If called outside a transformed tool context.
148
+ TypeError: If provided arguments don't match the transformed schema.
149
+ """
150
+ tool = _current_tool.get()
151
+ if tool is None:
152
+ raise RuntimeError("forward() can only be called within a transformed tool")
153
+
154
+ # Use the forwarding function that handles mapping
155
+ return await tool.forwarding_fn(**kwargs)
156
+
157
+
158
+ async def forward_raw(**kwargs) -> Any:
159
+ """Forward directly to parent tool without transformation.
160
+
161
+ This function bypasses all argument transformation and validation, calling the parent
162
+ tool directly with the provided arguments. Use this when you need to call the parent
163
+ with its original parameter names and structure.
164
+
165
+ For example, if the parent tool has args `x` and `y`, then `forward_raw(x=1,
166
+ y=2)` will call the parent tool with `x=1` and `y=2`.
167
+
168
+ Args:
169
+ **kwargs: Arguments to pass directly to the parent tool (using original names).
170
+
171
+ Returns:
172
+ The result from the parent tool execution.
173
+
174
+ Raises:
175
+ RuntimeError: If called outside a transformed tool context.
176
+ """
177
+ tool = _current_tool.get()
178
+ if tool is None:
179
+ raise RuntimeError("forward_raw() can only be called within a transformed tool")
180
+
181
+ return await tool.parent_tool.run(kwargs)
182
+
183
+
184
+ @dataclass(kw_only=True)
185
+ class ArgTransform:
186
+ """Configuration for transforming a parent tool's argument.
187
+
188
+ This class allows fine-grained control over how individual arguments are transformed
189
+ when creating a new tool from an existing one. You can rename arguments, change their
190
+ descriptions, add default values, or drop them entirely.
191
+
192
+ Attributes:
193
+ name: New name for the argument. Use None to keep original name, or ... for no change.
194
+ description: New description for the argument. Use None to remove description, or ... for no change.
195
+ default: New default value for the argument. Use ... for no change.
196
+ drop: If True, remove this argument from the transformed tool's schema.
197
+
198
+ Examples:
199
+ # Rename argument 'old_name' to 'new_name'
200
+ ArgTransform(name="new_name")
201
+
202
+ # Change description only
203
+ ArgTransform(description="Updated description")
204
+
205
+ # Add a default value (makes argument optional)
206
+ ArgTransform(default=42)
207
+
208
+ # Drop the argument entirely
209
+ ArgTransform(drop=True)
210
+
211
+ # Combine multiple transformations
212
+ ArgTransform(name="new_name", description="New desc", default=None)
213
+ """
214
+
215
+ name: str | None | EllipsisType = ...
216
+ description: str | None | EllipsisType = ...
217
+ default: Any | EllipsisType = ...
218
+ drop: bool = False
219
+
220
+
221
+ class TransformedTool(Tool):
222
+ """A tool that is transformed from another tool.
223
+
224
+ This class represents a tool that has been created by transforming another tool.
225
+ It supports argument renaming, schema modification, custom function injection,
226
+ and provides context for the forward() and forward_raw() functions.
227
+
228
+ The transformation can be purely schema-based (argument renaming, dropping, etc.)
229
+ or can include a custom function that uses forward() to call the parent tool
230
+ with transformed arguments.
231
+
232
+ Attributes:
233
+ parent_tool: The original tool that this tool was transformed from.
234
+ fn: The function to execute when this tool is called (either the forwarding
235
+ function for pure transformations or a custom user function).
236
+ forwarding_fn: Internal function that handles argument transformation and
237
+ validation when forward() is called from custom functions.
238
+ """
239
+
240
+ parent_tool: Tool
241
+ fn: Callable[..., Any]
242
+ forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
243
+
244
+ async def run(
245
+ self, arguments: dict[str, Any]
246
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
247
+ """Run the tool with context set for forward() functions.
248
+
249
+ This method executes the tool's function while setting up the context
250
+ that allows forward() and forward_raw() to work correctly within custom
251
+ functions.
252
+
253
+ Args:
254
+ arguments: Dictionary of arguments to pass to the tool's function.
255
+
256
+ Returns:
257
+ List of content objects (text, image, or embedded resources) representing
258
+ the tool's output.
259
+ """
260
+ from fastmcp.tools.tool import _convert_to_content
261
+
262
+ token = _current_tool.set(self)
263
+ try:
264
+ result = await self.fn(**arguments)
265
+ return _convert_to_content(result, serializer=self.serializer)
266
+ finally:
267
+ _current_tool.reset(token)
268
+
269
+ @classmethod
270
+ def from_tool(
271
+ cls,
272
+ tool: Tool,
273
+ transform_fn: Callable[..., Any] | None = None,
274
+ name: str | None = None,
275
+ transform_args: dict[str, str | ArgTransform | None] | None = None,
276
+ description: str | None = None,
277
+ tags: set[str] | None = None,
278
+ annotations: ToolAnnotations | None = None,
279
+ serializer: Callable[[Any], str] | None = None,
280
+ ) -> TransformedTool:
281
+ """Create a transformed tool from a parent tool.
282
+
283
+ Args:
284
+ tool: The parent tool to transform.
285
+ transform_fn: Optional custom function. Can use forward() and forward_raw()
286
+ to call the parent tool. Functions with **kwargs receive transformed
287
+ argument names.
288
+ name: New name for the tool. Defaults to parent tool's name.
289
+ transform_args: Optional transformations for parent tool arguments.
290
+ Only specified arguments are transformed, others pass through unchanged:
291
+ - str: Simple rename
292
+ - ArgTransform: Complex transformation (rename/description/default/drop)
293
+ - None: Drop the argument
294
+ description: New description. Defaults to parent's description.
295
+ tags: New tags. Defaults to parent's tags.
296
+ annotations: New annotations. Defaults to parent's annotations.
297
+ serializer: New serializer. Defaults to parent's serializer.
298
+
299
+ Returns:
300
+ TransformedTool with the specified transformations.
301
+
302
+ Examples:
303
+ # Transform specific arguments only
304
+ Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged
305
+
306
+ # Custom function with partial transforms
307
+ async def custom(x: int, y: int) -> str:
308
+ result = await forward(x=x, y=y)
309
+ return f"Custom: {result}"
310
+
311
+ Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"})
312
+
313
+ # Using **kwargs (gets all args, transformed and untransformed)
314
+ async def flexible(**kwargs) -> str:
315
+ result = await forward(**kwargs)
316
+ return f"Got: {kwargs}"
317
+
318
+ Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
319
+ """
320
+
321
+ # Validate transform_args early
322
+ if transform_args:
323
+ parent_params = set(tool.parameters.get("properties", {}).keys())
324
+ unknown_args = set(transform_args.keys()) - parent_params
325
+ if unknown_args:
326
+ raise ValueError(
327
+ f"Unknown arguments in transform_args: {', '.join(sorted(unknown_args))}. "
328
+ f"Parent tool has: {', '.join(sorted(parent_params))}"
329
+ )
330
+
331
+ # Always create the forwarding transform
332
+ schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
333
+
334
+ if transform_fn is None:
335
+ # User wants pure transformation - use forwarding_fn as the main function
336
+ final_fn = forwarding_fn
337
+ final_schema = schema
338
+ else:
339
+ # User provided custom function - merge schemas
340
+ parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
341
+ final_fn = transform_fn
342
+
343
+ has_kwargs = cls._function_has_kwargs(transform_fn)
344
+
345
+ # Validate function parameters against transformed schema
346
+ fn_params = set(parsed_fn.parameters.get("properties", {}).keys())
347
+ transformed_params = set(schema.get("properties", {}).keys())
348
+
349
+ if not has_kwargs:
350
+ # Without **kwargs, function must declare all transformed params
351
+ # Check if function is missing any parameters required after transformation
352
+ missing_params = transformed_params - fn_params
353
+ if missing_params:
354
+ raise ValueError(
355
+ f"Function missing parameters required after transformation: "
356
+ f"{', '.join(sorted(missing_params))}. "
357
+ f"Function declares: {', '.join(sorted(fn_params))}"
358
+ )
359
+
360
+ # The function defines the final schema
361
+ final_schema = parsed_fn.parameters.copy()
362
+ # Inherit descriptions from transformed parent where possible
363
+ fn_props = final_schema.get("properties", {})
364
+ transformed_props = schema.get("properties", {})
365
+
366
+ for param_name in fn_props:
367
+ if param_name in transformed_props:
368
+ parent_desc = transformed_props[param_name].get("description")
369
+ if parent_desc and "description" not in fn_props[param_name]:
370
+ fn_props[param_name]["description"] = parent_desc
371
+ else:
372
+ # With **kwargs, function can access all transformed params
373
+ # Function params override transformed params if they overlap
374
+ # No validation needed - kwargs makes everything accessible
375
+
376
+ # Function accepts **kwargs, so use transformed schema as base
377
+ # and let function override specific parameters
378
+ fn_props = parsed_fn.parameters.get("properties", {})
379
+ fn_required = set(parsed_fn.parameters.get("required", []))
380
+
381
+ final_props = schema.get("properties", {}).copy()
382
+ final_required = set(schema.get("required", []))
383
+
384
+ # Override with function's parameters
385
+ for param_name, param_schema in fn_props.items():
386
+ # Inherit description from transformed parent if function doesn't provide one
387
+ if param_name in final_props and "description" not in param_schema:
388
+ param_schema = param_schema.copy()
389
+ param_schema["description"] = final_props[param_name].get(
390
+ "description"
391
+ )
392
+
393
+ final_props[param_name] = param_schema
394
+
395
+ if param_name in fn_required:
396
+ final_required.add(param_name)
397
+ else:
398
+ final_required.discard(param_name)
399
+
400
+ final_schema = {
401
+ "type": "object",
402
+ "properties": final_props,
403
+ "required": list(final_required),
404
+ }
405
+
406
+ # Additional validation: check for naming conflicts after transformation
407
+ if transform_args:
408
+ new_names = []
409
+ for old_name, transform in transform_args.items():
410
+ if isinstance(transform, str):
411
+ new_names.append(transform)
412
+ elif isinstance(transform, ArgTransform) and not transform.drop:
413
+ if transform.name is not ... and transform.name is not None:
414
+ new_names.append(transform.name)
415
+ else:
416
+ new_names.append(old_name)
417
+
418
+ # Check for duplicate names after transformation
419
+ name_counts = {}
420
+ for arg_name in new_names:
421
+ name_counts[arg_name] = name_counts.get(arg_name, 0) + 1
422
+
423
+ duplicates = [
424
+ arg_name for arg_name, count in name_counts.items() if count > 1
425
+ ]
426
+ if duplicates:
427
+ raise ValueError(
428
+ f"Multiple arguments would be mapped to the same names: "
429
+ f"{', '.join(sorted(duplicates))}"
430
+ )
431
+
432
+ final_description = description if description is not None else tool.description
433
+
434
+ return cls(
435
+ fn=final_fn,
436
+ forwarding_fn=forwarding_fn,
437
+ parent_tool=tool,
438
+ name=name or tool.name,
439
+ description=final_description,
440
+ parameters=final_schema,
441
+ tags=tags or tool.tags,
442
+ annotations=annotations or tool.annotations,
443
+ serializer=serializer or tool.serializer,
444
+ )
445
+
446
+ @classmethod
447
+ def _create_forwarding_transform(
448
+ cls,
449
+ parent_tool: Tool,
450
+ transform_args: dict[str, str | ArgTransform | None] | None,
451
+ ) -> tuple[dict[str, Any], Callable[..., Any]]:
452
+ """Create schema and forwarding function that encapsulates all transformation logic.
453
+
454
+ This method builds a new JSON schema for the transformed tool and creates a
455
+ forwarding function that validates arguments against the new schema and maps
456
+ them back to the parent tool's expected arguments.
457
+
458
+ Args:
459
+ parent_tool: The original tool to transform.
460
+ transform_args: Dictionary defining how to transform each argument.
461
+
462
+ Returns:
463
+ A tuple containing:
464
+ - dict: The new JSON schema for the transformed tool
465
+ - Callable: Async function that validates and forwards calls to the parent tool
466
+ """
467
+
468
+ # Build transformed schema and mapping
469
+ parent_props = parent_tool.parameters.get("properties", {}).copy()
470
+ parent_required = set(parent_tool.parameters.get("required", []))
471
+
472
+ new_props = {}
473
+ new_required = set()
474
+ new_to_old = {}
475
+
476
+ for old_name, old_schema in parent_props.items():
477
+ # Check if parameter is in transform_args
478
+ if transform_args and old_name in transform_args:
479
+ transform = transform_args[old_name]
480
+ else:
481
+ transform = ... # Default behavior - pass through
482
+
483
+ transform_result = cls._apply_single_transform(
484
+ old_name,
485
+ old_schema,
486
+ transform,
487
+ old_name in parent_required,
488
+ )
489
+
490
+ if transform_result:
491
+ new_name, new_schema, is_required = transform_result
492
+ new_props[new_name] = new_schema
493
+ new_to_old[new_name] = old_name
494
+ if is_required:
495
+ new_required.add(new_name)
496
+
497
+ schema = {
498
+ "type": "object",
499
+ "properties": new_props,
500
+ "required": list(new_required),
501
+ }
502
+
503
+ # Create forwarding function that closes over everything it needs
504
+ async def _forward(**kwargs):
505
+ # Validate arguments
506
+ valid_args = set(new_props.keys())
507
+ provided_args = set(kwargs.keys())
508
+ unknown_args = provided_args - valid_args
509
+
510
+ if unknown_args:
511
+ raise TypeError(
512
+ f"Got unexpected keyword argument(s): {', '.join(sorted(unknown_args))}"
513
+ )
514
+
515
+ # Check required arguments
516
+ missing_args = new_required - provided_args
517
+ if missing_args:
518
+ raise TypeError(
519
+ f"Missing required argument(s): {', '.join(sorted(missing_args))}"
520
+ )
521
+
522
+ # Map arguments to parent names
523
+ parent_args = {}
524
+ for new_name, value in kwargs.items():
525
+ old_name = new_to_old.get(new_name, new_name)
526
+ parent_args[old_name] = value
527
+
528
+ return await parent_tool.run(parent_args)
529
+
530
+ return schema, _forward
531
+
532
+ @staticmethod
533
+ def _apply_single_transform(
534
+ old_name: str,
535
+ old_schema: dict[str, Any],
536
+ transform: str | ArgTransform | None | EllipsisType,
537
+ is_required: bool,
538
+ ) -> tuple[str, dict[str, Any], bool] | None:
539
+ """Apply transformation to a single parameter.
540
+
541
+ This method handles the transformation of a single argument according to
542
+ the specified transformation rules.
543
+
544
+ Args:
545
+ old_name: Original name of the parameter.
546
+ old_schema: Original JSON schema for the parameter.
547
+ transform: Transformation to apply (string for rename, ArgTransform for complex,
548
+ None to drop, ... to pass through unchanged).
549
+ is_required: Whether the original parameter was required.
550
+
551
+ Returns:
552
+ Tuple of (new_name, new_schema, new_is_required) if parameter should be kept,
553
+ None if parameter should be dropped.
554
+ """
555
+ if transform is ...:
556
+ # Not in transform_args - pass through
557
+ return old_name, old_schema.copy(), is_required
558
+ elif transform is None:
559
+ # Explicitly set to None in transform_args - drop the parameter
560
+ return None
561
+
562
+ if isinstance(transform, str):
563
+ # Simple rename
564
+ return transform, old_schema.copy(), is_required
565
+
566
+ if isinstance(transform, ArgTransform):
567
+ if transform.drop:
568
+ return None
569
+
570
+ if transform.name is not ...:
571
+ new_name = transform.name or old_name # Handle None case
572
+ else:
573
+ new_name = old_name
574
+ new_schema = old_schema.copy()
575
+
576
+ if transform.description is not ...:
577
+ new_schema["description"] = transform.description
578
+ if transform.default is not ...:
579
+ new_schema["default"] = transform.default
580
+ is_required = False
581
+
582
+ return new_name, new_schema, is_required # type: ignore[return-value]
583
+
584
+ raise ValueError(f"Invalid transform: {transform}")
585
+
586
+ @staticmethod
587
+ def _function_has_kwargs(fn: Callable[..., Any]) -> bool:
588
+ """Check if function accepts **kwargs.
589
+
590
+ This determines whether a custom function can accept arbitrary keyword arguments,
591
+ which affects how schemas are merged during tool transformation.
592
+
593
+ Args:
594
+ fn: Function to inspect.
595
+
596
+ Returns:
597
+ True if the function has a **kwargs parameter, False otherwise.
598
+ """
599
+ sig = inspect.signature(fn)
600
+ return any(
601
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
602
+ )
tests/tools/test_tool_transform.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import Annotated, Any
3
+
4
+ import pytest
5
+ from dirty_equals import IsList
6
+ from pydantic import Field
7
+ from rich import print # type: ignore
8
+
9
+ from fastmcp import FastMCP
10
+ from fastmcp.client.client import Client
11
+ from fastmcp.tools import Tool, forward, forward_raw
12
+ from fastmcp.tools.tool import FunctionTool
13
+ from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
14
+
15
+
16
+ def get_property(tool: Tool, name: str) -> dict[str, Any]:
17
+ return tool.parameters["properties"][name]
18
+
19
+
20
+ @pytest.fixture
21
+ def add_tool() -> FunctionTool:
22
+ def add(
23
+ old_x: Annotated[int, Field(description="old_x description")], old_y: int = 10
24
+ ) -> int:
25
+ print("running!")
26
+ return old_x + old_y
27
+
28
+ return Tool.from_function(add)
29
+
30
+
31
+ def test_tool_from_tool_no_change(add_tool):
32
+ new_tool = Tool.from_tool(add_tool)
33
+ assert isinstance(new_tool, TransformedTool)
34
+ assert new_tool.parameters == add_tool.parameters
35
+ assert new_tool.name == add_tool.name
36
+ assert new_tool.description == add_tool.description
37
+
38
+
39
+ async def test_tool_change_arg_name_with_string(add_tool):
40
+ new_tool = Tool.from_tool(add_tool, transform_args={"old_x": "new_x"})
41
+
42
+ assert sorted(new_tool.parameters["properties"]) == ["new_x", "old_y"]
43
+ assert get_property(new_tool, "new_x") == get_property(add_tool, "old_x")
44
+ assert get_property(new_tool, "old_y") == get_property(add_tool, "old_y")
45
+ assert new_tool.parameters["required"] == ["new_x"]
46
+ result = await new_tool.run(arguments={"new_x": 1, "old_y": 2})
47
+ assert result[0].text == "3" # type: ignore
48
+
49
+
50
+ async def test_renamed_arg_description_is_maintained(add_tool):
51
+ new_tool = Tool.from_tool(add_tool, transform_args={"old_x": "new_x"})
52
+ assert get_property(new_tool, "new_x")["description"] == "old_x description"
53
+
54
+
55
+ async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool):
56
+ new_tool = Tool.from_tool(add_tool, transform_args={"old_x": "new_x"})
57
+ result = await new_tool.run(arguments={"new_x": 1})
58
+ assert result[0].text == "11" # type: ignore
59
+
60
+
61
+ async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
62
+ new_tool = Tool.from_tool(add_tool, transform_args={"old_y": "new_y"})
63
+ result = await new_tool.run(arguments={"old_x": 1})
64
+ assert result[0].text == "11" # type: ignore
65
+
66
+
67
+ def test_tool_change_arg_name_with_arg_transform(add_tool):
68
+ new_tool = Tool.from_tool(
69
+ add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
70
+ )
71
+
72
+ assert sorted(new_tool.parameters["properties"]) == ["new_x", "old_y"]
73
+ assert get_property(new_tool, "new_x") == get_property(add_tool, "old_x")
74
+ assert get_property(new_tool, "old_y") == get_property(add_tool, "old_y")
75
+ assert new_tool.parameters["required"] == ["new_x"]
76
+
77
+
78
+ def test_tool_change_arg_description(add_tool):
79
+ new_tool = Tool.from_tool(
80
+ add_tool, transform_args={"old_x": ArgTransform(description="new description")}
81
+ )
82
+ assert get_property(new_tool, "old_x")["description"] == "new description"
83
+
84
+
85
+ async def test_tool_drop_arg_with_none(add_tool):
86
+ # drop the arg with a default value
87
+ new_tool = Tool.from_tool(add_tool, transform_args={"old_y": None})
88
+ assert sorted(new_tool.parameters["properties"]) == ["old_x"]
89
+ result = await new_tool.run(arguments={"old_x": 1})
90
+ assert result[0].text == "11" # type: ignore
91
+
92
+
93
+ async def test_tool_drop_arg_with_arg_transform(add_tool):
94
+ new_tool = Tool.from_tool(
95
+ add_tool, transform_args={"old_y": ArgTransform(drop=True)}
96
+ )
97
+ assert sorted(new_tool.parameters["properties"]) == ["old_x"]
98
+ result = await new_tool.run(arguments={"old_x": 1})
99
+ assert result[0].text == "11" # type: ignore
100
+
101
+
102
+ async def test_dropped_args_error_if_provided(add_tool):
103
+ new_tool = Tool.from_tool(
104
+ add_tool, transform_args={"old_y": ArgTransform(drop=True)}
105
+ )
106
+ with pytest.raises(
107
+ TypeError, match="Got unexpected keyword argument\\(s\\): old_y"
108
+ ):
109
+ await new_tool.run(arguments={"old_x": 1, "old_y": 2})
110
+
111
+
112
+ async def test_forward_with_argument_mapping(add_tool):
113
+ """Test that forward() applies argument mapping correctly."""
114
+
115
+ async def custom_fn(new_x: int, new_y: int = 5) -> int:
116
+ return await forward(new_x=new_x, new_y=new_y)
117
+
118
+ new_tool = Tool.from_tool(
119
+ add_tool,
120
+ transform_fn=custom_fn,
121
+ transform_args={"old_x": "new_x", "old_y": "new_y"},
122
+ )
123
+
124
+ result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
125
+ assert result[0].text == "5" # type: ignore
126
+
127
+
128
+ async def test_forward_with_incorrect_args_raises_error(add_tool):
129
+ async def custom_fn(new_x: int, new_y: int = 5) -> int:
130
+ # the forward should use the new args, not the old ones
131
+ return await forward(old_x=new_x, old_y=new_y)
132
+
133
+ new_tool = Tool.from_tool(
134
+ add_tool,
135
+ transform_fn=custom_fn,
136
+ transform_args={"old_x": "new_x", "old_y": "new_y"},
137
+ )
138
+ with pytest.raises(
139
+ TypeError, match=re.escape("Got unexpected keyword argument(s): old_x, old_y")
140
+ ):
141
+ await new_tool.run(arguments={"new_x": 2, "new_y": 3})
142
+
143
+
144
+ async def test_forward_raw_without_argument_mapping(add_tool):
145
+ """Test that forward_raw() calls parent directly without mapping."""
146
+
147
+ async def custom_fn(new_x: int, new_y: int = 5) -> int:
148
+ # Call parent directly with original argument names
149
+ result = await forward_raw(old_x=new_x, old_y=new_y)
150
+ return result
151
+
152
+ new_tool = Tool.from_tool(
153
+ add_tool,
154
+ transform_fn=custom_fn,
155
+ transform_args={"old_x": "new_x", "old_y": "new_y"},
156
+ )
157
+
158
+ result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
159
+ assert result[0].text == "5" # type: ignore
160
+
161
+
162
+ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
163
+ async def custom_fn(extra: int, **kwargs) -> int:
164
+ sum = await forward(**kwargs)
165
+ return int(sum[0].text) + extra # type: ignore[attr-defined]
166
+
167
+ new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
168
+ result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
169
+ assert result[0].text == "6" # type: ignore
170
+ assert new_tool.parameters["required"] == IsList(
171
+ "extra", "old_x", check_order=False
172
+ )
173
+ assert list(new_tool.parameters["properties"]) == IsList(
174
+ "extra", "old_x", "old_y", check_order=False
175
+ )
176
+
177
+
178
+ async def test_fn_with_kwargs_passes_through_original_args(add_tool):
179
+ async def custom_fn(new_y: int = 5, **kwargs) -> int:
180
+ assert kwargs == {"old_y": 3}
181
+ result = await forward(old_x=new_y, **kwargs)
182
+ return result
183
+
184
+ new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
185
+ result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
186
+ assert result[0].text == "5" # type: ignore
187
+
188
+
189
+ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
190
+ """Test that **kwargs receives arguments with their transformed names from transform_args."""
191
+
192
+ async def custom_fn(new_x: int, **kwargs) -> int:
193
+ # kwargs should contain 'old_y': 3 (transformed name), not 'old_y': 3 (original name)
194
+ assert kwargs == {"old_y": 3}
195
+ result = await forward(new_x=new_x, **kwargs)
196
+ return result
197
+
198
+ new_tool = Tool.from_tool(
199
+ add_tool, transform_fn=custom_fn, transform_args={"old_x": "new_x"}
200
+ )
201
+ result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
202
+ assert result[0].text == "5" # type: ignore
203
+
204
+
205
+ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
206
+ """Test that function can explicitly handle some transformed args while others pass through kwargs."""
207
+
208
+ async def custom_fn(new_x: int, some_other_param: str = "default", **kwargs) -> int:
209
+ # x is explicitly handled, y should come through kwargs with transformed name
210
+ assert kwargs == {"old_y": 7}
211
+ result = await forward(new_x=new_x, **kwargs)
212
+ return result
213
+
214
+ new_tool = Tool.from_tool(
215
+ add_tool, transform_fn=custom_fn, transform_args={"old_x": "new_x"}
216
+ )
217
+ result = await new_tool.run(
218
+ arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
219
+ )
220
+ assert result[0].text == "10" # type: ignore
221
+
222
+
223
+ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
224
+ """Test **kwargs behavior with mix of mapped and unmapped arguments."""
225
+
226
+ async def custom_fn(new_x: int, **kwargs) -> int:
227
+ # new_x is explicitly handled, old_y should pass through kwargs with original name (unmapped)
228
+ assert kwargs == {"old_y": 5}
229
+ result = await forward(new_x=new_x, **kwargs)
230
+ return result
231
+
232
+ new_tool = Tool.from_tool(
233
+ add_tool, transform_fn=custom_fn, transform_args={"old_x": "new_x"}
234
+ ) # only map 'a'
235
+ result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
236
+ assert result[0].text == "6" # type: ignore
237
+
238
+
239
+ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
240
+ """Test that dropped arguments don't appear in **kwargs."""
241
+
242
+ async def custom_fn(new_x: int, **kwargs) -> int:
243
+ # 'b' was dropped, so kwargs should be empty
244
+ assert kwargs == {}
245
+ # Can't use 'old_y' since it was dropped, so just use 'old_x' mapped to 'new_x'
246
+ result = await forward(new_x=new_x)
247
+ return result
248
+
249
+ new_tool = Tool.from_tool(
250
+ add_tool,
251
+ transform_fn=custom_fn,
252
+ transform_args={"old_x": "new_x", "old_y": None},
253
+ ) # drop 'old_y'
254
+ result = await new_tool.run(arguments={"new_x": 8})
255
+ # 8 + 10 (default value of b in parent)
256
+ assert result[0].text == "18" # type: ignore[attr-defined]
257
+
258
+
259
+ async def test_forward_outside_context_raises_error():
260
+ """Test that forward() raises RuntimeError when called outside a transformed tool."""
261
+ with pytest.raises(
262
+ RuntimeError,
263
+ match=re.escape("forward() can only be called within a transformed tool"),
264
+ ):
265
+ await forward(new_x=1, old_y=2)
266
+
267
+
268
+ async def test_forward_raw_outside_context_raises_error():
269
+ """Test that forward_raw() raises RuntimeError when called outside a transformed tool."""
270
+ with pytest.raises(
271
+ RuntimeError,
272
+ match=re.escape("forward_raw() can only be called within a transformed tool"),
273
+ ):
274
+ await forward_raw(new_x=1, old_y=2)
275
+
276
+
277
+ def test_transform_args_validation_unknown_arg(add_tool):
278
+ """Test that transform_args with unknown arguments raises ValueError."""
279
+ with pytest.raises(
280
+ ValueError, match="Unknown arguments in transform_args: unknown_param"
281
+ ):
282
+ Tool.from_tool(add_tool, transform_args={"unknown_param": "new_name"})
283
+
284
+
285
+ def test_transform_args_creates_duplicate_names(add_tool):
286
+ """Test that transform_args creating duplicate parameter names raises ValueError."""
287
+ with pytest.raises(
288
+ ValueError,
289
+ match="Multiple arguments would be mapped to the same names: same_name",
290
+ ):
291
+ Tool.from_tool(
292
+ add_tool, transform_args={"old_x": "same_name", "old_y": "same_name"}
293
+ )
294
+
295
+
296
+ def test_transform_args_creates_duplicate_names_with_arg_transform(add_tool):
297
+ """Test that transform_args creating duplicate parameter names raises ValueError."""
298
+ with pytest.raises(
299
+ ValueError,
300
+ match="Multiple arguments would be mapped to the same names: same_name",
301
+ ):
302
+ Tool.from_tool(
303
+ add_tool,
304
+ transform_args={
305
+ "old_x": ArgTransform(name="same_name"),
306
+ "old_y": "same_name",
307
+ },
308
+ )
309
+
310
+
311
+ def test_function_without_kwargs_missing_params(add_tool):
312
+ """Test that function without **kwargs must declare all transformed params."""
313
+
314
+ def invalid_fn(new_x: int, non_existent: str) -> str:
315
+ return "test"
316
+
317
+ with pytest.raises(
318
+ ValueError,
319
+ match="Function missing parameters required after transformation: new_y",
320
+ ):
321
+ Tool.from_tool(
322
+ add_tool,
323
+ transform_fn=invalid_fn,
324
+ transform_args={"old_x": "new_x", "old_y": "new_y"},
325
+ )
326
+
327
+
328
+ def test_function_without_kwargs_can_have_extra_params(add_tool):
329
+ """Test that function without **kwargs can declare extra params beyond transformed ones."""
330
+
331
+ def valid_fn(new_x: int, new_y: int, extra_param: str = "default") -> str:
332
+ return f"{new_x + new_y}: {extra_param}"
333
+
334
+ # This should work fine - function declares all required params plus an extra one
335
+ tool = Tool.from_tool(
336
+ add_tool,
337
+ transform_fn=valid_fn,
338
+ transform_args={"old_x": "new_x", "old_y": "new_y"},
339
+ )
340
+
341
+ # The final schema should include all function parameters
342
+ assert "new_x" in tool.parameters["properties"]
343
+ assert "new_y" in tool.parameters["properties"]
344
+ assert "extra_param" in tool.parameters["properties"]
345
+
346
+
347
+ def test_function_with_kwargs_can_add_params(add_tool):
348
+ """Test that function with **kwargs can add new parameters."""
349
+
350
+ async def valid_fn(extra_param: str, **kwargs) -> str:
351
+ result = await forward(**kwargs)
352
+ return f"{extra_param}: {result}"
353
+
354
+ # This should work fine - kwargs allows access to all transformed params
355
+ tool = Tool.from_tool(
356
+ add_tool,
357
+ transform_fn=valid_fn,
358
+ transform_args={"old_x": "new_x", "old_y": "new_y"},
359
+ )
360
+
361
+ # extra_param is added, new_x and new_y are available
362
+ assert "extra_param" in tool.parameters["properties"]
363
+ assert "new_x" in tool.parameters["properties"]
364
+ assert "new_y" in tool.parameters["properties"]
365
+
366
+
367
+ async def test_chaining_transformations(add_tool):
368
+ """Test that transformed tools can be transformed again."""
369
+ # First transformation
370
+ tool1 = Tool.from_tool(add_tool, transform_args={"old_x": "x"})
371
+
372
+ # Second transformation on the already-transformed tool
373
+ tool2 = Tool.from_tool(tool1, transform_args={"x": "final_x"})
374
+
375
+ # Should work with the final names
376
+ result = await tool2.run(arguments={"final_x": 5, "old_y": 3})
377
+ assert result[0].text == "8" # type: ignore
378
+
379
+ # And forward() in a custom function should work
380
+ async def custom(final_x: int, old_y: int) -> str:
381
+ # forward() goes to tool1, which has 'final_x' and 'old_y' after transformation
382
+ result = await forward(final_x=final_x, old_y=old_y)
383
+ return f"Chained: {result}"
384
+
385
+ tool3 = Tool.from_tool(tool1, transform_fn=custom, transform_args={"x": "final_x"})
386
+
387
+ result = await tool3.run(arguments={"final_x": 5, "old_y": 3})
388
+ assert "Chained:" in result[0].text # type: ignore
389
+
390
+
391
+ class TestProxy:
392
+ @pytest.fixture
393
+ def mcp_server(self) -> FastMCP:
394
+ mcp = FastMCP()
395
+
396
+ @mcp.tool
397
+ def add(old_x: int, old_y: int = 10) -> int:
398
+ return old_x + old_y
399
+
400
+ return mcp
401
+
402
+ @pytest.fixture
403
+ def proxy_server(self, mcp_server: FastMCP) -> FastMCP:
404
+ from fastmcp.client.transports import FastMCPTransport
405
+
406
+ proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(mcp_server)))
407
+ return proxy
408
+
409
+ async def test_transform_proxy(self, proxy_server: FastMCP):
410
+ # when adding transformed tools to proxy servers. Needs separate investigation.
411
+
412
+ add_tool = await proxy_server.get_tool("add")
413
+ new_add_tool = Tool.from_tool(
414
+ add_tool, name="add_transformed", transform_args={"old_x": "new_x"}
415
+ )
416
+ proxy_server.add_tool(new_add_tool)
417
+
418
+ async with Client(proxy_server) as client:
419
+ # The tool should be registered with its transformed name
420
+ result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
421
+ assert result[0].text == "3" # type: ignore