Jeremiah Lowin commited on
Commit
7770a61
·
unverified ·
2 Parent(s): 3515dc3895c1f8

Merge pull request #31 from jurasofish/draft-handling-of-complex-inputs

Browse files
README.md CHANGED
@@ -212,6 +212,27 @@ async def fetch_weather(city: str) -> str:
212
  return response.text
213
  ```
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  ### Prompts
216
 
217
  Prompts are reusable templates that help LLMs interact with your server effectively. They're like "best practices" encoded into your server. A prompt can be as simple as a string:
 
212
  return response.text
213
  ```
214
 
215
+ Complex input handling example:
216
+ ```python
217
+ from pydantic import BaseModel, Field
218
+ from typing import Annotated
219
+
220
+ class ShrimpTank(BaseModel):
221
+ class Shrimp(BaseModel):
222
+ name: Annotated[str, Field(max_length=10)]
223
+
224
+ shrimp: list[Shrimp]
225
+
226
+ @mcp.tool()
227
+ def name_shrimp(
228
+ tank: ShrimpTank,
229
+ # You can use pydantic Field in function signatures for validation.
230
+ extra_names: Annotated[list[str], Field(max_length=10)],
231
+ ) -> list[str]:
232
+ """List all shrimp names in the tank"""
233
+ return [shrimp.name for shrimp in tank.shrimp] + extra_names
234
+ ```
235
+
236
  ### Prompts
237
 
238
  Prompts are reusable templates that help LLMs interact with your server effectively. They're like "best practices" encoded into your server. A prompt can be as simple as a string:
examples/complex_inputs.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastMCP Complex inputs Example
3
+
4
+ Demonstrates validation via pydantic with complex models.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field
8
+ from typing import Annotated
9
+ from fastmcp.server import FastMCP
10
+
11
+ mcp = FastMCP("Shrimp Tank")
12
+
13
+
14
+ class ShrimpTank(BaseModel):
15
+ class Shrimp(BaseModel):
16
+ name: Annotated[str, Field(max_length=10)]
17
+
18
+ shrimp: list[Shrimp]
19
+
20
+
21
+ @mcp.tool()
22
+ def name_shrimp(
23
+ tank: ShrimpTank,
24
+ # You can use pydantic Field in function signatures for validation.
25
+ extra_names: Annotated[list[str], Field(max_length=10)],
26
+ ) -> list[str]:
27
+ """List all shrimp names in the tank"""
28
+ return [shrimp.name for shrimp in tank.shrimp] + extra_names
src/fastmcp/exceptions.py CHANGED
@@ -15,3 +15,7 @@ class ResourceError(FastMCPError):
15
 
16
  class ToolError(FastMCPError):
17
  """Error in tool operations."""
 
 
 
 
 
15
 
16
  class ToolError(FastMCPError):
17
  """Error in tool operations."""
18
+
19
+
20
+ class InvalidSignature(Exception):
21
+ """Invalid signature for use with FastMCP."""
src/fastmcp/tools/base.py CHANGED
@@ -1,8 +1,8 @@
1
  import fastmcp
2
  from fastmcp.exceptions import ToolError
3
 
4
-
5
- from pydantic import BaseModel, Field, TypeAdapter, validate_call
6
 
7
 
8
  import inspect
@@ -19,6 +19,9 @@ class Tool(BaseModel):
19
  name: str = Field(description="Name of the tool")
20
  description: str = Field(description="Description of what the tool does")
21
  parameters: dict = Field(description="JSON schema for tool parameters")
 
 
 
22
  is_async: bool = Field(description="Whether the tool is async")
23
  context_kwarg: Optional[str] = Field(
24
  None, description="Name of the kwarg that should receive context"
@@ -41,9 +44,6 @@ class Tool(BaseModel):
41
  func_doc = description or fn.__doc__ or ""
42
  is_async = inspect.iscoroutinefunction(fn)
43
 
44
- # Get schema from TypeAdapter - will fail if function isn't properly typed
45
- parameters = TypeAdapter(fn).json_schema()
46
-
47
  # Find context parameter if it exists
48
  if context_kwarg is None:
49
  sig = inspect.signature(fn)
@@ -52,14 +52,18 @@ class Tool(BaseModel):
52
  context_kwarg = param_name
53
  break
54
 
55
- # ensure the arguments are properly cast
56
- fn = validate_call(fn)
 
 
 
57
 
58
  return cls(
59
  fn=fn,
60
  name=func_name,
61
  description=func_doc,
62
  parameters=parameters,
 
63
  is_async=is_async,
64
  context_kwarg=context_kwarg,
65
  )
@@ -67,13 +71,13 @@ class Tool(BaseModel):
67
  async def run(self, arguments: dict, context: Optional["Context"] = None) -> Any:
68
  """Run the tool with arguments."""
69
  try:
70
- # Inject context if needed
71
- if self.context_kwarg:
72
- arguments[self.context_kwarg] = context
73
-
74
- # Call function with proper async handling
75
- if self.is_async:
76
- return await self.fn(**arguments)
77
- return self.fn(**arguments)
78
  except Exception as e:
79
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
 
1
  import fastmcp
2
  from fastmcp.exceptions import ToolError
3
 
4
+ from fastmcp.utilities.func_metadata import func_metadata, FuncMetadata
5
+ from pydantic import BaseModel, Field
6
 
7
 
8
  import inspect
 
19
  name: str = Field(description="Name of the tool")
20
  description: str = Field(description="Description of what the tool does")
21
  parameters: dict = Field(description="JSON schema for tool parameters")
22
+ fn_metadata: FuncMetadata = Field(
23
+ description="Metadata about the function including a pydantic model for tool arguments"
24
+ )
25
  is_async: bool = Field(description="Whether the tool is async")
26
  context_kwarg: Optional[str] = Field(
27
  None, description="Name of the kwarg that should receive context"
 
44
  func_doc = description or fn.__doc__ or ""
45
  is_async = inspect.iscoroutinefunction(fn)
46
 
 
 
 
47
  # Find context parameter if it exists
48
  if context_kwarg is None:
49
  sig = inspect.signature(fn)
 
52
  context_kwarg = param_name
53
  break
54
 
55
+ func_arg_metadata = func_metadata(
56
+ fn,
57
+ skip_names=[context_kwarg] if context_kwarg is not None else [],
58
+ )
59
+ parameters = func_arg_metadata.arg_model.model_json_schema()
60
 
61
  return cls(
62
  fn=fn,
63
  name=func_name,
64
  description=func_doc,
65
  parameters=parameters,
66
+ fn_metadata=func_arg_metadata,
67
  is_async=is_async,
68
  context_kwarg=context_kwarg,
69
  )
 
71
  async def run(self, arguments: dict, context: Optional["Context"] = None) -> Any:
72
  """Run the tool with arguments."""
73
  try:
74
+ return await self.fn_metadata.call_fn_with_arg_validation(
75
+ self.fn,
76
+ self.is_async,
77
+ arguments,
78
+ {self.context_kwarg: context}
79
+ if self.context_kwarg is not None
80
+ else None,
81
+ )
82
  except Exception as e:
83
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
src/fastmcp/utilities/func_metadata.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import inspect
2
+ from collections.abc import Callable, Sequence, Awaitable
3
+ from typing import (
4
+ Annotated,
5
+ Any,
6
+ Dict,
7
+ ForwardRef,
8
+ )
9
+ from pydantic import Field
10
+ from fastmcp.exceptions import InvalidSignature
11
+ from pydantic._internal._typing_extra import try_eval_type
12
+ import json
13
+ from pydantic import BaseModel
14
+ from pydantic.fields import FieldInfo
15
+ from pydantic import ConfigDict, create_model
16
+ from pydantic import WithJsonSchema
17
+ from pydantic_core import PydanticUndefined
18
+ from fastmcp.utilities.logging import get_logger
19
+
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class ArgModelBase(BaseModel):
25
+ """A model representing the arguments to a function."""
26
+
27
+ def model_dump_one_level(self) -> dict[str, Any]:
28
+ """Return a dict of the model's fields, one level deep.
29
+
30
+ That is, sub-models etc are not dumped - they are kept as pydantic models.
31
+ """
32
+ kwargs: dict[str, Any] = {}
33
+ for field_name in self.model_fields.keys():
34
+ kwargs[field_name] = getattr(self, field_name)
35
+ return kwargs
36
+
37
+ model_config = ConfigDict(
38
+ arbitrary_types_allowed=True,
39
+ )
40
+
41
+
42
+ class FuncMetadata(BaseModel):
43
+ arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
44
+ # We can add things in the future like
45
+ # - Maybe some args are excluded from attempting to parse from JSON
46
+ # - Maybe some args are special (like context) for dependency injection
47
+
48
+ async def call_fn_with_arg_validation(
49
+ self,
50
+ fn: Callable | Awaitable,
51
+ fn_is_async: bool,
52
+ arguments_to_validate: dict[str, Any],
53
+ arguments_to_pass_directly: dict[str, Any] | None,
54
+ ) -> Any:
55
+ """Call the given function with arguments validated and injected.
56
+
57
+ Arguments are first attempted to be parsed from JSON, then validated against
58
+ the argument model, before being passed to the function.
59
+ """
60
+ arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
61
+ arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
62
+ arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
63
+
64
+ arguments_parsed_dict |= arguments_to_pass_directly or {}
65
+
66
+ if fn_is_async:
67
+ return await fn(**arguments_parsed_dict)
68
+ return fn(**arguments_parsed_dict)
69
+
70
+ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
71
+ """Pre-parse data from JSON.
72
+
73
+ Return a dict with same keys as input but with values parsed from JSON
74
+ if appropriate.
75
+
76
+ This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
77
+ a string rather than an actual list. Claude desktop is prone to this - in fact
78
+ it seems incapable of NOT doing this. For sub-models, it tends to pass
79
+ dicts (JSON objects) as JSON strings, which can be pre-parsed here.
80
+ """
81
+ new_data = data.copy() # Shallow copy
82
+ for field_name, field_info in self.arg_model.model_fields.items():
83
+ if field_name not in data.keys():
84
+ continue
85
+ if isinstance(data[field_name], str):
86
+ try:
87
+ pre_parsed = json.loads(data[field_name])
88
+ except json.JSONDecodeError:
89
+ continue # Not JSON - skip
90
+ if isinstance(pre_parsed, str):
91
+ # This is likely that the raw value is e.g. `"hello"` which we
92
+ # Should really be parsed as '"hello"' in Python - but if we parse
93
+ # it as JSON it'll turn into just 'hello'. So we skip it.
94
+ continue
95
+ new_data[field_name] = pre_parsed
96
+ assert new_data.keys() == data.keys()
97
+ return new_data
98
+
99
+ model_config = ConfigDict(
100
+ arbitrary_types_allowed=True,
101
+ )
102
+
103
+
104
+ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata:
105
+ """Given a function, return metadata including a pydantic model representing its signature.
106
+
107
+ The use case for this is
108
+ ```
109
+ meta = func_to_pyd(func)
110
+ validated_args = meta.arg_model.model_validate(some_raw_data_dict)
111
+ return func(**validated_args.model_dump_one_level())
112
+ ```
113
+
114
+ **critically** it also provides pre-parse helper to attempt to parse things from JSON.
115
+
116
+ Args:
117
+ func: The function to convert to a pydantic model
118
+ skip_names: A list of parameter names to skip. These will not be included in
119
+ the model.
120
+ Returns:
121
+ A pydantic model representing the function's signature.
122
+ """
123
+ sig = _get_typed_signature(func)
124
+ params = sig.parameters
125
+ dynamic_pydantic_model_params: dict[str, Any] = {}
126
+ for param in params.values():
127
+ if param.name.startswith("_"):
128
+ raise InvalidSignature(
129
+ f"Parameter {param.name} of {func.__name__} may not start with an underscore"
130
+ )
131
+ if param.name in skip_names:
132
+ continue
133
+ annotation = param.annotation
134
+
135
+ # `x: None` / `x: None = None`
136
+ if annotation is None:
137
+ annotation = Annotated[
138
+ None,
139
+ Field(
140
+ default=param.default
141
+ if param.default is not inspect.Parameter.empty
142
+ else PydanticUndefined
143
+ ),
144
+ ]
145
+
146
+ # Untyped field
147
+ if annotation is inspect.Parameter.empty:
148
+ annotation = Annotated[
149
+ Any,
150
+ Field(),
151
+ # 🤷
152
+ WithJsonSchema({"title": param.name, "type": "string"}),
153
+ ]
154
+
155
+ field_info = FieldInfo.from_annotated_attribute(
156
+ annotation,
157
+ param.default
158
+ if param.default is not inspect.Parameter.empty
159
+ else PydanticUndefined,
160
+ )
161
+ dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
162
+ continue
163
+
164
+ arguments_model = create_model(
165
+ f"{func.__name__}Arguments",
166
+ **dynamic_pydantic_model_params,
167
+ __base__=ArgModelBase,
168
+ )
169
+ resp = FuncMetadata(arg_model=arguments_model)
170
+ return resp
171
+
172
+
173
+ def _get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any:
174
+ if isinstance(annotation, str):
175
+ annotation = ForwardRef(annotation)
176
+ annotation, status = try_eval_type(annotation, globalns, globalns)
177
+
178
+ # This check and raise could perhaps be skipped, and we (FastMCP) just call
179
+ # model_rebuild right before using it 🤷
180
+ if status is False:
181
+ raise InvalidSignature(f"Unable to evaluate type annotation {annotation}")
182
+
183
+ return annotation
184
+
185
+
186
+ def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
187
+ """Get function signature while evaluating forward references"""
188
+ signature = inspect.signature(call)
189
+ globalns = getattr(call, "__globals__", {})
190
+ typed_params = [
191
+ inspect.Parameter(
192
+ name=param.name,
193
+ kind=param.kind,
194
+ default=param.default,
195
+ annotation=_get_typed_annotation(param.annotation, globalns),
196
+ )
197
+ for param in signature.parameters.values()
198
+ ]
199
+ typed_signature = inspect.Signature(typed_params)
200
+ return typed_signature
tests/test_func_metadata.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Annotated
3
+ import annotated_types
4
+ from fastmcp.utilities.func_metadata import func_metadata
5
+ import pytest
6
+
7
+
8
+ class TestInputModelA(BaseModel):
9
+ pass
10
+
11
+
12
+ class TestInputModelB(BaseModel):
13
+ class InnerModel(BaseModel):
14
+ x: int
15
+
16
+ how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")]
17
+ ok: InnerModel
18
+ y: None
19
+
20
+
21
+ def complex_arguments_fn(
22
+ an_int: int,
23
+ must_be_none: None,
24
+ must_be_none_dumb_annotation: Annotated[None, "blah"],
25
+ list_of_ints: list[int],
26
+ # list[str] | str is an interesting case because if it comes in as JSON like
27
+ # "[\"a\", \"b\"]" then it will be naively parsed as a string.
28
+ list_str_or_str: list[str] | str,
29
+ an_int_annotated_with_field: Annotated[
30
+ int, Field(description="An int with a field")
31
+ ],
32
+ an_int_annotated_with_field_and_others: Annotated[
33
+ int,
34
+ str, # Should be ignored, really
35
+ Field(description="An int with a field"),
36
+ annotated_types.Gt(1),
37
+ ],
38
+ an_int_annotated_with_junk: Annotated[
39
+ int,
40
+ "123",
41
+ 456,
42
+ ],
43
+ field_with_default_via_field_annotation_before_nondefault_arg: Annotated[
44
+ int, Field(1)
45
+ ],
46
+ unannotated,
47
+ my_model_a: TestInputModelA,
48
+ my_model_a_forward_ref: "TestInputModelA",
49
+ my_model_b: TestInputModelB,
50
+ an_int_annotated_with_field_default: Annotated[
51
+ int,
52
+ Field(1, description="An int with a field"),
53
+ ],
54
+ unannotated_with_default=5,
55
+ my_model_a_with_default: TestInputModelA = TestInputModelA(), # noqa: B008
56
+ an_int_with_default: int = 1,
57
+ must_be_none_with_default: None = None,
58
+ an_int_with_equals_field: int = Field(1, ge=0),
59
+ int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
60
+ ) -> str:
61
+ _ = (
62
+ an_int,
63
+ must_be_none,
64
+ must_be_none_dumb_annotation,
65
+ list_of_ints,
66
+ list_str_or_str,
67
+ an_int_annotated_with_field,
68
+ an_int_annotated_with_field_and_others,
69
+ an_int_annotated_with_junk,
70
+ field_with_default_via_field_annotation_before_nondefault_arg,
71
+ unannotated,
72
+ an_int_annotated_with_field_default,
73
+ unannotated_with_default,
74
+ my_model_a,
75
+ my_model_a_forward_ref,
76
+ my_model_b,
77
+ my_model_a_with_default,
78
+ an_int_with_default,
79
+ must_be_none_with_default,
80
+ an_int_with_equals_field,
81
+ int_annotated_with_default,
82
+ )
83
+ return "ok!"
84
+
85
+
86
+ async def test_complex_function_runtime_arg_validation_non_json():
87
+ """Test that basic non-JSON arguments are validated correctly"""
88
+ meta = func_metadata(complex_arguments_fn)
89
+
90
+ # Test with minimum required arguments
91
+ result = await meta.call_fn_with_arg_validation(
92
+ complex_arguments_fn,
93
+ fn_is_async=False,
94
+ arguments_to_validate={
95
+ "an_int": 1,
96
+ "must_be_none": None,
97
+ "must_be_none_dumb_annotation": None,
98
+ "list_of_ints": [1, 2, 3],
99
+ "list_str_or_str": "hello",
100
+ "an_int_annotated_with_field": 42,
101
+ "an_int_annotated_with_field_and_others": 5,
102
+ "an_int_annotated_with_junk": 100,
103
+ "unannotated": "test",
104
+ "my_model_a": {},
105
+ "my_model_a_forward_ref": {},
106
+ "my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
107
+ },
108
+ arguments_to_pass_directly=None,
109
+ )
110
+ assert result == "ok!"
111
+
112
+ # Test with invalid types
113
+ with pytest.raises(ValueError):
114
+ await meta.call_fn_with_arg_validation(
115
+ complex_arguments_fn,
116
+ fn_is_async=False,
117
+ arguments_to_validate={"an_int": "not an int"},
118
+ arguments_to_pass_directly=None,
119
+ )
120
+
121
+
122
+ async def test_complex_function_runtime_arg_validation_with_json():
123
+ """Test that JSON string arguments are parsed and validated correctly"""
124
+ meta = func_metadata(complex_arguments_fn)
125
+
126
+ result = await meta.call_fn_with_arg_validation(
127
+ complex_arguments_fn,
128
+ fn_is_async=False,
129
+ arguments_to_validate={
130
+ "an_int": 1,
131
+ "must_be_none": None,
132
+ "must_be_none_dumb_annotation": None,
133
+ "list_of_ints": "[1, 2, 3]", # JSON string
134
+ "list_str_or_str": '["a", "b", "c"]', # JSON string
135
+ "an_int_annotated_with_field": 42,
136
+ "an_int_annotated_with_field_and_others": "5", # JSON string
137
+ "an_int_annotated_with_junk": 100,
138
+ "unannotated": "test",
139
+ "my_model_a": "{}", # JSON string
140
+ "my_model_a_forward_ref": "{}", # JSON string
141
+ "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}', # JSON string
142
+ },
143
+ arguments_to_pass_directly=None,
144
+ )
145
+ assert result == "ok!"
146
+
147
+
148
+ def test_str_vs_list_str():
149
+ """Test handling of string vs list[str] type annotations.
150
+
151
+ This is tricky as '"hello"' can be parsed as a JSON string or a Python string.
152
+ We want to make sure it's kept as a python string.
153
+ """
154
+
155
+ def func_with_str_types(str_or_list: str | list[str]):
156
+ return str_or_list
157
+
158
+ meta = func_metadata(func_with_str_types)
159
+
160
+ # Test string input for union type
161
+ result = meta.pre_parse_json({"str_or_list": "hello"})
162
+ assert result["str_or_list"] == "hello"
163
+
164
+ # Test string input that contains valid JSON for union type
165
+ # We want to see here that the JSON-vali string is NOT parsed as JSON, but rather
166
+ # kept as a raw string
167
+ result = meta.pre_parse_json({"str_or_list": '"hello"'})
168
+ assert result["str_or_list"] == '"hello"'
169
+
170
+ # Test list input for union type
171
+ result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'})
172
+ assert result["str_or_list"] == ["hello", "world"]
173
+
174
+
175
+ def test_skip_names():
176
+ """Test that skipped parameters are not included in the model"""
177
+
178
+ def func_with_many_params(
179
+ keep_this: int, skip_this: str, also_keep: float, also_skip: bool
180
+ ):
181
+ return keep_this, skip_this, also_keep, also_skip
182
+
183
+ # Skip some parameters
184
+ meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"])
185
+
186
+ # Check model fields
187
+ assert "keep_this" in meta.arg_model.model_fields
188
+ assert "also_keep" in meta.arg_model.model_fields
189
+ assert "skip_this" not in meta.arg_model.model_fields
190
+ assert "also_skip" not in meta.arg_model.model_fields
191
+
192
+ # Validate that we can call with only non-skipped parameters
193
+ model = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5})
194
+ assert model.keep_this == 1
195
+ assert model.also_keep == 2.5
196
+
197
+
198
+ async def test_lambda_function():
199
+ """Test lambda function schema and validation"""
200
+ fn = lambda x, y=5: x # noqa: E731
201
+ meta = func_metadata(lambda x, y=5: x)
202
+
203
+ # Test schema
204
+ assert meta.arg_model.model_json_schema() == {
205
+ "properties": {
206
+ "x": {"title": "x", "type": "string"},
207
+ "y": {"default": 5, "title": "y", "type": "string"},
208
+ },
209
+ "required": ["x"],
210
+ "title": "<lambda>Arguments",
211
+ "type": "object",
212
+ }
213
+
214
+ async def check_call(args):
215
+ return await meta.call_fn_with_arg_validation(
216
+ fn,
217
+ fn_is_async=False,
218
+ arguments_to_validate=args,
219
+ arguments_to_pass_directly=None,
220
+ )
221
+
222
+ # Basic calls
223
+ assert await check_call({"x": "hello"}) == "hello"
224
+ assert await check_call({"x": "hello", "y": "world"}) == "hello"
225
+ assert await check_call({"x": '"hello"'}) == '"hello"'
226
+
227
+ # Missing required arg
228
+ with pytest.raises(ValueError):
229
+ await check_call({"y": "world"})
230
+
231
+
232
+ def test_complex_function_json_schema():
233
+ meta = func_metadata(complex_arguments_fn)
234
+ assert meta.arg_model.model_json_schema() == {
235
+ "$defs": {
236
+ "InnerModel": {
237
+ "properties": {"x": {"title": "X", "type": "integer"}},
238
+ "required": ["x"],
239
+ "title": "InnerModel",
240
+ "type": "object",
241
+ },
242
+ "TestInputModelA": {
243
+ "properties": {},
244
+ "title": "TestInputModelA",
245
+ "type": "object",
246
+ },
247
+ "TestInputModelB": {
248
+ "properties": {
249
+ "how_many_shrimp": {
250
+ "description": "How many shrimp in the tank???",
251
+ "title": "How Many Shrimp",
252
+ "type": "integer",
253
+ },
254
+ "ok": {"$ref": "#/$defs/InnerModel"},
255
+ "y": {"title": "Y", "type": "null"},
256
+ },
257
+ "required": ["how_many_shrimp", "ok", "y"],
258
+ "title": "TestInputModelB",
259
+ "type": "object",
260
+ },
261
+ },
262
+ "properties": {
263
+ "an_int": {"title": "An Int", "type": "integer"},
264
+ "must_be_none": {"title": "Must Be None", "type": "null"},
265
+ "must_be_none_dumb_annotation": {
266
+ "title": "Must Be None Dumb Annotation",
267
+ "type": "null",
268
+ },
269
+ "list_of_ints": {
270
+ "items": {"type": "integer"},
271
+ "title": "List Of Ints",
272
+ "type": "array",
273
+ },
274
+ "list_str_or_str": {
275
+ "anyOf": [
276
+ {"items": {"type": "string"}, "type": "array"},
277
+ {"type": "string"},
278
+ ],
279
+ "title": "List Str Or Str",
280
+ },
281
+ "an_int_annotated_with_field": {
282
+ "description": "An int with a field",
283
+ "title": "An Int Annotated With Field",
284
+ "type": "integer",
285
+ },
286
+ "an_int_annotated_with_field_and_others": {
287
+ "description": "An int with a field",
288
+ "exclusiveMinimum": 1,
289
+ "title": "An Int Annotated With Field And Others",
290
+ "type": "integer",
291
+ },
292
+ "an_int_annotated_with_junk": {
293
+ "title": "An Int Annotated With Junk",
294
+ "type": "integer",
295
+ },
296
+ "field_with_default_via_field_annotation_before_nondefault_arg": {
297
+ "default": 1,
298
+ "title": "Field With Default Via Field Annotation Before Nondefault Arg",
299
+ "type": "integer",
300
+ },
301
+ "unannotated": {"title": "unannotated", "type": "string"},
302
+ "my_model_a": {"$ref": "#/$defs/TestInputModelA"},
303
+ "my_model_a_forward_ref": {"$ref": "#/$defs/TestInputModelA"},
304
+ "my_model_b": {"$ref": "#/$defs/TestInputModelB"},
305
+ "an_int_annotated_with_field_default": {
306
+ "default": 1,
307
+ "description": "An int with a field",
308
+ "title": "An Int Annotated With Field Default",
309
+ "type": "integer",
310
+ },
311
+ "unannotated_with_default": {
312
+ "default": 5,
313
+ "title": "unannotated_with_default",
314
+ "type": "string",
315
+ },
316
+ "my_model_a_with_default": {
317
+ "$ref": "#/$defs/TestInputModelA",
318
+ "default": {},
319
+ },
320
+ "an_int_with_default": {
321
+ "default": 1,
322
+ "title": "An Int With Default",
323
+ "type": "integer",
324
+ },
325
+ "must_be_none_with_default": {
326
+ "default": None,
327
+ "title": "Must Be None With Default",
328
+ "type": "null",
329
+ },
330
+ "an_int_with_equals_field": {
331
+ "default": 1,
332
+ "minimum": 0,
333
+ "title": "An Int With Equals Field",
334
+ "type": "integer",
335
+ },
336
+ "int_annotated_with_default": {
337
+ "default": 5,
338
+ "description": "hey",
339
+ "title": "Int Annotated With Default",
340
+ "type": "integer",
341
+ },
342
+ },
343
+ "required": [
344
+ "an_int",
345
+ "must_be_none",
346
+ "must_be_none_dumb_annotation",
347
+ "list_of_ints",
348
+ "list_str_or_str",
349
+ "an_int_annotated_with_field",
350
+ "an_int_annotated_with_field_and_others",
351
+ "an_int_annotated_with_junk",
352
+ "unannotated",
353
+ "my_model_a",
354
+ "my_model_a_forward_ref",
355
+ "my_model_b",
356
+ ],
357
+ "title": "complex_arguments_fnArguments",
358
+ "type": "object",
359
+ }
tests/test_tool_manager.py CHANGED
@@ -3,7 +3,7 @@ from typing import Optional
3
 
4
  import pytest
5
  from pydantic import BaseModel
6
-
7
  from fastmcp.exceptions import ToolError
8
  from fastmcp.tools import ToolManager
9
 
@@ -156,6 +156,74 @@ class TestCallTools:
156
  with pytest.raises(ToolError):
157
  await manager.call_tool("unknown", {"a": 1})
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  class TestContextHandling:
161
  """Test context handling in the tool manager."""
 
3
 
4
  import pytest
5
  from pydantic import BaseModel
6
+ import json
7
  from fastmcp.exceptions import ToolError
8
  from fastmcp.tools import ToolManager
9
 
 
156
  with pytest.raises(ToolError):
157
  await manager.call_tool("unknown", {"a": 1})
158
 
159
+ async def test_call_tool_with_list_int_input(self):
160
+ def sum_vals(vals: list[int]) -> int:
161
+ return sum(vals)
162
+
163
+ manager = ToolManager()
164
+ manager.add_tool(sum_vals)
165
+ # Try both with plain list and with JSON list
166
+ result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
167
+ assert result == 6
168
+ result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
169
+ assert result == 6
170
+
171
+ async def test_call_tool_with_list_str_or_str_input(self):
172
+ def concat_strs(vals: list[str] | str) -> str:
173
+ return vals if isinstance(vals, str) else "".join(vals)
174
+
175
+ manager = ToolManager()
176
+ manager.add_tool(concat_strs)
177
+ # Try both with plain python object and with JSON list
178
+ result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
179
+ assert result == "abc"
180
+ result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
181
+ assert result == "abc"
182
+ result = await manager.call_tool("concat_strs", {"vals": "a"})
183
+ assert result == "a"
184
+ result = await manager.call_tool("concat_strs", {"vals": '"a"'})
185
+ assert result == '"a"'
186
+
187
+ async def test_call_tool_with_complex_model(self):
188
+ from fastmcp import Context
189
+
190
+ class MyShrimpTank(BaseModel):
191
+ class Shrimp(BaseModel):
192
+ name: str
193
+
194
+ shrimp: list[Shrimp]
195
+ x: None
196
+
197
+ def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]:
198
+ return [x.name for x in tank.shrimp]
199
+
200
+ manager = ToolManager()
201
+ manager.add_tool(name_shrimp)
202
+ result = await manager.call_tool(
203
+ "name_shrimp",
204
+ {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
205
+ )
206
+ assert result == ["rex", "gertrude"]
207
+ result = await manager.call_tool(
208
+ "name_shrimp",
209
+ {"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
210
+ )
211
+ assert result == ["rex", "gertrude"]
212
+
213
+
214
+ class TestToolSchema:
215
+ async def test_context_arg_excluded_from_schema(self):
216
+ from fastmcp import Context
217
+
218
+ def something(a: int, ctx: Context) -> int:
219
+ return a
220
+
221
+ manager = ToolManager()
222
+ tool = manager.add_tool(something)
223
+ assert "ctx" not in json.dumps(tool.parameters)
224
+ assert "Context" not in json.dumps(tool.parameters)
225
+ assert "ctx" not in tool.fn_metadata.arg_model.model_fields
226
+
227
 
228
  class TestContextHandling:
229
  """Test context handling in the tool manager."""