Jeremiah Lowin commited on
Commit
388409a
·
1 Parent(s): ed196c3

Replace custom parsing with TypeAdapter

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -18,7 +18,6 @@ from fastmcp.resources import Resource, ResourceTemplate
18
  from fastmcp.server.server import FastMCP
19
  from fastmcp.tools.tool import Tool, _convert_to_content
20
  from fastmcp.utilities import openapi
21
- from fastmcp.utilities.func_metadata import func_metadata
22
  from fastmcp.utilities.logging import get_logger
23
  from fastmcp.utilities.openapi import (
24
  _combine_schemas,
@@ -123,7 +122,6 @@ class OpenAPITool(Tool):
123
  name: str,
124
  description: str,
125
  parameters: dict[str, Any],
126
- fn_metadata: Any,
127
  is_async: bool = True,
128
  tags: set[str] = set(),
129
  timeout: float | None = None,
@@ -135,7 +133,6 @@ class OpenAPITool(Tool):
135
  description=description,
136
  parameters=parameters,
137
  fn=self._execute_request, # We'll use an instance method instead of a global function
138
- fn_metadata=fn_metadata,
139
  is_async=is_async,
140
  context_kwarg="context", # Default context keyword argument
141
  tags=tags,
@@ -553,7 +550,6 @@ class FastMCPOpenAPI(FastMCP):
553
  name=tool_name,
554
  description=enhanced_description,
555
  parameters=combined_schema,
556
- fn_metadata=func_metadata(_openapi_passthrough),
557
  is_async=True,
558
  tags=set(route.tags or []),
559
  timeout=self._timeout,
 
18
  from fastmcp.server.server import FastMCP
19
  from fastmcp.tools.tool import Tool, _convert_to_content
20
  from fastmcp.utilities import openapi
 
21
  from fastmcp.utilities.logging import get_logger
22
  from fastmcp.utilities.openapi import (
23
  _combine_schemas,
 
122
  name: str,
123
  description: str,
124
  parameters: dict[str, Any],
 
125
  is_async: bool = True,
126
  tags: set[str] = set(),
127
  timeout: float | None = None,
 
133
  description=description,
134
  parameters=parameters,
135
  fn=self._execute_request, # We'll use an instance method instead of a global function
 
136
  is_async=is_async,
137
  context_kwarg="context", # Default context keyword argument
138
  tags=tags,
 
550
  name=tool_name,
551
  description=enhanced_description,
552
  parameters=combined_schema,
 
553
  is_async=True,
554
  tags=set(route.tags or []),
555
  timeout=self._timeout,
src/fastmcp/server/proxy.py CHANGED
@@ -24,7 +24,6 @@ from fastmcp.resources import Resource, ResourceTemplate
24
  from fastmcp.server.context import Context
25
  from fastmcp.server.server import FastMCP
26
  from fastmcp.tools.tool import Tool
27
- from fastmcp.utilities.func_metadata import func_metadata
28
  from fastmcp.utilities.logging import get_logger
29
 
30
  if TYPE_CHECKING:
@@ -53,7 +52,6 @@ class ProxyTool(Tool):
53
  description=tool.description,
54
  parameters=tool.inputSchema,
55
  fn=_proxy_passthrough,
56
- fn_metadata=func_metadata(_proxy_passthrough),
57
  is_async=True,
58
  )
59
 
 
24
  from fastmcp.server.context import Context
25
  from fastmcp.server.server import FastMCP
26
  from fastmcp.tools.tool import Tool
 
27
  from fastmcp.utilities.logging import get_logger
28
 
29
  if TYPE_CHECKING:
 
52
  description=tool.description,
53
  parameters=tool.inputSchema,
54
  fn=_proxy_passthrough,
 
55
  is_async=True,
56
  )
57
 
src/fastmcp/tools/tool.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import inspect
 
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
@@ -10,11 +11,12 @@ from mcp.types import Tool as MCPTool
10
  from pydantic import BaseModel, BeforeValidator, Field
11
 
12
  from fastmcp.exceptions import ToolError
13
- from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
14
  from fastmcp.utilities.logging import get_logger
15
  from fastmcp.utilities.types import (
16
  Image,
17
  _convert_set_defaults,
 
18
  is_class_member_of_type,
19
  )
20
 
@@ -38,10 +40,6 @@ class Tool(BaseModel):
38
  name: str = Field(description="Name of the tool")
39
  description: str = Field(description="Description of what the tool does")
40
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
41
- fn_metadata: FuncMetadata = Field(
42
- description="Metadata about the function including a pydantic model for tool"
43
- " arguments"
44
- )
45
  is_async: bool = Field(description="Whether the tool is async")
46
  context_kwarg: str | None = Field(
47
  None, description="Name of the kwarg that should receive context"
@@ -78,35 +76,26 @@ class Tool(BaseModel):
78
  func_doc = description or fn.__doc__ or ""
79
  is_async = inspect.iscoroutinefunction(fn)
80
 
 
 
 
 
81
  if context_kwarg is None:
82
- if inspect.ismethod(fn) and hasattr(fn, "__func__"):
83
- sig = inspect.signature(fn.__func__)
84
- else:
85
- sig = inspect.signature(fn)
86
  for param_name, param in sig.parameters.items():
87
  if is_class_member_of_type(param.annotation, Context):
88
  context_kwarg = param_name
89
  break
90
 
91
- # Use callable typing to ensure fn is treated as a callable despite being a classmethod
92
- fn_callable: Callable[..., Any] = fn
93
- func_arg_metadata = func_metadata(
94
- fn_callable,
95
- skip_names=[context_kwarg] if context_kwarg is not None else [],
96
- )
97
- try:
98
- parameters = func_arg_metadata.arg_model.model_json_schema()
99
- except Exception as e:
100
- raise TypeError(
101
- f'Unable to parse parameters for function "{fn.__name__}": {e}'
102
- ) from e
103
 
104
  return cls(
105
- fn=fn_callable,
106
  name=func_name,
107
  description=func_doc,
108
- parameters=parameters,
109
- fn_metadata=func_arg_metadata,
110
  is_async=is_async,
111
  context_kwarg=context_kwarg,
112
  tags=tags or set(),
@@ -121,17 +110,30 @@ class Tool(BaseModel):
121
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
122
  """Run the tool with arguments."""
123
  try:
124
- pass_args = (
125
- {self.context_kwarg: context}
126
- if self.context_kwarg is not None
127
- else None
128
- )
129
- result = await self.fn_metadata.call_fn_with_arg_validation(
130
- fn=self.fn,
131
- fn_is_async=self.is_async,
132
- arguments_to_validate=arguments,
133
- arguments_to_pass_directly=pass_args,
134
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  return _convert_to_content(result, serializer=self.serializer)
136
  except Exception as e:
137
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
 
1
  from __future__ import annotations
2
 
3
  import inspect
4
+ import json
5
  from collections.abc import Callable
6
  from typing import TYPE_CHECKING, Annotated, Any
7
 
 
11
  from pydantic import BaseModel, BeforeValidator, Field
12
 
13
  from fastmcp.exceptions import ToolError
14
+ from fastmcp.utilities.json_schema import prune_params
15
  from fastmcp.utilities.logging import get_logger
16
  from fastmcp.utilities.types import (
17
  Image,
18
  _convert_set_defaults,
19
+ get_cached_typeadapter,
20
  is_class_member_of_type,
21
  )
22
 
 
40
  name: str = Field(description="Name of the tool")
41
  description: str = Field(description="Description of what the tool does")
42
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
 
 
 
 
43
  is_async: bool = Field(description="Whether the tool is async")
44
  context_kwarg: str | None = Field(
45
  None, description="Name of the kwarg that should receive context"
 
76
  func_doc = description or fn.__doc__ or ""
77
  is_async = inspect.iscoroutinefunction(fn)
78
 
79
+ if inspect.ismethod(fn) and hasattr(fn, "__func__"):
80
+ sig = inspect.signature(fn.__func__)
81
+ else:
82
+ sig = inspect.signature(fn)
83
  if context_kwarg is None:
 
 
 
 
84
  for param_name, param in sig.parameters.items():
85
  if is_class_member_of_type(param.annotation, Context):
86
  context_kwarg = param_name
87
  break
88
 
89
+ type_adapter = get_cached_typeadapter(fn)
90
+ schema = type_adapter.json_schema()
91
+ if context_kwarg:
92
+ schema = prune_params(schema, params=[context_kwarg])
 
 
 
 
 
 
 
 
93
 
94
  return cls(
95
+ fn=fn,
96
  name=func_name,
97
  description=func_doc,
98
+ parameters=schema,
 
99
  is_async=is_async,
100
  context_kwarg=context_kwarg,
101
  tags=tags or set(),
 
110
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
111
  """Run the tool with arguments."""
112
  try:
113
+ injected_args = (
114
+ {self.context_kwarg: context} if self.context_kwarg is not None else {}
 
 
 
 
 
 
 
 
115
  )
116
+
117
+ parsed_args = arguments.copy()
118
+
119
+ # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
120
+ # being passed in as JSON inside a string rather than an actual list.
121
+ #
122
+ # Claude desktop is prone to this - in fact it seems incapable of NOT doing
123
+ # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
124
+ # which can be pre-parsed here.
125
+ for param_name in self.parameters["properties"]:
126
+ if isinstance(parsed_args.get(param_name, None), str):
127
+ try:
128
+ parsed_args[param_name] = json.loads(parsed_args[param_name])
129
+ except Exception:
130
+ pass
131
+
132
+ type_adapter = get_cached_typeadapter(self.fn)
133
+ result = type_adapter.validate_python(parsed_args | injected_args)
134
+ if inspect.isawaitable(result):
135
+ result = await result
136
+
137
  return _convert_to_content(result, serializer=self.serializer)
138
  except Exception as e:
139
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
src/fastmcp/utilities/func_metadata.py DELETED
@@ -1,229 +0,0 @@
1
- import inspect
2
- import json
3
- from collections.abc import Awaitable, Callable, Sequence
4
- from typing import (
5
- Annotated,
6
- Any,
7
- ForwardRef,
8
- )
9
-
10
- from pydantic import (
11
- BaseModel,
12
- ConfigDict,
13
- Field,
14
- TypeAdapter,
15
- ValidationError,
16
- WithJsonSchema,
17
- create_model,
18
- )
19
- from pydantic._internal._typing_extra import eval_type_backport
20
- from pydantic.fields import FieldInfo
21
- from pydantic_core import PydanticUndefined
22
-
23
- from fastmcp.exceptions import InvalidSignature
24
- from fastmcp.utilities.logging import get_logger
25
-
26
- logger = get_logger(__name__)
27
-
28
-
29
- class ArgModelBase(BaseModel):
30
- """A model representing the arguments to a function."""
31
-
32
- def model_dump_one_level(self) -> dict[str, Any]:
33
- """Return a dict of the model's fields, one level deep.
34
-
35
- That is, sub-models etc are not dumped - they are kept as pydantic models.
36
- """
37
- kwargs: dict[str, Any] = {}
38
- for field_name in self.__class__.model_fields.keys():
39
- kwargs[field_name] = getattr(self, field_name)
40
- return kwargs
41
-
42
- model_config = ConfigDict(
43
- arbitrary_types_allowed=True,
44
- )
45
-
46
-
47
- class FuncMetadata(BaseModel):
48
- arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
49
- # We can add things in the future like
50
- # - Maybe some args are excluded from attempting to parse from JSON
51
- # - Maybe some args are special (like context) for dependency injection
52
-
53
- async def call_fn_with_arg_validation(
54
- self,
55
- fn: Callable[..., Any] | Awaitable[Any],
56
- fn_is_async: bool,
57
- arguments_to_validate: dict[str, Any],
58
- arguments_to_pass_directly: dict[str, Any] | None,
59
- ) -> Any:
60
- """Call the given function with arguments validated and injected.
61
-
62
- Arguments are first attempted to be parsed from JSON, then validated against
63
- the argument model, before being passed to the function.
64
- """
65
- arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
66
- arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
67
- arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
68
-
69
- arguments_parsed_dict |= arguments_to_pass_directly or {}
70
-
71
- if fn_is_async:
72
- if isinstance(fn, Awaitable):
73
- return await fn
74
- return await fn(**arguments_parsed_dict)
75
- if isinstance(fn, Callable):
76
- return fn(**arguments_parsed_dict)
77
- raise TypeError("fn must be either Callable or Awaitable")
78
-
79
- def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
80
- """Pre-parse data from JSON.
81
-
82
- Return a dict with same keys as input but with values parsed from JSON
83
- if appropriate.
84
-
85
- This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
86
- a string rather than an actual list. Claude desktop is prone to this - in fact
87
- it seems incapable of NOT doing this. For sub-models, it tends to pass
88
- dicts (JSON objects) as JSON strings, which can be pre-parsed here.
89
- """
90
- new_data = data.copy() # Shallow copy
91
- for field_name, field_info in self.arg_model.model_fields.items():
92
- if field_name not in data.keys():
93
- continue
94
- if isinstance(data[field_name], str):
95
- try:
96
- pre_parsed = json.loads(data[field_name])
97
-
98
- # Check if the pre_parsed value is valid for the field
99
- validator = TypeAdapter(field_info.annotation)
100
- validator.validate_python(pre_parsed)
101
- except (json.JSONDecodeError, ValidationError):
102
- continue # Not JSON or invalid for the field
103
- if isinstance(pre_parsed, str | int | float):
104
- # This is likely that the raw value is e.g. `"hello"` which we
105
- # Should really be parsed as '"hello"' in Python - but if we parse
106
- # it as JSON it'll turn into just 'hello'. So we skip it.
107
- continue
108
- new_data[field_name] = pre_parsed
109
- assert new_data.keys() == data.keys()
110
- return new_data
111
-
112
- model_config = ConfigDict(
113
- arbitrary_types_allowed=True,
114
- )
115
-
116
-
117
- def func_metadata(
118
- func: Callable[..., Any], skip_names: Sequence[str] = ()
119
- ) -> FuncMetadata:
120
- """Given a function, return metadata including a pydantic model representing its
121
- signature.
122
-
123
- The use case for this is
124
- ```
125
- meta = func_to_pyd(func)
126
- validated_args = meta.arg_model.model_validate(some_raw_data_dict)
127
- return func(**validated_args.model_dump_one_level())
128
- ```
129
-
130
- **critically** it also provides pre-parse helper to attempt to parse things from
131
- JSON.
132
-
133
- Args:
134
- func: The function to convert to a pydantic model
135
- skip_names: A list of parameter names to skip. These will not be included in
136
- the model.
137
- Returns:
138
- A pydantic model representing the function's signature.
139
- """
140
- if isinstance(func, classmethod):
141
- sig = _get_typed_signature(func.__func__)
142
- else:
143
- sig = _get_typed_signature(func)
144
- params = sig.parameters
145
- dynamic_pydantic_model_params: dict[str, Any] = {}
146
- globalns = getattr(func, "__globals__", {})
147
- for param in params.values():
148
- if param.name.startswith("_"):
149
- raise InvalidSignature(
150
- f"Parameter {param.name} of {func.__name__} cannot start with '_'"
151
- )
152
- if param.name in skip_names:
153
- continue
154
- annotation = param.annotation
155
-
156
- # `x: None` / `x: None = None`
157
- if annotation is None:
158
- annotation = Annotated[
159
- None,
160
- Field(
161
- default=param.default
162
- if param.default is not inspect.Parameter.empty
163
- else PydanticUndefined
164
- ),
165
- ]
166
-
167
- # Untyped field
168
- if annotation is inspect.Parameter.empty:
169
- annotation = Annotated[
170
- Any,
171
- Field(),
172
- # 🤷
173
- WithJsonSchema({"title": param.name, "type": "string"}),
174
- ]
175
-
176
- field_info = FieldInfo.from_annotated_attribute(
177
- _get_typed_annotation(annotation, globalns),
178
- param.default
179
- if param.default is not inspect.Parameter.empty
180
- else PydanticUndefined,
181
- )
182
- dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
183
- continue
184
-
185
- arguments_model = create_model(
186
- f"{func.__name__}Arguments",
187
- **dynamic_pydantic_model_params,
188
- __base__=ArgModelBase,
189
- )
190
- resp = FuncMetadata(arg_model=arguments_model)
191
- return resp
192
-
193
-
194
- def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
195
- def try_eval_type(
196
- value: Any, globalns: dict[str, Any], localns: dict[str, Any]
197
- ) -> tuple[Any, bool]:
198
- try:
199
- return eval_type_backport(value, globalns, localns), True
200
- except NameError:
201
- return value, False
202
-
203
- if isinstance(annotation, str):
204
- annotation = ForwardRef(annotation)
205
- annotation, status = try_eval_type(annotation, globalns, globalns)
206
-
207
- # This check and raise could perhaps be skipped, and we (FastMCP) just call
208
- # model_rebuild right before using it 🤷
209
- if status is False:
210
- raise InvalidSignature(f"Unable to evaluate type annotation {annotation}")
211
-
212
- return annotation
213
-
214
-
215
- def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
216
- """Get function signature while evaluating forward references"""
217
- signature = inspect.signature(call)
218
- globalns = getattr(call, "__globals__", {})
219
- typed_params = [
220
- inspect.Parameter(
221
- name=param.name,
222
- kind=param.kind,
223
- default=param.default,
224
- annotation=_get_typed_annotation(param.annotation, globalns),
225
- )
226
- for param in signature.parameters.values()
227
- ]
228
- typed_signature = inspect.Signature(typed_params)
229
- return typed_signature
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fastmcp/utilities/json_schema.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from collections.abc import Mapping, Sequence
5
+
6
+
7
+ def _prune_param(schema: dict, param: str) -> dict:
8
+ """Return a new schema with *param* removed from `properties`, `required`,
9
+ and (if no longer referenced) `$defs`.
10
+ """
11
+
12
+ # ── 1. drop from properties/required ──────────────────────────────
13
+ props = schema.get("properties", {})
14
+ removed = props.pop(param, None)
15
+ if removed is None: # nothing to do
16
+ return schema
17
+ # Keep empty properties object rather than removing it entirely
18
+ schema["properties"] = props
19
+ if param in schema.get("required", []):
20
+ schema["required"].remove(param)
21
+ if not schema["required"]:
22
+ schema.pop("required")
23
+
24
+ # ── 2. collect all remaining local $ref targets ───────────────────
25
+ used_defs: set[str] = set()
26
+
27
+ def walk(node: object) -> None: # depth-first traversal
28
+ if isinstance(node, Mapping):
29
+ ref = node.get("$ref")
30
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
31
+ used_defs.add(ref.split("/")[-1])
32
+ for v in node.values():
33
+ walk(v)
34
+ elif isinstance(node, Sequence) and not isinstance(node, str | bytes):
35
+ for v in node:
36
+ walk(v)
37
+
38
+ walk(schema)
39
+
40
+ # ── 3. remove orphaned definitions ────────────────────────────────
41
+ defs = schema.get("$defs", {})
42
+ for def_name in list(defs):
43
+ if def_name not in used_defs:
44
+ defs.pop(def_name)
45
+ if not defs:
46
+ schema.pop("$defs", None)
47
+
48
+ return schema
49
+
50
+
51
+ def prune_params(schema: dict, params: list[str]) -> dict:
52
+ """
53
+ Remove the given parameters from the schema.
54
+
55
+ """
56
+ schema = copy.deepcopy(schema)
57
+ for param in params:
58
+ schema = _prune_param(schema, param=param)
59
+ return schema
src/fastmcp/utilities/types.py CHANGED
@@ -1,15 +1,28 @@
1
  """Common types used across FastMCP."""
2
 
3
  import base64
 
4
  from pathlib import Path
5
  from types import UnionType
6
  from typing import Annotated, TypeVar, Union, get_args, get_origin
7
 
8
  from mcp.types import ImageContent
 
9
 
10
  T = TypeVar("T")
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
13
  def issubclass_safe(cls: type, base: type) -> bool:
14
  """Check if cls is a subclass of base, even if cls is a type variable."""
15
  try:
 
1
  """Common types used across FastMCP."""
2
 
3
  import base64
4
+ from functools import lru_cache
5
  from pathlib import Path
6
  from types import UnionType
7
  from typing import Annotated, TypeVar, Union, get_args, get_origin
8
 
9
  from mcp.types import ImageContent
10
+ from pydantic import TypeAdapter
11
 
12
  T = TypeVar("T")
13
 
14
 
15
+ @lru_cache(maxsize=5000)
16
+ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
17
+ """
18
+ TypeAdapters are heavy objects, and in an application context we'd typically
19
+ create them once in a global scope and reuse them as often as possible.
20
+ However, this isn't feasible for user-generated functions. Instead, we use a
21
+ cache to minimize the cost of creating them as much as possible.
22
+ """
23
+ return TypeAdapter(cls)
24
+
25
+
26
  def issubclass_safe(cls: type, base: type) -> bool:
27
  """Check if cls is a subclass of base, even if cls is a type variable."""
28
  try:
tests/server/test_server_interactions.py CHANGED
@@ -460,7 +460,7 @@ class TestToolParameters:
460
  pass
461
 
462
  async with Client(mcp) as client:
463
- with pytest.raises(ClientError, match="Field required"):
464
  await client.call_tool("analyze", {})
465
 
466
  async def test_literal_type_validation_error(self):
@@ -537,7 +537,7 @@ class TestToolParameters:
537
  assert isinstance(result[0], TextContent)
538
  assert result[0].text == "1.0"
539
 
540
- with pytest.raises(ClientError, match="2 validation errors for analyze"):
541
  await client.call_tool("analyze", {"x": "not a number"})
542
 
543
  async def test_path_type(self):
 
460
  pass
461
 
462
  async with Client(mcp) as client:
463
+ with pytest.raises(ClientError, match="Missing required argument"):
464
  await client.call_tool("analyze", {})
465
 
466
  async def test_literal_type_validation_error(self):
 
537
  assert isinstance(result[0], TextContent)
538
  assert result[0].text == "1.0"
539
 
540
+ with pytest.raises(ClientError, match="2 validation errors"):
541
  await client.call_tool("analyze", {"x": "not a number"})
542
 
543
  async def test_path_type(self):
tests/tools/test_tool.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from mcp.types import ImageContent, TextContent
3
+ from pydantic import BaseModel
4
+
5
+ from fastmcp import Image
6
+ from fastmcp.tools.tool import Tool
7
+
8
+
9
+ class TestToolFromFunction:
10
+ def test_basic_function(self):
11
+ """Test registering and running a basic function."""
12
+
13
+ def add(a: int, b: int) -> int:
14
+ """Add two numbers."""
15
+ return a + b
16
+
17
+ tool = Tool.from_function(add)
18
+
19
+ assert tool.name == "add"
20
+ assert tool.description == "Add two numbers."
21
+ assert tool.is_async is False
22
+ assert tool.parameters["properties"]["a"]["type"] == "integer"
23
+ assert tool.parameters["properties"]["b"]["type"] == "integer"
24
+
25
+ async def test_async_function(self):
26
+ """Test registering and running an async function."""
27
+
28
+ async def fetch_data(url: str) -> str:
29
+ """Fetch data from URL."""
30
+ return f"Data from {url}"
31
+
32
+ tool = Tool.from_function(fetch_data)
33
+
34
+ assert tool.name == "fetch_data"
35
+ assert tool.description == "Fetch data from URL."
36
+ assert tool.is_async is True
37
+ assert tool.parameters["properties"]["url"]["type"] == "string"
38
+
39
+ def test_pydantic_model_function(self):
40
+ """Test registering a function that takes a Pydantic model."""
41
+
42
+ class UserInput(BaseModel):
43
+ name: str
44
+ age: int
45
+
46
+ def create_user(user: UserInput, flag: bool) -> dict:
47
+ """Create a new user."""
48
+ return {"id": 1, **user.model_dump()}
49
+
50
+ tool = Tool.from_function(create_user)
51
+
52
+ assert tool.name == "create_user"
53
+ assert tool.description == "Create a new user."
54
+ assert tool.is_async is False
55
+ assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
56
+ assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
57
+ assert "flag" in tool.parameters["properties"]
58
+
59
+ async def test_tool_with_image_return(self):
60
+ def image_tool(data: bytes) -> Image:
61
+ return Image(data=data)
62
+
63
+ tool = Tool.from_function(image_tool)
64
+
65
+ result = await tool.run({"data": "test.png"})
66
+ assert tool.parameters["properties"]["data"]["type"] == "string"
67
+ assert isinstance(result[0], ImageContent)
68
+
69
+ def test_add_invalid_tool(self):
70
+ with pytest.raises(AttributeError):
71
+ Tool.from_function(1) # type: ignore
72
+
73
+ def test_add_lambda(self):
74
+ tool = Tool.from_function(lambda x: x, name="my_tool")
75
+ assert tool.name == "my_tool"
76
+
77
+ def test_add_lambda_with_no_name(self):
78
+ with pytest.raises(
79
+ ValueError, match="You must provide a name for lambda functions"
80
+ ):
81
+ Tool.from_function(lambda x: x)
82
+
83
+ def test_no_private_arguments(self):
84
+ def add(_a: int, _b: int) -> int:
85
+ """Add two numbers."""
86
+ return _a + _b
87
+
88
+ tool = Tool.from_function(add)
89
+ assert tool.parameters["properties"]["_a"]["type"] == "integer"
90
+ assert tool.parameters["properties"]["_b"]["type"] == "integer"
91
+
92
+
93
+ class TestToolJsonParsing:
94
+ """Tests for Tool's JSON pre-parsing functionality."""
95
+
96
+ async def test_json_string_arguments(self):
97
+ """Test that JSON string arguments are parsed and validated correctly"""
98
+
99
+ def simple_func(x: int, y: list[str]) -> str:
100
+ return f"{x}-{','.join(y)}"
101
+
102
+ # Create a tool to use its JSON pre-parsing logic
103
+ tool = Tool.from_function(simple_func)
104
+
105
+ # Prepare arguments where some are JSON strings
106
+ json_args = {
107
+ "x": 1,
108
+ "y": '["a", "b", "c"]', # JSON string
109
+ }
110
+
111
+ # Run the tool which will do JSON parsing
112
+ result = await tool.run(json_args)
113
+ assert len(result) == 1
114
+ assert isinstance(result[0], TextContent)
115
+ assert result[0].text == "1-a,b,c"
116
+
117
+ async def test_str_vs_list_str(self):
118
+ """Test handling of string vs list[str] type annotations."""
119
+
120
+ def func_with_str_types(str_or_list: str | list[str]) -> str | list[str]:
121
+ return str_or_list
122
+
123
+ tool = Tool.from_function(func_with_str_types)
124
+
125
+ # Test regular string input (should remain a string)
126
+ result = await tool.run({"str_or_list": "hello"})
127
+ assert len(result) == 1
128
+ assert isinstance(result[0], TextContent)
129
+ assert result[0].text == "hello"
130
+
131
+ # Test JSON string input (should be parsed as a string)
132
+ result = await tool.run({"str_or_list": '"hello"'})
133
+ assert len(result) == 1
134
+ assert isinstance(result[0], TextContent)
135
+ assert result[0].text == "hello"
136
+
137
+ # Test JSON list input (should be parsed as a list)
138
+ result = await tool.run({"str_or_list": '["hello", "world"]'})
139
+ assert len(result) == 1
140
+ assert isinstance(result[0], TextContent)
141
+
142
+ # The exact formatting might vary, so we just check that it contains the key elements
143
+ text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "")
144
+ assert "hello" in text_without_whitespace
145
+ assert "world" in text_without_whitespace
146
+ assert "[" in text_without_whitespace
147
+ assert "]" in text_without_whitespace
148
+
149
+ async def test_keep_str_as_str(self):
150
+ """Test that string arguments are kept as strings when they're not valid JSON"""
151
+
152
+ def func_with_str_types(string: str) -> str:
153
+ return string
154
+
155
+ tool = Tool.from_function(func_with_str_types)
156
+
157
+ # Invalid JSON should remain a string
158
+ invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
159
+ result = await tool.run({"string": invalid_json})
160
+ assert len(result) == 1
161
+ assert isinstance(result[0], TextContent)
162
+ assert result[0].text == invalid_json
163
+
164
+ async def test_keep_str_union_as_str(self):
165
+ """Test that string arguments are kept as strings when parsing would create an invalid value"""
166
+
167
+ def func_with_str_types(
168
+ string: str | dict[int, str] | None,
169
+ ) -> str | dict[int, str] | None:
170
+ return string
171
+
172
+ tool = Tool.from_function(func_with_str_types)
173
+
174
+ # Invalid JSON for the union type should remain a string
175
+ invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
176
+ result = await tool.run({"string": invalid_json})
177
+ assert len(result) == 1
178
+ assert isinstance(result[0], TextContent)
179
+ assert result[0].text == invalid_json
180
+
181
+ async def test_complex_type_validation(self):
182
+ """Test that parsed JSON is validated against complex types"""
183
+
184
+ class SomeModel(BaseModel):
185
+ x: int
186
+ y: dict[int, str]
187
+
188
+ def func_with_complex_type(data: SomeModel) -> SomeModel:
189
+ return data
190
+
191
+ tool = Tool.from_function(func_with_complex_type)
192
+
193
+ # Valid JSON for the model
194
+ valid_json = '{"x": 1, "y": {"1": "hello"}}'
195
+ result = await tool.run({"data": valid_json})
196
+ assert len(result) == 1
197
+ assert isinstance(result[0], TextContent)
198
+ assert '"x": 1' in result[0].text
199
+ assert '"y": {' in result[0].text
200
+ assert '"1": "hello"' in result[0].text
201
+
202
+ # Invalid JSON for the model (y has string keys, not int keys)
203
+ # Should throw a validation error
204
+ invalid_json = '{"x": 1, "y": {"invalid": "hello"}}'
205
+ with pytest.raises(Exception):
206
+ await tool.run({"data": invalid_json})
tests/tools/test_tool_manager.py CHANGED
@@ -369,7 +369,7 @@ class TestCallTools:
369
  assert isinstance(result, list)
370
  assert len(result) == 1
371
  assert isinstance(result[0], TextContent)
372
- assert result[0].text == '"a"'
373
 
374
  async def test_call_tool_with_complex_model(self):
375
  class MyShrimpTank(BaseModel):
@@ -379,7 +379,7 @@ class TestCallTools:
379
  shrimp: list[Shrimp]
380
  x: None
381
 
382
- def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]:
383
  return [x.name for x in tank.shrimp]
384
 
385
  manager = ToolManager()
@@ -449,7 +449,24 @@ class TestToolSchema:
449
  tool = manager.add_tool_from_fn(something)
450
  assert "ctx" not in json.dumps(tool.parameters)
451
  assert "Context" not in json.dumps(tool.parameters)
452
- assert "ctx" not in tool.fn_metadata.arg_model.model_fields
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
 
455
  class TestContextHandling:
 
369
  assert isinstance(result, list)
370
  assert len(result) == 1
371
  assert isinstance(result[0], TextContent)
372
+ assert result[0].text == "a"
373
 
374
  async def test_call_tool_with_complex_model(self):
375
  class MyShrimpTank(BaseModel):
 
379
  shrimp: list[Shrimp]
380
  x: None
381
 
382
+ def name_shrimp(tank: MyShrimpTank, ctx: Context | None) -> list[str]:
383
  return [x.name for x in tank.shrimp]
384
 
385
  manager = ToolManager()
 
449
  tool = manager.add_tool_from_fn(something)
450
  assert "ctx" not in json.dumps(tool.parameters)
451
  assert "Context" not in json.dumps(tool.parameters)
452
+
453
+ async def test_optional_context_arg_excluded_from_schema(self):
454
+ def something(a: int, ctx: Context | None) -> int:
455
+ return a
456
+
457
+ manager = ToolManager()
458
+ tool = manager.add_tool_from_fn(something)
459
+ assert "ctx" not in json.dumps(tool.parameters)
460
+ assert "Context" not in json.dumps(tool.parameters)
461
+
462
+ async def test_annotated_context_arg_excluded_from_schema(self):
463
+ def something(a: int, ctx: Annotated[Context | int | None, "ctx"]) -> int:
464
+ return a
465
+
466
+ manager = ToolManager()
467
+ tool = manager.add_tool_from_fn(something)
468
+ assert "ctx" not in json.dumps(tool.parameters)
469
+ assert "Context" not in json.dumps(tool.parameters)
470
 
471
 
472
  class TestContextHandling:
tests/utilities/test_func_metadata.py DELETED
@@ -1,481 +0,0 @@
1
- from typing import Annotated
2
-
3
- import annotated_types
4
- import pytest
5
- from pydantic import BaseModel, Field
6
-
7
- from fastmcp.utilities.func_metadata import func_metadata
8
-
9
-
10
- class SomeInputModelA(BaseModel):
11
- pass
12
-
13
-
14
- class SomeInputModelB(BaseModel):
15
- class InnerModel(BaseModel):
16
- x: int
17
-
18
- how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")]
19
- ok: InnerModel
20
- y: None
21
-
22
-
23
- def complex_arguments_fn(
24
- an_int: int,
25
- must_be_none: None,
26
- must_be_none_dumb_annotation: Annotated[None, "blah"],
27
- list_of_ints: list[int],
28
- # list[str] | str is an interesting case because if it comes in as JSON like
29
- # "[\"a\", \"b\"]" then it will be naively parsed as a string.
30
- list_str_or_str: list[str] | str,
31
- an_int_annotated_with_field: Annotated[
32
- int, Field(description="An int with a field")
33
- ],
34
- an_int_annotated_with_field_and_others: Annotated[
35
- int,
36
- str, # Should be ignored, really
37
- Field(description="An int with a field"),
38
- annotated_types.Gt(1),
39
- ],
40
- an_int_annotated_with_junk: Annotated[
41
- int,
42
- "123",
43
- 456,
44
- ],
45
- field_with_default_via_field_annotation_before_nondefault_arg: Annotated[
46
- int, Field(1)
47
- ],
48
- unannotated,
49
- my_model_a: SomeInputModelA,
50
- my_model_a_forward_ref: "SomeInputModelA",
51
- my_model_b: SomeInputModelB,
52
- an_int_annotated_with_field_default: Annotated[
53
- int,
54
- Field(1, description="An int with a field"),
55
- ],
56
- unannotated_with_default=5,
57
- my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008
58
- an_int_with_default: int = 1,
59
- must_be_none_with_default: None = None,
60
- an_int_with_equals_field: int = Field(1, ge=0),
61
- int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
62
- ) -> str:
63
- _ = (
64
- an_int,
65
- must_be_none,
66
- must_be_none_dumb_annotation,
67
- list_of_ints,
68
- list_str_or_str,
69
- an_int_annotated_with_field,
70
- an_int_annotated_with_field_and_others,
71
- an_int_annotated_with_junk,
72
- field_with_default_via_field_annotation_before_nondefault_arg,
73
- unannotated,
74
- an_int_annotated_with_field_default,
75
- unannotated_with_default,
76
- my_model_a,
77
- my_model_a_forward_ref,
78
- my_model_b,
79
- my_model_a_with_default,
80
- an_int_with_default,
81
- must_be_none_with_default,
82
- an_int_with_equals_field,
83
- int_annotated_with_default,
84
- )
85
- return "ok!"
86
-
87
-
88
- async def test_complex_function_runtime_arg_validation_non_json():
89
- """Test that basic non-JSON arguments are validated correctly"""
90
- meta = func_metadata(complex_arguments_fn)
91
-
92
- # Test with minimum required arguments
93
- result = await meta.call_fn_with_arg_validation(
94
- complex_arguments_fn,
95
- fn_is_async=False,
96
- arguments_to_validate={
97
- "an_int": 1,
98
- "must_be_none": None,
99
- "must_be_none_dumb_annotation": None,
100
- "list_of_ints": [1, 2, 3],
101
- "list_str_or_str": "hello",
102
- "an_int_annotated_with_field": 42,
103
- "an_int_annotated_with_field_and_others": 5,
104
- "an_int_annotated_with_junk": 100,
105
- "unannotated": "test",
106
- "my_model_a": {},
107
- "my_model_a_forward_ref": {},
108
- "my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
109
- },
110
- arguments_to_pass_directly=None,
111
- )
112
- assert result == "ok!"
113
-
114
- # Test with invalid types
115
- with pytest.raises(ValueError):
116
- await meta.call_fn_with_arg_validation(
117
- complex_arguments_fn,
118
- fn_is_async=False,
119
- arguments_to_validate={"an_int": "not an int"},
120
- arguments_to_pass_directly=None,
121
- )
122
-
123
-
124
- async def test_complex_function_runtime_arg_validation_with_json():
125
- """Test that JSON string arguments are parsed and validated correctly"""
126
- meta = func_metadata(complex_arguments_fn)
127
-
128
- result = await meta.call_fn_with_arg_validation(
129
- complex_arguments_fn,
130
- fn_is_async=False,
131
- arguments_to_validate={
132
- "an_int": 1,
133
- "must_be_none": None,
134
- "must_be_none_dumb_annotation": None,
135
- "list_of_ints": "[1, 2, 3]", # JSON string
136
- "list_str_or_str": '["a", "b", "c"]', # JSON string
137
- "an_int_annotated_with_field": 42,
138
- "an_int_annotated_with_field_and_others": "5", # JSON string
139
- "an_int_annotated_with_junk": 100,
140
- "unannotated": "test",
141
- "my_model_a": "{}", # JSON string
142
- "my_model_a_forward_ref": "{}", # JSON string
143
- "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
144
- },
145
- arguments_to_pass_directly=None,
146
- )
147
- assert result == "ok!"
148
-
149
-
150
- def test_str_vs_list_str():
151
- """Test handling of string vs list[str] type annotations.
152
-
153
- This is tricky as '"hello"' can be parsed as a JSON string or a Python string.
154
- We want to make sure it's kept as a python string.
155
- """
156
-
157
- def func_with_str_types(str_or_list: str | list[str]):
158
- return str_or_list
159
-
160
- meta = func_metadata(func_with_str_types)
161
-
162
- # Test string input for union type
163
- result = meta.pre_parse_json({"str_or_list": "hello"})
164
- assert result["str_or_list"] == "hello"
165
-
166
- # Test string input that contains valid JSON for union type
167
- # We want to see here that the JSON-vali string is NOT parsed as JSON, but rather
168
- # kept as a raw string
169
- result = meta.pre_parse_json({"str_or_list": '"hello"'})
170
- assert result["str_or_list"] == '"hello"'
171
-
172
- # Test list input for union type
173
- result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'})
174
- assert result["str_or_list"] == ["hello", "world"]
175
-
176
-
177
- def test_keep_str_as_str():
178
- """Test that string arguments are kept as strings"""
179
-
180
- def func_with_str_types(string: str):
181
- return string
182
-
183
- meta = func_metadata(func_with_str_types)
184
- result = meta.pre_parse_json(
185
- {"string": "{'nice to meet you': 'hello', 'goodbye': 5}"}
186
- )
187
- assert result["string"] == "{'nice to meet you': 'hello', 'goodbye': 5}"
188
-
189
-
190
- def test_missing_annotation():
191
- """Test that missing annotations don't cause errors"""
192
-
193
- def fn(x, y):
194
- return x + y
195
-
196
- meta = func_metadata(fn)
197
- result = meta.pre_parse_json({"x": "1", "y": "2"})
198
- assert result["x"] == "1"
199
- assert result["y"] == "2"
200
-
201
-
202
- def test_keep_str_union_as_str():
203
- """Test that string arguments are kept as strings"""
204
-
205
- def func_with_str_types(string: str | dict[int, str] | None):
206
- return string
207
-
208
- meta = func_metadata(func_with_str_types)
209
- result = meta.pre_parse_json(
210
- {"string": "{'nice to meet you': 'hello', 'goodbye': 5}"}
211
- )
212
- assert result["string"] == "{'nice to meet you': 'hello', 'goodbye': 5}"
213
-
214
-
215
- def test_keep_str_complex_type_as_str():
216
- """Test that string arguments are kept as strings because it's invalid for the field"""
217
-
218
- class SomeModel(BaseModel):
219
- x: int
220
- y: dict[int, str]
221
-
222
- def func_with_str_types(string: str | SomeModel | None):
223
- return string
224
-
225
- meta = func_metadata(func_with_str_types)
226
- result = meta.pre_parse_json({"string": '{"x": 1, "y": {"invalid": "hello"}}'})
227
- assert result["string"] == '{"x": 1, "y": {"invalid": "hello"}}'
228
-
229
-
230
- def test_convert_str_to_complex_type():
231
- """Test that string arguments are converted to the complex type because it's valid for the field"""
232
-
233
- class SomeModel(BaseModel):
234
- x: int
235
- y: dict[int, str]
236
-
237
- def func_with_str_types(string: str | SomeModel | None):
238
- return string
239
-
240
- meta = func_metadata(func_with_str_types)
241
- result = meta.pre_parse_json({"string": '{"x": 1, "y": {"1": "hello"}}'})
242
- assert result["string"] == {"x": 1, "y": {"1": "hello"}}
243
-
244
-
245
- def test_skip_names():
246
- """Test that skipped parameters are not included in the model"""
247
-
248
- def func_with_many_params(
249
- keep_this: int, skip_this: str, also_keep: float, also_skip: bool
250
- ):
251
- return keep_this, skip_this, also_keep, also_skip
252
-
253
- # Skip some parameters
254
- meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"])
255
-
256
- # Check model fields
257
- assert "keep_this" in meta.arg_model.model_fields
258
- assert "also_keep" in meta.arg_model.model_fields
259
- assert "skip_this" not in meta.arg_model.model_fields
260
- assert "also_skip" not in meta.arg_model.model_fields
261
-
262
- # Validate that we can call with only non-skipped parameters
263
- model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore
264
- assert model.keep_this == 1 # type: ignore
265
- assert model.also_keep == 2.5 # type: ignore
266
-
267
-
268
- async def test_lambda_function():
269
- """Test lambda function schema and validation"""
270
- fn = lambda x, y=5: x # noqa: E731
271
- meta = func_metadata(lambda x, y=5: x)
272
-
273
- # Test schema
274
- assert meta.arg_model.model_json_schema() == {
275
- "properties": {
276
- "x": {"title": "x", "type": "string"},
277
- "y": {"default": 5, "title": "y", "type": "string"},
278
- },
279
- "required": ["x"],
280
- "title": "<lambda>Arguments",
281
- "type": "object",
282
- }
283
-
284
- async def check_call(args):
285
- return await meta.call_fn_with_arg_validation(
286
- fn,
287
- fn_is_async=False,
288
- arguments_to_validate=args,
289
- arguments_to_pass_directly=None,
290
- )
291
-
292
- # Basic calls
293
- assert await check_call({"x": "hello"}) == "hello"
294
- assert await check_call({"x": "hello", "y": "world"}) == "hello"
295
- assert await check_call({"x": '"hello"'}) == '"hello"'
296
-
297
- # Missing required arg
298
- with pytest.raises(ValueError):
299
- await check_call({"y": "world"})
300
-
301
-
302
- def test_complex_function_json_schema():
303
- """Test JSON schema generation for complex function arguments.
304
-
305
- Note: Different versions of pydantic output slightly different
306
- JSON Schema formats for model fields with defaults. The format changed in 2.9.0:
307
-
308
- 1. Before 2.9.0:
309
- {
310
- "allOf": [{"$ref": "#/$defs/Model"}],
311
- "default": {}
312
- }
313
-
314
- 2. Since 2.9.0:
315
- {
316
- "$ref": "#/$defs/Model",
317
- "default": {}
318
- }
319
-
320
- Both formats are valid and functionally equivalent. This test accepts either format
321
- to ensure compatibility across our supported pydantic versions.
322
-
323
- This change in format does not affect runtime behavior since:
324
- 1. Both schemas validate the same way
325
- 2. The actual model classes and validation logic are unchanged
326
- 3. func_metadata uses model_validate/model_dump, not the schema directly
327
- """
328
- meta = func_metadata(complex_arguments_fn)
329
- actual_schema = meta.arg_model.model_json_schema()
330
-
331
- # Create a copy of the actual schema to normalize
332
- normalized_schema = actual_schema.copy()
333
-
334
- # Normalize the my_model_a_with_default field to handle both pydantic formats
335
- if "allOf" in actual_schema["properties"]["my_model_a_with_default"]:
336
- normalized_schema["properties"]["my_model_a_with_default"] = {
337
- "$ref": "#/$defs/SomeInputModelA",
338
- "default": {},
339
- }
340
-
341
- assert normalized_schema == {
342
- "$defs": {
343
- "InnerModel": {
344
- "properties": {"x": {"title": "X", "type": "integer"}},
345
- "required": ["x"],
346
- "title": "InnerModel",
347
- "type": "object",
348
- },
349
- "SomeInputModelA": {
350
- "properties": {},
351
- "title": "SomeInputModelA",
352
- "type": "object",
353
- },
354
- "SomeInputModelB": {
355
- "properties": {
356
- "how_many_shrimp": {
357
- "description": "How many shrimp in the tank???",
358
- "title": "How Many Shrimp",
359
- "type": "integer",
360
- },
361
- "ok": {"$ref": "#/$defs/InnerModel"},
362
- "y": {"title": "Y", "type": "null"},
363
- },
364
- "required": ["how_many_shrimp", "ok", "y"],
365
- "title": "SomeInputModelB",
366
- "type": "object",
367
- },
368
- },
369
- "properties": {
370
- "an_int": {"title": "An Int", "type": "integer"},
371
- "must_be_none": {"title": "Must Be None", "type": "null"},
372
- "must_be_none_dumb_annotation": {
373
- "title": "Must Be None Dumb Annotation",
374
- "type": "null",
375
- },
376
- "list_of_ints": {
377
- "items": {"type": "integer"},
378
- "title": "List Of Ints",
379
- "type": "array",
380
- },
381
- "list_str_or_str": {
382
- "anyOf": [
383
- {"items": {"type": "string"}, "type": "array"},
384
- {"type": "string"},
385
- ],
386
- "title": "List Str Or Str",
387
- },
388
- "an_int_annotated_with_field": {
389
- "description": "An int with a field",
390
- "title": "An Int Annotated With Field",
391
- "type": "integer",
392
- },
393
- "an_int_annotated_with_field_and_others": {
394
- "description": "An int with a field",
395
- "exclusiveMinimum": 1,
396
- "title": "An Int Annotated With Field And Others",
397
- "type": "integer",
398
- },
399
- "an_int_annotated_with_junk": {
400
- "title": "An Int Annotated With Junk",
401
- "type": "integer",
402
- },
403
- "field_with_default_via_field_annotation_before_nondefault_arg": {
404
- "default": 1,
405
- "title": "Field With Default Via Field Annotation Before Nondefault Arg",
406
- "type": "integer",
407
- },
408
- "unannotated": {"title": "unannotated", "type": "string"},
409
- "my_model_a": {"$ref": "#/$defs/SomeInputModelA"},
410
- "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"},
411
- "my_model_b": {"$ref": "#/$defs/SomeInputModelB"},
412
- "an_int_annotated_with_field_default": {
413
- "default": 1,
414
- "description": "An int with a field",
415
- "title": "An Int Annotated With Field Default",
416
- "type": "integer",
417
- },
418
- "unannotated_with_default": {
419
- "default": 5,
420
- "title": "unannotated_with_default",
421
- "type": "string",
422
- },
423
- "my_model_a_with_default": {
424
- "$ref": "#/$defs/SomeInputModelA",
425
- "default": {},
426
- },
427
- "an_int_with_default": {
428
- "default": 1,
429
- "title": "An Int With Default",
430
- "type": "integer",
431
- },
432
- "must_be_none_with_default": {
433
- "default": None,
434
- "title": "Must Be None With Default",
435
- "type": "null",
436
- },
437
- "an_int_with_equals_field": {
438
- "default": 1,
439
- "minimum": 0,
440
- "title": "An Int With Equals Field",
441
- "type": "integer",
442
- },
443
- "int_annotated_with_default": {
444
- "default": 5,
445
- "description": "hey",
446
- "title": "Int Annotated With Default",
447
- "type": "integer",
448
- },
449
- },
450
- "required": [
451
- "an_int",
452
- "must_be_none",
453
- "must_be_none_dumb_annotation",
454
- "list_of_ints",
455
- "list_str_or_str",
456
- "an_int_annotated_with_field",
457
- "an_int_annotated_with_field_and_others",
458
- "an_int_annotated_with_junk",
459
- "unannotated",
460
- "my_model_a",
461
- "my_model_a_forward_ref",
462
- "my_model_b",
463
- ],
464
- "title": "complex_arguments_fnArguments",
465
- "type": "object",
466
- }
467
-
468
-
469
- def test_str_vs_int():
470
- """
471
- Test that string values are kept as strings even when they contain numbers,
472
- while numbers are parsed correctly.
473
- """
474
-
475
- def func_with_str_and_int(a: str, b: int):
476
- return a
477
-
478
- meta = func_metadata(func_with_str_and_int)
479
- result = meta.pre_parse_json({"a": "123", "b": 123})
480
- assert result["a"] == "123"
481
- assert result["b"] == 123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/utilities/test_json_schema.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastmcp.utilities.json_schema import _prune_param, prune_params
2
+
3
+
4
+ def test_prune_param_nonexistent():
5
+ """Test pruning a parameter that doesn't exist."""
6
+ schema = {"properties": {"foo": {"type": "string"}}}
7
+ result = _prune_param(schema, "bar")
8
+ assert result == schema # Schema should be unchanged
9
+
10
+
11
+ def test_prune_param_exists():
12
+ """Test pruning a parameter that exists."""
13
+ schema = {"properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}}}
14
+ result = _prune_param(schema, "bar")
15
+ assert result["properties"] == {"foo": {"type": "string"}}
16
+
17
+
18
+ def test_prune_param_last_property():
19
+ """Test pruning the only/last parameter, should leave empty properties object."""
20
+ schema = {"properties": {"foo": {"type": "string"}}}
21
+ result = _prune_param(schema, "foo")
22
+ assert "properties" in result
23
+ assert result["properties"] == {}
24
+
25
+
26
+ def test_prune_param_from_required():
27
+ """Test pruning a parameter that's in the required list."""
28
+ schema = {
29
+ "properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}},
30
+ "required": ["foo", "bar"],
31
+ }
32
+ result = _prune_param(schema, "bar")
33
+ assert result["required"] == ["foo"]
34
+
35
+
36
+ def test_prune_param_last_required():
37
+ """Test pruning the last required parameter, should remove required field."""
38
+ schema = {
39
+ "properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}},
40
+ "required": ["foo"],
41
+ }
42
+ result = _prune_param(schema, "foo")
43
+ assert "required" not in result
44
+
45
+
46
+ def test_prune_param_with_refs():
47
+ """Test pruning a parameter that has references in $defs."""
48
+ schema = {
49
+ "properties": {
50
+ "foo": {"$ref": "#/$defs/foo_def"},
51
+ "bar": {"$ref": "#/$defs/bar_def"},
52
+ },
53
+ "$defs": {
54
+ "foo_def": {"type": "string"},
55
+ "bar_def": {"type": "integer"},
56
+ },
57
+ }
58
+ result = _prune_param(schema, "bar")
59
+ assert "bar_def" not in result["$defs"]
60
+ assert "foo_def" in result["$defs"]
61
+
62
+
63
+ def test_prune_param_all_refs():
64
+ """Test pruning all parameters with refs, should remove $defs."""
65
+ schema = {
66
+ "properties": {
67
+ "foo": {"$ref": "#/$defs/foo_def"},
68
+ },
69
+ "$defs": {
70
+ "foo_def": {"type": "string"},
71
+ },
72
+ }
73
+ result = _prune_param(schema, "foo")
74
+ assert "$defs" not in result
75
+
76
+
77
+ def test_prune_params_multiple():
78
+ """Test pruning multiple parameters at once."""
79
+ schema = {
80
+ "properties": {
81
+ "foo": {"type": "string"},
82
+ "bar": {"type": "integer"},
83
+ "baz": {"type": "boolean"},
84
+ },
85
+ "required": ["foo", "bar"],
86
+ }
87
+ result = prune_params(schema, ["foo", "baz"])
88
+ assert result["properties"] == {"bar": {"type": "integer"}}
89
+ assert result["required"] == ["bar"]
90
+
91
+
92
+ def test_prune_params_nested_refs():
93
+ """Test pruning with nested references."""
94
+ schema = {
95
+ "properties": {
96
+ "foo": {
97
+ "type": "object",
98
+ "properties": {"nested": {"$ref": "#/$defs/nested_def"}},
99
+ },
100
+ "bar": {"$ref": "#/$defs/bar_def"},
101
+ },
102
+ "$defs": {
103
+ "nested_def": {"type": "string"},
104
+ "bar_def": {"type": "integer"},
105
+ },
106
+ }
107
+ # Removing foo should keep nested_def as it's not referenced anymore
108
+ result = _prune_param(schema, "foo")
109
+ assert "nested_def" not in result["$defs"]
110
+ assert "bar_def" in result["$defs"]
tests/utilities/test_typeadapter.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This test file adapts tests from test_func_metadata.py which tested a custom implementation
3
+ that has been replaced by pydantic TypeAdapters.
4
+
5
+ The tests ensure our TypeAdapter-based approach covers all the edge cases the old custom
6
+ implementation handled. Since we're now using standard pydantic functionality, these tests
7
+ may be redundant with pydantic's own tests and could potentially be removed in the future.
8
+ """
9
+
10
+ from typing import Annotated
11
+
12
+ import annotated_types
13
+ import pytest
14
+ from pydantic import BaseModel, Field
15
+
16
+ from fastmcp.utilities.json_schema import prune_params
17
+ from fastmcp.utilities.types import get_cached_typeadapter
18
+
19
+
20
+ # Models must be defined at the module level for forward references to work
21
+ class SomeInputModelA(BaseModel):
22
+ pass
23
+
24
+
25
+ class SomeInputModelB(BaseModel):
26
+ class InnerModel(BaseModel):
27
+ x: int
28
+
29
+ how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")]
30
+ ok: InnerModel
31
+ y: None
32
+
33
+
34
+ # Define additional models needed in tests
35
+ class SomeComplexModel(BaseModel):
36
+ x: int
37
+ y: dict[int, str]
38
+
39
+
40
+ def complex_arguments_fn(
41
+ an_int: int,
42
+ must_be_none: None,
43
+ must_be_none_dumb_annotation: Annotated[None, "blah"],
44
+ list_of_ints: list[int],
45
+ # list[str] | str is an interesting case because if it comes in as JSON like
46
+ # "[\"a\", \"b\"]" then it will be naively parsed as a string.
47
+ list_str_or_str: list[str] | str,
48
+ an_int_annotated_with_field: Annotated[
49
+ int, Field(description="An int with a field")
50
+ ],
51
+ an_int_annotated_with_field_and_others: Annotated[
52
+ int,
53
+ str, # Should be ignored, really
54
+ Field(description="An int with a field"),
55
+ annotated_types.Gt(1),
56
+ ],
57
+ an_int_annotated_with_junk: Annotated[
58
+ int,
59
+ "123",
60
+ 456,
61
+ ],
62
+ field_with_default_via_field_annotation_before_nondefault_arg: Annotated[
63
+ int, Field(1)
64
+ ],
65
+ unannotated,
66
+ my_model_a: SomeInputModelA,
67
+ my_model_a_forward_ref: "SomeInputModelA",
68
+ my_model_b: SomeInputModelB,
69
+ an_int_annotated_with_field_default: Annotated[
70
+ int,
71
+ Field(1, description="An int with a field"),
72
+ ],
73
+ unannotated_with_default=5,
74
+ my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008
75
+ an_int_with_default: int = 1,
76
+ must_be_none_with_default: None = None,
77
+ an_int_with_equals_field: int = Field(1, ge=0),
78
+ int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
79
+ ) -> str:
80
+ _ = (
81
+ an_int,
82
+ must_be_none,
83
+ must_be_none_dumb_annotation,
84
+ list_of_ints,
85
+ list_str_or_str,
86
+ an_int_annotated_with_field,
87
+ an_int_annotated_with_field_and_others,
88
+ an_int_annotated_with_junk,
89
+ field_with_default_via_field_annotation_before_nondefault_arg,
90
+ unannotated,
91
+ an_int_annotated_with_field_default,
92
+ unannotated_with_default,
93
+ my_model_a,
94
+ my_model_a_forward_ref,
95
+ my_model_b,
96
+ my_model_a_with_default,
97
+ an_int_with_default,
98
+ must_be_none_with_default,
99
+ an_int_with_equals_field,
100
+ int_annotated_with_default,
101
+ )
102
+ return "ok!"
103
+
104
+
105
+ def get_simple_func_adapter():
106
+ """Get a TypeAdapter for a simple function to avoid forward reference issues"""
107
+
108
+ def simple_func(x: int, y: str = "default") -> str:
109
+ return f"{x}-{y}"
110
+
111
+ return get_cached_typeadapter(simple_func)
112
+
113
+
114
+ async def test_complex_function_runtime_arg_validation_non_json():
115
+ """Test that basic non-JSON arguments are validated correctly using a simpler function"""
116
+ type_adapter = get_simple_func_adapter()
117
+
118
+ # Test with minimum required arguments
119
+ args = {"x": 1}
120
+ result = type_adapter.validate_python(args)
121
+ assert (
122
+ result == "1-default"
123
+ ) # Don't call result() as TypeAdapter returns the value directly
124
+
125
+ # Test with all arguments
126
+ args = {"x": 1, "y": "hello"}
127
+ result = type_adapter.validate_python(args)
128
+ assert result == "1-hello"
129
+
130
+ # Test with invalid types
131
+ with pytest.raises(Exception):
132
+ type_adapter.validate_python({"x": "not an int"})
133
+
134
+
135
+ def test_missing_annotation():
136
+ """Test that missing annotations don't cause errors"""
137
+
138
+ def func_no_annotations(x, y):
139
+ return x + y
140
+
141
+ type_adapter = get_cached_typeadapter(func_no_annotations)
142
+ result = type_adapter.validate_python({"x": "1", "y": "2"})
143
+ assert result == "12" # String concatenation since no type info
144
+
145
+
146
+ def test_convert_str_to_complex_type():
147
+ """Test that string arguments are converted to the complex type when valid"""
148
+
149
+ def func_with_str_types(string: SomeComplexModel):
150
+ return string
151
+
152
+ # Create a valid model instance
153
+ input_data = {"x": 1, "y": {1: "hello"}}
154
+
155
+ # Validate with model directly
156
+ SomeComplexModel.model_validate(input_data)
157
+
158
+ # Now check if type adapter validates correctly
159
+ type_adapter = get_cached_typeadapter(func_with_str_types)
160
+ result = type_adapter.validate_python({"string": input_data})
161
+
162
+ assert isinstance(result, SomeComplexModel)
163
+ assert result.x == 1
164
+ assert result.y == {1: "hello"}
165
+
166
+
167
+ def test_skip_names():
168
+ """Test that skipped parameters are not included in the schema"""
169
+
170
+ def func_with_many_params(
171
+ keep_this: int, skip_this: str, also_keep: float, also_skip: bool
172
+ ):
173
+ return keep_this, skip_this, also_keep, also_skip
174
+
175
+ # Get schema and prune parameters
176
+ type_adapter = get_cached_typeadapter(func_with_many_params)
177
+ schema = type_adapter.json_schema()
178
+ pruned_schema = prune_params(schema, params=["skip_this", "also_skip"])
179
+
180
+ # Check that only the desired parameters remain
181
+ assert "keep_this" in pruned_schema["properties"]
182
+ assert "also_keep" in pruned_schema["properties"]
183
+ assert "skip_this" not in pruned_schema["properties"]
184
+ assert "also_skip" not in pruned_schema["properties"]
185
+
186
+ # The pruned parameters should also be removed from required
187
+ if "required" in pruned_schema:
188
+ assert "skip_this" not in pruned_schema["required"]
189
+ assert "also_skip" not in pruned_schema["required"]
190
+
191
+
192
+ async def test_lambda_function():
193
+ """Test lambda function schema and validation"""
194
+ fn = lambda x, y=5: str(x) # noqa: E731
195
+ type_adapter = get_cached_typeadapter(fn)
196
+
197
+ # Basic calls - validate_python returns the result directly
198
+ result = type_adapter.validate_python({"x": "hello"})
199
+ assert result == "hello"
200
+
201
+ result = type_adapter.validate_python({"x": "hello", "y": "world"})
202
+ assert result == "hello"
203
+
204
+ # Missing required arg
205
+ with pytest.raises(Exception):
206
+ type_adapter.validate_python({"y": "world"})
207
+
208
+
209
+ def test_basic_json_schema():
210
+ """Test JSON schema generation for a simple function"""
211
+
212
+ def simple_func(a: int, b: str = "default") -> str:
213
+ return f"{a}-{b}"
214
+
215
+ type_adapter = get_cached_typeadapter(simple_func)
216
+ schema = type_adapter.json_schema()
217
+
218
+ # Check basic properties
219
+ assert "properties" in schema
220
+ assert "a" in schema["properties"]
221
+ assert "b" in schema["properties"]
222
+ assert schema["properties"]["a"]["type"] == "integer"
223
+ assert schema["properties"]["b"]["type"] == "string"
224
+ assert "default" in schema["properties"]["b"]
225
+ assert schema["properties"]["b"]["default"] == "default"
226
+
227
+ # Check required
228
+ assert "required" in schema
229
+ assert "a" in schema["required"]
230
+ assert "b" not in schema["required"]
231
+
232
+
233
+ def test_str_vs_int():
234
+ """
235
+ Test that string values are kept as strings even when they contain numbers,
236
+ while numbers are parsed correctly.
237
+ """
238
+
239
+ def func_with_str_and_int(a: str, b: int):
240
+ return a
241
+
242
+ type_adapter = get_cached_typeadapter(func_with_str_and_int)
243
+ result = type_adapter.validate_python({"a": "123", "b": 123})
244
+ assert result == "123"
uv.lock CHANGED
The diff for this file is too large to render. See raw diff