60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
457 lines
14 KiB
Python
457 lines
14 KiB
Python
"""Tests for argument transformation in tool transforms."""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Annotated, Any
|
|
|
|
import pytest
|
|
from mcp_types import TextContent
|
|
from pydantic import BaseModel, Field
|
|
from typing_extensions import TypedDict
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client.client import Client
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp.tools import Tool, forward, forward_raw
|
|
from fastmcp.tools.function_tool import FunctionTool
|
|
from fastmcp.tools.tool_transform import (
|
|
ArgTransform,
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
async def test_tool_transform_chaining(add_tool):
|
|
"""Test that transformed tools can be transformed again."""
|
|
# First transformation: a -> x
|
|
tool1 = Tool.from_tool(add_tool, transform_args={"old_x": ArgTransform(name="x")})
|
|
|
|
# Second transformation: x -> final_x, using tool1
|
|
tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
|
|
|
|
result = await tool2.run(arguments={"final_x": 5})
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == "15"
|
|
|
|
# Transform tool1 with custom function that handles all parameters
|
|
async def custom(final_x: int, **kwargs) -> str:
|
|
result = await forward(final_x=final_x, **kwargs)
|
|
assert isinstance(result.content[0], TextContent)
|
|
return f"custom {result.content[0].text}" # Extract text from content
|
|
|
|
tool3 = Tool.from_tool(
|
|
tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
|
|
)
|
|
result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == "custom 8"
|
|
|
|
|
|
class MyModel(BaseModel):
|
|
x: int
|
|
y: str
|
|
|
|
|
|
@dataclass
|
|
class MyDataclass:
|
|
x: int
|
|
y: str
|
|
|
|
|
|
class MyTypedDict(TypedDict):
|
|
x: int
|
|
y: str
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"py_type, json_type",
|
|
[
|
|
(int, "integer"),
|
|
(str, "string"),
|
|
(float, "number"),
|
|
(bool, "boolean"),
|
|
(MyModel, "object"),
|
|
(MyDataclass, "object"),
|
|
(MyTypedDict, "object"),
|
|
],
|
|
)
|
|
def test_arg_transform_type_handling(add_tool, py_type, json_type):
|
|
new_tool = Tool.from_tool(
|
|
add_tool, transform_args={"old_x": ArgTransform(type=py_type)}
|
|
)
|
|
prop = get_property(new_tool, "old_x")
|
|
assert prop["type"] == json_type
|
|
|
|
|
|
def test_arg_transform_annotated_types(add_tool):
|
|
new_tool = Tool.from_tool(
|
|
add_tool,
|
|
transform_args={
|
|
"old_x": ArgTransform(
|
|
type=Annotated[int, Field(ge=0, le=100)], description="A number 0-100"
|
|
)
|
|
},
|
|
)
|
|
prop = get_property(new_tool, "old_x")
|
|
assert prop["type"] == "integer"
|
|
assert prop["description"] == "A number 0-100"
|
|
assert prop["minimum"] == 0
|
|
assert prop["maximum"] == 100
|
|
|
|
|
|
def test_arg_transform_precedence_over_function_without_kwargs():
|
|
def base(x: int) -> int:
|
|
return x
|
|
|
|
tool = Tool.from_function(base)
|
|
new_tool = Tool.from_tool(
|
|
tool, transform_args={"x": ArgTransform(type=str, description="String input")}
|
|
)
|
|
|
|
prop = get_property(new_tool, "x")
|
|
assert prop["type"] == "string"
|
|
assert prop["description"] == "String input"
|
|
|
|
|
|
async def test_arg_transform_precedence_over_function_with_kwargs():
|
|
"""Test that ArgTransform attributes take precedence over function signature (with **kwargs)."""
|
|
|
|
@Tool.from_function
|
|
def base(x: int, y: str = "base_default") -> str:
|
|
return f"{x}: {y}"
|
|
|
|
# Function signature has different types/defaults than ArgTransform
|
|
async def custom_fn(x: str = "function_default", **kwargs) -> str:
|
|
result = await forward(x=x, **kwargs)
|
|
assert isinstance(result.content[0], TextContent)
|
|
return f"custom: {result.content[0].text}"
|
|
|
|
tool = Tool.from_tool(
|
|
base,
|
|
transform_fn=custom_fn,
|
|
transform_args={
|
|
"x": ArgTransform(type=int, default=42), # Different type and default
|
|
"y": ArgTransform(description="ArgTransform description"),
|
|
},
|
|
)
|
|
|
|
# ArgTransform should take precedence
|
|
x_prop = get_property(tool, "x")
|
|
y_prop = get_property(tool, "y")
|
|
|
|
assert x_prop["type"] == "integer" # ArgTransform type wins over function's str
|
|
assert x_prop["default"] == 42 # ArgTransform default wins over function's default
|
|
assert (
|
|
y_prop["description"] == "ArgTransform description"
|
|
) # ArgTransform description
|
|
|
|
# x should not be required due to ArgTransform default
|
|
assert "x" not in tool.parameters["required"]
|
|
|
|
# Test it works at runtime
|
|
result = await tool.run(arguments={"y": "test"})
|
|
# Should use ArgTransform default of 42
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert "42: test" in result.content[0].text
|
|
|
|
|
|
def test_arg_transform_combined_attributes(add_tool):
|
|
new_tool = Tool.from_tool(
|
|
add_tool,
|
|
transform_args={
|
|
"old_x": ArgTransform(
|
|
name="new_x",
|
|
description="New description",
|
|
type=str,
|
|
)
|
|
},
|
|
)
|
|
|
|
prop = get_property(new_tool, "new_x")
|
|
assert prop["type"] == "string"
|
|
assert prop["description"] == "New description"
|
|
assert "old_x" not in new_tool.parameters["properties"]
|
|
|
|
|
|
async def test_arg_transform_type_precedence_runtime():
|
|
"""Test that ArgTransform type changes work correctly at runtime."""
|
|
|
|
@Tool.from_function
|
|
def base(x: int, y: int = 10) -> int:
|
|
return x + y
|
|
|
|
# Transform x to string type but keep same logic
|
|
async def custom_fn(x: str, y: int = 10) -> str:
|
|
# Convert string back to int for the original function
|
|
result = await forward_raw(x=int(x), y=y)
|
|
# Extract the text from the result
|
|
assert isinstance(result.content[0], TextContent)
|
|
result_text = result.content[0].text
|
|
return f"String input '{x}' converted to result: {result_text}"
|
|
|
|
tool = Tool.from_tool(
|
|
base, transform_fn=custom_fn, transform_args={"x": ArgTransform(type=str)}
|
|
)
|
|
|
|
# Verify schema shows string type
|
|
assert get_property(tool, "x")["type"] == "string"
|
|
|
|
# Test it works with string input
|
|
result = await tool.run(arguments={"x": "5", "y": 3})
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert "String input '5'" in result.content[0].text
|
|
assert "result: 8" in result.content[0].text
|
|
|
|
|
|
async def test_arg_transform_default_factory():
|
|
"""Test ArgTransform with default_factory for hidden parameters."""
|
|
import asyncio
|
|
import time
|
|
|
|
@Tool.from_function
|
|
def base_tool(x: int, timestamp: float) -> str:
|
|
return f"{x}_{timestamp}"
|
|
|
|
new_tool = Tool.from_tool(
|
|
base_tool,
|
|
transform_args={
|
|
"timestamp": ArgTransform(hide=True, default_factory=time.time)
|
|
},
|
|
)
|
|
|
|
result1 = await new_tool.run(arguments={"x": 1})
|
|
await asyncio.sleep(0.01)
|
|
result2 = await new_tool.run(arguments={"x": 2})
|
|
|
|
# Each call should get a different timestamp
|
|
assert isinstance(result1.content[0], TextContent)
|
|
assert isinstance(result2.content[0], TextContent)
|
|
assert result1.content[0].text != result2.content[0].text
|
|
assert "1_" in result1.content[0].text
|
|
assert "2_" in result2.content[0].text
|
|
|
|
|
|
async def test_arg_transform_default_factory_called_each_time():
|
|
"""Test that default_factory is called for each tool execution."""
|
|
|
|
call_count = {"count": 0}
|
|
|
|
def get_counter():
|
|
call_count["count"] += 1
|
|
return call_count["count"]
|
|
|
|
@Tool.from_function
|
|
def base_tool(x: int, counter: int) -> str:
|
|
return f"{x}_{counter}"
|
|
|
|
new_tool = Tool.from_tool(
|
|
base_tool,
|
|
transform_args={
|
|
"counter": ArgTransform(hide=True, default_factory=get_counter)
|
|
},
|
|
)
|
|
|
|
result1 = await new_tool.run(arguments={"x": 1})
|
|
result2 = await new_tool.run(arguments={"x": 2})
|
|
result3 = await new_tool.run(arguments={"x": 3})
|
|
|
|
# Each call should increment the counter
|
|
assert isinstance(result1.content[0], TextContent)
|
|
assert isinstance(result2.content[0], TextContent)
|
|
assert isinstance(result3.content[0], TextContent)
|
|
assert "1_1" in result1.content[0].text
|
|
assert "2_2" in result2.content[0].text
|
|
assert "3_3" in result3.content[0].text
|
|
|
|
|
|
async def test_arg_transform_hidden_with_default_factory():
|
|
"""Test that hidden parameters with default_factory work correctly."""
|
|
|
|
@Tool.from_function
|
|
def base_tool(x: int, session_id: str) -> str:
|
|
return f"{x}_{session_id}"
|
|
|
|
import uuid
|
|
|
|
new_tool = Tool.from_tool(
|
|
base_tool,
|
|
transform_args={
|
|
"session_id": ArgTransform(
|
|
hide=True, default_factory=lambda: str(uuid.uuid4())
|
|
)
|
|
},
|
|
)
|
|
|
|
result = await new_tool.run(arguments={"x": 1})
|
|
# Should have a UUID in the result
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert "1_" in result.content[0].text
|
|
assert len(result.content[0].text.split("_")[1]) > 10
|
|
|
|
|
|
async def test_arg_transform_default_and_factory_raises_error():
|
|
"""Test that providing both default and default_factory raises an error."""
|
|
with pytest.raises(
|
|
ValueError, match="Cannot specify both 'default' and 'default_factory'"
|
|
):
|
|
ArgTransform(default=10, default_factory=lambda: 20)
|
|
|
|
|
|
async def test_arg_transform_default_factory_requires_hide():
|
|
"""Test that default_factory requires hide=True."""
|
|
with pytest.raises(
|
|
ValueError, match="default_factory can only be used with hide=True"
|
|
):
|
|
ArgTransform(default_factory=lambda: 10)
|
|
|
|
|
|
async def test_arg_transform_required_true(add_tool):
|
|
"""Test ArgTransform with required=True."""
|
|
new_tool = Tool.from_tool(
|
|
add_tool,
|
|
transform_args={"old_y": ArgTransform(required=True)},
|
|
)
|
|
|
|
# old_y should now be required (even though it had a default)
|
|
assert "old_y" in new_tool.parameters["required"]
|
|
|
|
|
|
async def test_arg_transform_required_false():
|
|
"""Test ArgTransform with required=False by setting a default."""
|
|
|
|
def func(x: int, y: int) -> int:
|
|
return x + y
|
|
|
|
tool = Tool.from_function(func)
|
|
# Setting a default makes it not required
|
|
new_tool = Tool.from_tool(tool, transform_args={"y": ArgTransform(default=0)})
|
|
|
|
# y should not be required since it has a default
|
|
assert "y" not in new_tool.parameters.get("required", [])
|
|
|
|
|
|
async def test_arg_transform_required_with_rename(add_tool):
|
|
"""Test ArgTransform with required and rename."""
|
|
new_tool = Tool.from_tool(
|
|
add_tool,
|
|
transform_args={"old_y": ArgTransform(name="new_y", required=True)},
|
|
)
|
|
|
|
# new_y should be required
|
|
assert "new_y" in new_tool.parameters["required"]
|
|
assert "old_y" not in new_tool.parameters["properties"]
|
|
|
|
|
|
async def test_arg_transform_required_true_with_default_raises_error():
|
|
"""Test that required=True with default raises an error."""
|
|
with pytest.raises(
|
|
ValueError, match="Cannot specify 'required=True' with 'default'"
|
|
):
|
|
ArgTransform(required=True, default=42)
|
|
|
|
|
|
async def test_arg_transform_required_true_with_factory_raises_error():
|
|
"""Test that required=True with default_factory raises an error."""
|
|
with pytest.raises(
|
|
ValueError, match="default_factory can only be used with hide=True"
|
|
):
|
|
ArgTransform(required=True, default_factory=lambda: 42)
|
|
|
|
|
|
async def test_arg_transform_required_no_change():
|
|
"""Test that not specifying required doesn't change existing required status."""
|
|
|
|
def func(x: int, y: int) -> int:
|
|
return x + y
|
|
|
|
tool = Tool.from_function(func)
|
|
# Both x and y are required in original
|
|
assert "x" in tool.parameters["required"]
|
|
assert "y" in tool.parameters["required"]
|
|
|
|
# Not specifying required should keep x required
|
|
new_tool = Tool.from_tool(
|
|
tool, transform_args={"x": ArgTransform(description="Updated x")}
|
|
)
|
|
|
|
# x should still be required, and y should still be
|
|
assert "x" in new_tool.parameters.get("required", [])
|
|
assert "y" in new_tool.parameters["required"]
|
|
|
|
|
|
async def test_arg_transform_hide_and_required_raises_error():
|
|
"""Test that hide=True and required=True together raises an error."""
|
|
with pytest.raises(
|
|
ValueError, match="Cannot specify both 'hide=True' and 'required=True'"
|
|
):
|
|
ArgTransform(hide=True, required=True)
|
|
|
|
|
|
class TestEnableDisable:
|
|
async def test_transform_disabled_tool(self):
|
|
"""
|
|
Tests that a transformed tool can run even if the parent tool is disabled via server.
|
|
"""
|
|
mcp = FastMCP()
|
|
|
|
@mcp.tool
|
|
def add(x: int, y: int = 10) -> int:
|
|
return x + y
|
|
|
|
# Get the registered Tool object from the server
|
|
add_tool = await mcp._local_provider.get_tool("add")
|
|
assert isinstance(add_tool, Tool)
|
|
new_add = Tool.from_tool(add_tool, name="new_add")
|
|
mcp.add_tool(new_add)
|
|
|
|
# Disable original tool, but new_add should still work
|
|
mcp.disable(names={"add"}, components={"tool"})
|
|
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
assert {tool.name for tool in tools} == {"new_add"}
|
|
|
|
result = await client.call_tool("new_add", {"x": 1, "y": 2})
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == "3"
|
|
|
|
with pytest.raises(ToolError):
|
|
await client.call_tool("add", {"x": 1, "y": 2})
|
|
|
|
async def test_disable_transformed_tool(self):
|
|
mcp = FastMCP()
|
|
|
|
@mcp.tool
|
|
def add(x: int, y: int = 10) -> int:
|
|
return x + y
|
|
|
|
# Get the registered Tool object from the server
|
|
add_tool = await mcp._local_provider.get_tool("add")
|
|
assert isinstance(add_tool, Tool)
|
|
new_add = Tool.from_tool(add_tool, name="new_add")
|
|
mcp.add_tool(new_add)
|
|
|
|
# Disable both tools via server
|
|
mcp.disable(names={"add"}, components={"tool"}).disable(
|
|
names={"new_add"}, components={"tool"}
|
|
)
|
|
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
assert len(tools) == 0
|
|
|
|
with pytest.raises(ToolError):
|
|
await client.call_tool("new_add", {"x": 1, "y": 2})
|