Jeremiah Lowin commited on
Commit
d20b356
·
1 Parent(s): 77d4a9a

Add tool tests

Browse files
src/fastmcp/models.py DELETED
@@ -1,18 +0,0 @@
1
- """Pydantic models for FastMCP."""
2
-
3
- from typing import Callable, Optional, Type
4
-
5
- from pydantic import BaseModel
6
-
7
-
8
- class Tool(BaseModel):
9
- """Internal tool registration info."""
10
-
11
- model_config: dict = dict(arbitrary_types_allowed=True)
12
-
13
- func: Callable
14
- name: str
15
- description: str
16
- input_schema: dict
17
- is_async: bool
18
- pydantic_model: Optional[Type[BaseModel]] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/server.py CHANGED
@@ -35,7 +35,7 @@ class FastMCPServer:
35
  Tool(
36
  name=info.name,
37
  description=info.description,
38
- inputSchema=info.input_schema,
39
  )
40
  for info in tools
41
  ]
 
35
  Tool(
36
  name=info.name,
37
  description=info.description,
38
+ inputSchema=info.parameters,
39
  )
40
  for info in tools
41
  ]
src/fastmcp/tools.py CHANGED
@@ -1,12 +1,54 @@
1
  """Tool management for FastMCP."""
2
 
3
  import inspect
4
- from typing import Any, Callable, Dict, Optional, get_type_hints
5
 
6
- from pydantic import BaseModel, create_model
7
 
8
  from .exceptions import ToolError
9
- from .models import Tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
 
12
  class ToolManager:
@@ -30,61 +72,12 @@ class ToolManager:
30
  description: Optional[str] = None,
31
  ) -> None:
32
  """Add a tool to the server."""
33
- func_name = name or func.__name__
34
- func_doc = description or func.__doc__ or ""
35
- is_async = inspect.iscoroutinefunction(func)
36
-
37
- # Get type hints for parameters
38
- hints = get_type_hints(func)
39
- if "return" in hints:
40
- del hints["return"]
41
-
42
- # Check for Pydantic model parameter
43
- if len(hints) == 1 and issubclass(next(iter(hints.values())), BaseModel):
44
- model = next(iter(hints.values()))
45
- schema = model.model_json_schema()
46
- pydantic_model = model
47
- else:
48
- # Create parameter schema from type hints
49
- fields = {}
50
- sig = inspect.signature(func)
51
- for param_name, param in sig.parameters.items():
52
- param_type = hints.get(param_name, Any)
53
- default = (
54
- ... if param.default is inspect.Parameter.empty else param.default
55
- )
56
- fields[param_name] = (param_type, default)
57
-
58
- model = create_model(f"{func_name}Args", **fields)
59
- schema = model.model_json_schema()
60
- pydantic_model = model
61
-
62
- self._tools[func_name] = Tool(
63
- func=func,
64
- name=func_name,
65
- description=func_doc,
66
- input_schema=schema,
67
- is_async=is_async,
68
- pydantic_model=pydantic_model,
69
- )
70
 
71
  async def call_tool(self, name: str, arguments: dict) -> Any:
72
  """Call a tool by name with arguments."""
73
  tool = self.get_tool(name)
74
  if not tool:
75
  raise ToolError(f"Unknown tool: {name}")
76
-
77
- try:
78
- # Validate arguments using schema
79
- if tool.pydantic_model:
80
- validated_args = tool.pydantic_model(**arguments)
81
- args_dict = validated_args.model_dump()
82
- else:
83
- args_dict = arguments
84
-
85
- # Call function with proper async handling
86
- if tool.is_async:
87
- return await tool.func(**args_dict)
88
- return tool.func(**args_dict)
89
- except Exception as e:
90
- raise ToolError(f"Error executing tool {name}: {e}") from e
 
1
  """Tool management for FastMCP."""
2
 
3
  import inspect
4
+ from typing import Any, Callable, Dict, Optional
5
 
6
+ from pydantic import BaseModel, Field, TypeAdapter
7
 
8
  from .exceptions import ToolError
9
+
10
+
11
+ class Tool(BaseModel):
12
+ """Internal tool registration info."""
13
+
14
+ func: Callable = Field(exclude=True)
15
+ name: str = Field(description="Name of the tool")
16
+ description: str = Field(description="Description of what the tool does")
17
+ parameters: dict = Field(description="JSON schema for tool parameters")
18
+ is_async: bool = Field(description="Whether the tool is async")
19
+
20
+ @classmethod
21
+ def from_function(
22
+ cls,
23
+ func: Callable,
24
+ name: Optional[str] = None,
25
+ description: Optional[str] = None,
26
+ ) -> "Tool":
27
+ """Create a Tool from a function."""
28
+ func_name = name or func.__name__
29
+ func_doc = description or func.__doc__ or ""
30
+ is_async = inspect.iscoroutinefunction(func)
31
+
32
+ # Get schema from TypeAdapter - will fail if function isn't properly typed
33
+ schema = TypeAdapter(func).json_schema()
34
+
35
+ return cls(
36
+ func=func,
37
+ name=func_name,
38
+ description=func_doc,
39
+ parameters=schema,
40
+ is_async=is_async,
41
+ )
42
+
43
+ async def run(self, arguments: dict) -> Any:
44
+ """Run the tool with arguments."""
45
+ try:
46
+ # Call function with proper async handling
47
+ if self.is_async:
48
+ return await self.func(**arguments)
49
+ return self.func(**arguments)
50
+ except Exception as e:
51
+ raise ToolError(f"Error executing tool {self.name}: {e}") from e
52
 
53
 
54
  class ToolManager:
 
72
  description: Optional[str] = None,
73
  ) -> None:
74
  """Add a tool to the server."""
75
+ tool = Tool.from_function(func, name=name, description=description)
76
+ self._tools[tool.name] = tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  async def call_tool(self, name: str, arguments: dict) -> Any:
79
  """Call a tool by name with arguments."""
80
  tool = self.get_tool(name)
81
  if not tool:
82
  raise ToolError(f"Unknown tool: {name}")
83
+ return await tool.run(arguments)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_tools.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test tool registration and execution."""
2
+
3
+ import pytest
4
+ from pydantic import BaseModel
5
+
6
+ from fastmcp.exceptions import ToolError
7
+ from fastmcp.tools import ToolManager
8
+
9
+
10
+ class TestAddTools:
11
+ def test_basic_function(self):
12
+ """Test registering and running a basic function."""
13
+
14
+ def add(a: int, b: int) -> int:
15
+ """Add two numbers."""
16
+ return a + b
17
+
18
+ manager = ToolManager()
19
+ manager.add_tool(add)
20
+
21
+ tool = manager.get_tool("add")
22
+ assert tool is not None
23
+ assert tool.name == "add"
24
+ assert tool.description == "Add two numbers."
25
+ assert tool.is_async is False
26
+ assert tool.parameters["properties"]["a"]["type"] == "integer"
27
+ assert tool.parameters["properties"]["b"]["type"] == "integer"
28
+
29
+ async def test_async_function(self):
30
+ """Test registering and running an async function."""
31
+
32
+ async def fetch_data(url: str) -> str:
33
+ """Fetch data from URL."""
34
+ return f"Data from {url}"
35
+
36
+ manager = ToolManager()
37
+ manager.add_tool(fetch_data)
38
+
39
+ tool = manager.get_tool("fetch_data")
40
+ assert tool is not None
41
+ assert tool.name == "fetch_data"
42
+ assert tool.description == "Fetch data from URL."
43
+ assert tool.is_async is True
44
+ assert tool.parameters["properties"]["url"]["type"] == "string"
45
+
46
+ def test_pydantic_model_function(self):
47
+ """Test registering a function that takes a Pydantic model."""
48
+
49
+ class UserInput(BaseModel):
50
+ name: str
51
+ age: int
52
+
53
+ def create_user(user: UserInput, flag: bool) -> dict:
54
+ """Create a new user."""
55
+ return {"id": 1, **user.model_dump()}
56
+
57
+ manager = ToolManager()
58
+ manager.add_tool(create_user)
59
+
60
+ tool = manager.get_tool("create_user")
61
+ assert tool is not None
62
+ assert tool.name == "create_user"
63
+ assert tool.description == "Create a new user."
64
+ assert tool.is_async is False
65
+ assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
66
+ assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
67
+ assert "flag" in tool.parameters["properties"]
68
+
69
+ def test_add_invalid_tool(self):
70
+ manager = ToolManager()
71
+ with pytest.raises(AttributeError):
72
+ manager.add_tool(1)
73
+
74
+
75
+ class TestCallTools:
76
+ async def test_call_tool(self):
77
+ def add(a: int, b: int) -> int:
78
+ """Add two numbers."""
79
+ return a + b
80
+
81
+ manager = ToolManager()
82
+ manager.add_tool(add)
83
+ result = await manager.call_tool("add", {"a": 1, "b": 2})
84
+ assert result == 3
85
+
86
+ async def test_call_async_tool(self):
87
+ async def double(n: int) -> int:
88
+ """Double a number."""
89
+ return n * 2
90
+
91
+ manager = ToolManager()
92
+ manager.add_tool(double)
93
+ result = await manager.call_tool("double", {"n": 5})
94
+ assert result == 10
95
+
96
+ async def test_call_tool_with_default_args(self):
97
+ def add(a: int, b: int = 1) -> int:
98
+ """Add two numbers."""
99
+ return a + b
100
+
101
+ manager = ToolManager()
102
+ manager.add_tool(add)
103
+ result = await manager.call_tool("add", {"a": 1})
104
+ assert result == 2
105
+
106
+ async def test_call_tool_with_missing_args(self):
107
+ def add(a: int, b: int) -> int:
108
+ """Add two numbers."""
109
+ return a + b
110
+
111
+ manager = ToolManager()
112
+ manager.add_tool(add)
113
+ with pytest.raises(ToolError):
114
+ await manager.call_tool("add", {"a": 1})
115
+
116
+ async def test_call_unknown_tool(self):
117
+ manager = ToolManager()
118
+ with pytest.raises(ToolError):
119
+ await manager.call_tool("unknown", {"a": 1})