fastmcp/tests/tools/tool_transform/test_tool_transform.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

620 lines
21 KiB
Python

"""Core tool transform functionality."""
import re
from typing import Annotated, Any
import pytest
from mcp.types import TextContent
from pydantic import BaseModel, Field
from fastmcp import FastMCP
from fastmcp.client.client import Client
from fastmcp.tools import Tool, forward, forward_raw, tool
from fastmcp.tools.base import ToolResult
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import (
ArgTransform,
TransformedTool,
)
def get_property(tool: Tool, name: str) -> dict[str, Any]:
return tool.parameters["properties"][name]
@pytest.fixture
def add_tool() -> FunctionTool:
def add(
old_x: Annotated[int, Field(description="old_x description")], old_y: int = 10
) -> int:
print("running!")
return old_x + old_y
return Tool.from_function(add)
def test_tool_from_tool_no_change(add_tool):
new_tool = Tool.from_tool(add_tool)
assert isinstance(new_tool, TransformedTool)
assert new_tool.parameters == add_tool.parameters
assert new_tool.name == add_tool.name
assert new_tool.description == add_tool.description
def test_from_tool_accepts_decorated_function():
@tool
def search(q: str, limit: int = 10) -> list[str]:
"""Search for items."""
return [f"Result {i} for {q}" for i in range(limit)]
transformed = Tool.from_tool(
search,
name="find_items",
transform_args={"q": ArgTransform(name="query")},
)
assert isinstance(transformed, TransformedTool)
assert transformed.name == "find_items"
assert "query" in transformed.parameters["properties"]
assert "q" not in transformed.parameters["properties"]
def test_from_tool_accepts_plain_function():
def search(q: str, limit: int = 10) -> list[str]:
return [f"Result {i} for {q}" for i in range(limit)]
transformed = Tool.from_tool(
search,
name="find_items",
transform_args={"q": ArgTransform(name="query")},
)
assert isinstance(transformed, TransformedTool)
assert transformed.name == "find_items"
assert "query" in transformed.parameters["properties"]
def test_from_tool_decorated_function_preserves_metadata():
@tool(description="Custom description")
def search(q: str) -> list[str]:
"""Original description."""
return []
transformed = Tool.from_tool(search)
assert transformed.parent_tool.description == "Custom description"
async def test_from_tool_decorated_function_runs(add_tool):
@tool
def add(x: int, y: int = 10) -> int:
return x + y
transformed = Tool.from_tool(
add,
transform_args={"x": ArgTransform(name="a")},
)
result = await transformed.run(arguments={"a": 3, "y": 5})
assert result.structured_content == {"result": 8}
async def test_renamed_arg_description_is_maintained(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
)
assert (
new_tool.parameters["properties"]["new_x"]["description"] == "old_x description"
)
async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
)
result = await new_tool.run(arguments={"new_x": 1})
# The parent tool returns int which gets wrapped as structured output
assert result.structured_content == {"result": 11}
async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
)
result = await new_tool.run(arguments={"old_x": 1})
# The parent tool returns int which gets wrapped as structured output
assert result.structured_content == {"result": 11}
def test_tool_change_arg_name(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
)
assert sorted(new_tool.parameters["properties"]) == ["new_x", "old_y"]
assert get_property(new_tool, "new_x") == get_property(add_tool, "old_x")
assert get_property(new_tool, "old_y") == get_property(add_tool, "old_y")
assert new_tool.parameters["required"] == ["new_x"]
def test_tool_change_arg_description(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_x": ArgTransform(description="new description")}
)
assert get_property(new_tool, "old_x")["description"] == "new description"
async def test_tool_drop_arg(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
)
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
result = await new_tool.run(arguments={"old_x": 1})
assert result.structured_content == {"result": 11}
async def test_dropped_args_error_if_provided(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
)
with pytest.raises(
TypeError, match="Got unexpected keyword argument\\(s\\): old_y"
):
await new_tool.run(arguments={"old_x": 1, "old_y": 2})
async def test_hidden_arg_with_constant_default(add_tool):
new_tool = Tool.from_tool(
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
)
result = await new_tool.run(arguments={"old_x": 1})
# old_y should use its default value of 10
assert result.structured_content == {"result": 11}
async def test_hidden_arg_without_default_uses_parent_default(add_tool):
"""Test that hidden argument without default uses parent's default."""
new_tool = Tool.from_tool(
add_tool, transform_args={"old_y": ArgTransform(hide=True)}
)
# Only old_x should be exposed
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
# Should pass old_x=3 and let parent use its default old_y=10
result = await new_tool.run(arguments={"old_x": 3})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "13"
assert result.structured_content == {"result": 13}
async def test_mixed_hidden_args_with_custom_function(add_tool):
async def custom_fn(new_x: int, **kwargs) -> str:
result = await forward(new_x=new_x, **kwargs)
assert isinstance(result.content[0], TextContent)
return f"Custom: {result.content[0].text}"
new_tool = Tool.from_tool(
add_tool,
transform_fn=custom_fn,
transform_args={
"old_x": ArgTransform(name="new_x"),
"old_y": ArgTransform(hide=True),
},
)
result = await new_tool.run(arguments={"new_x": 5})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Custom: 15"
async def test_hide_required_param_without_default_raises_error():
"""Test that hiding a required parameter without providing default raises error."""
@Tool.from_function
def tool_with_required_param(required_param: int, optional_param: int = 10) -> int:
return required_param + optional_param
# This should raise an error because required_param has no default and we're not providing one
with pytest.raises(
ValueError,
match=r"Hidden parameter 'required_param' has no default value in parent tool",
):
Tool.from_tool(
tool_with_required_param,
transform_args={"required_param": ArgTransform(hide=True)},
)
async def test_hide_required_param_with_user_default_works():
"""Test that hiding a required parameter works when user provides a default."""
@Tool.from_function
def tool_with_required_param(required_param: int, optional_param: int = 10) -> int:
return required_param + optional_param
# This should work because we're providing a default for the hidden required param
new_tool = Tool.from_tool(
tool_with_required_param,
transform_args={"required_param": ArgTransform(hide=True, default=5)},
)
# Only optional_param should be exposed
assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
# Should pass required_param=5 and optional_param=20 to parent
result = await new_tool.run(arguments={"optional_param": 20})
assert result.structured_content == {"result": 25}
async def test_hidden_param_prunes_defs():
class VisibleType(BaseModel):
x: int
class HiddenType(BaseModel):
y: int
@Tool.from_function
def tool_with_refs(a: VisibleType, b: HiddenType | None = None) -> int:
return a.x + (b.y if b else 0)
# Hide parameter 'b'
new_tool = Tool.from_tool(
tool_with_refs, transform_args={"b": ArgTransform(hide=True)}
)
schema = new_tool.parameters
# Only 'a' should be visible
assert list(schema["properties"].keys()) == ["a"]
# HiddenType should be pruned from $defs
assert "HiddenType" not in schema.get("$defs", {})
# VisibleType should remain in $defs and be referenced via $ref
assert schema["properties"]["a"] == {"$ref": "#/$defs/VisibleType"}
assert schema["$defs"]["VisibleType"] == {
"properties": {"x": {"type": "integer"}},
"required": ["x"],
"type": "object",
}
async def test_forward_with_argument_mapping(add_tool):
async def custom_fn(new_x: int, **kwargs) -> str:
result = await forward(new_x=new_x, **kwargs)
assert isinstance(result.content[0], TextContent)
return f"Mapped: {result.content[0].text}"
new_tool = Tool.from_tool(
add_tool,
transform_fn=custom_fn,
transform_args={"old_x": ArgTransform(name="new_x")},
)
result = await new_tool.run(arguments={"new_x": 3, "old_y": 7})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Mapped: 10"
async def test_forward_with_incorrect_args_raises_error(add_tool):
async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
# the forward should use the new args, not the old ones
return await forward(old_x=new_x, old_y=new_y)
new_tool = Tool.from_tool(
add_tool,
transform_fn=custom_fn,
transform_args={
"old_x": ArgTransform(name="new_x"),
"old_y": ArgTransform(name="new_y"),
},
)
with pytest.raises(
TypeError, match=re.escape("Got unexpected keyword argument(s): old_x, old_y")
):
await new_tool.run(arguments={"new_x": 2, "new_y": 3})
async def test_forward_raw_without_argument_mapping(add_tool):
async def custom_fn(**kwargs) -> str:
# forward_raw passes through kwargs as-is
result = await forward_raw(**kwargs)
assert isinstance(result.content[0], TextContent)
return f"Raw: {result.content[0].text}"
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
result = await new_tool.run(arguments={"old_x": 2, "old_y": 8})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Raw: 10"
async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
async def custom_fn(**kwargs) -> str:
result = await forward(**kwargs)
assert isinstance(result.content[0], TextContent)
return f"Custom: {result.content[0].text}"
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
result = await new_tool.run(arguments={"old_x": 4, "old_y": 6})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Custom: 10"
async def test_fn_with_kwargs_passes_through_original_args(add_tool):
async def custom_fn(**kwargs) -> str:
# Should receive original arg names
assert "old_x" in kwargs
assert "old_y" in kwargs
result = await forward(**kwargs)
assert isinstance(result.content[0], TextContent)
return result.content[0].text
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
result = await new_tool.run(arguments={"old_x": 1, "old_y": 2})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "3"
async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
"""Test that **kwargs receives arguments with their transformed names from transform_args."""
async def custom_fn(new_x: int, **kwargs) -> ToolResult:
# kwargs should contain 'old_y': 3 (transformed name), not 'old_y': 3 (original name)
assert kwargs == {"old_y": 3}
result = await forward(new_x=new_x, **kwargs)
return result
new_tool = Tool.from_tool(
add_tool,
transform_fn=custom_fn,
transform_args={"old_x": ArgTransform(name="new_x")},
)
result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "5"
assert result.structured_content == {"result": 5}
async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
async def custom_fn(new_x: int, **kwargs) -> str:
result = await forward(new_x=new_x, **kwargs)
assert isinstance(result.content[0], TextContent)
return result.content[0].text
new_tool = Tool.from_tool(
add_tool,
transform_fn=custom_fn,
transform_args={"old_x": ArgTransform(name="new_x")},
)
# Only provide new_x, old_y should use default
result = await new_tool.run(arguments={"new_x": 7})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "17" # 7 + 10 (default)
async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
async def custom_fn(new_x: int, old_y: int, **kwargs) -> str:
result = await forward(new_x=new_x, old_y=old_y, **kwargs)
assert isinstance(result.content[0], TextContent)
return result.content[0].text
new_tool = Tool.from_tool(
add_tool,
transform_fn=custom_fn,
transform_args={"old_x": ArgTransform(name="new_x")},
)
result = await new_tool.run(arguments={"new_x": 2, "old_y": 8})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "10"
async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
async def custom_fn(new_x: int, **kwargs) -> str:
# old_y is dropped, so it shouldn't be in kwargs
assert "old_y" not in kwargs
result = await forward(new_x=new_x, **kwargs)
assert isinstance(result.content[0], TextContent)
return result.content[0].text
new_tool = Tool.from_tool(
add_tool,
transform_fn=custom_fn,
transform_args={
"old_x": ArgTransform(name="new_x"),
"old_y": ArgTransform(hide=True),
},
)
result = await new_tool.run(arguments={"new_x": 3})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "13" # 3 + 10 (default for hidden old_y)
async def test_forward_outside_context_raises_error():
"""Test that forward() raises error when called outside transform context."""
with pytest.raises(RuntimeError, match=r"forward\(\) can only be called"):
await forward(x=1)
async def test_forward_raw_outside_context_raises_error():
"""Test that forward_raw() raises error when called outside transform context."""
with pytest.raises(RuntimeError, match=r"forward_raw\(\) can only be called"):
await forward_raw(x=1)
def test_transform_args_with_parent_defaults():
"""Test that transform_args with parent defaults works."""
class CoolModel(BaseModel):
x: int = 10
def parent_tool(cool_model: CoolModel) -> int:
return cool_model.x
tool = Tool.from_function(parent_tool)
new_tool = Tool.from_tool(tool)
# Both tools should have the same schema (with $ref/$defs preserved)
assert new_tool.parameters == tool.parameters
def test_transform_args_validation_unknown_arg(add_tool):
"""Test that transform_args with unknown arguments raises ValueError."""
with pytest.raises(
ValueError, match="Unknown arguments in transform_args: unknown_param"
) as exc_info:
Tool.from_tool(
add_tool, transform_args={"unknown_param": ArgTransform(name="new_name")}
)
assert "`add`" in str(exc_info.value)
def test_transform_args_creates_duplicate_names(add_tool):
"""Test that transform_args creating duplicate parameter names raises ValueError."""
with pytest.raises(
ValueError,
match="Multiple arguments would be mapped to the same names: same_name",
):
Tool.from_tool(
add_tool,
transform_args={
"old_x": ArgTransform(name="same_name"),
"old_y": ArgTransform(name="same_name"),
},
)
def test_transform_args_collision_with_passthrough_name(add_tool):
"""Test that renaming to a passthrough parameter name raises ValueError."""
with pytest.raises(
ValueError,
match="Multiple arguments would be mapped to the same names: old_y",
):
Tool.from_tool(
add_tool,
transform_args={
"old_x": ArgTransform(name="old_y"),
},
)
def test_function_without_kwargs_missing_params(add_tool):
"""Test that function missing required transformed parameters raises ValueError."""
def invalid_fn(new_x: int, non_existent: str) -> str:
return f"{new_x}_{non_existent}"
with pytest.raises(
ValueError,
match="Function missing parameters required after transformation: new_y",
):
Tool.from_tool(
add_tool,
transform_fn=invalid_fn,
transform_args={
"old_x": ArgTransform(name="new_x"),
"old_y": ArgTransform(name="new_y"),
},
)
def test_function_without_kwargs_can_have_extra_params(add_tool):
"""Test that function can have extra parameters not in parent tool."""
def valid_fn(new_x: int, new_y: int, extra_param: str = "default") -> str:
return f"{new_x}_{new_y}_{extra_param}"
# Should work - extra_param is fine as long as it has a default
new_tool = Tool.from_tool(
add_tool,
transform_fn=valid_fn,
transform_args={
"old_x": ArgTransform(name="new_x"),
"old_y": ArgTransform(name="new_y"),
},
)
# The final schema should include all function parameters
assert "new_x" in new_tool.parameters["properties"]
assert "new_y" in new_tool.parameters["properties"]
assert "extra_param" in new_tool.parameters["properties"]
def test_function_with_kwargs_can_add_params(add_tool):
"""Test that function with **kwargs can add new parameters."""
async def valid_fn(extra_param: str, **kwargs) -> str:
result = await forward(**kwargs)
return f"{extra_param}: {result}"
# This should work fine - kwargs allows access to all transformed params
tool = Tool.from_tool(
add_tool,
transform_fn=valid_fn,
transform_args={
"old_x": ArgTransform(name="new_x"),
"old_y": ArgTransform(name="new_y"),
},
)
# extra_param is added, new_x and new_y are available
assert "extra_param" in tool.parameters["properties"]
assert "new_x" in tool.parameters["properties"]
async def test_from_tool_decorated_function_via_client():
@tool
def search(q: str, limit: int = 10) -> list[str]:
"""Search for items."""
return [f"Result {i} for {q}" for i in range(limit)]
better_search = Tool.from_tool(
search,
name="find_items",
transform_args={
"q": ArgTransform(name="query", description="The search terms"),
},
)
mcp = FastMCP("Server")
mcp.add_tool(better_search)
async with Client(mcp) as client:
result = await client.call_tool("find_items", {"query": "hello", "limit": 3})
assert isinstance(result.content[0], TextContent)
assert "Result 0 for hello" in result.content[0].text
class TestProxy:
@pytest.fixture
def mcp_server(self) -> FastMCP:
mcp = FastMCP()
@mcp.tool
def add(old_x: int, old_y: int = 10) -> int:
return old_x + old_y
return mcp
@pytest.fixture
def proxy_server(self, mcp_server: FastMCP) -> FastMCP:
from fastmcp.client.transports import FastMCPTransport
proxy = FastMCP.as_proxy(FastMCPTransport(mcp_server))
return proxy
async def test_transform_proxy(self, proxy_server: FastMCP):
# when adding transformed tools to proxy servers. Needs separate investigation.
add_tool = await proxy_server.get_tool("add")
assert add_tool is not None
new_add_tool = Tool.from_tool(
add_tool,
name="add_transformed",
transform_args={"old_x": ArgTransform(name="new_x")},
)
proxy_server.add_tool(new_add_tool)
async with Client(proxy_server) as client:
# The tool should be registered with its transformed name
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "3"