Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
1f34d30
1
Parent(s): cf04a1f
Add tool transformation
Browse files- src/fastmcp/tools/__init__.py +2 -1
- src/fastmcp/tools/tool.py +103 -49
- src/fastmcp/tools/tool_transform.py +602 -0
- tests/tools/test_tool_transform.py +421 -0
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 |
-
|
| 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 |
-
|
| 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
|
| 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=
|
| 164 |
-
description=
|
| 165 |
-
parameters=
|
| 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
|