Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
388409a
1
Parent(s): ed196c3
Replace custom parsing with TypeAdapter
Browse files- src/fastmcp/server/openapi.py +0 -4
- src/fastmcp/server/proxy.py +0 -2
- src/fastmcp/tools/tool.py +36 -34
- src/fastmcp/utilities/func_metadata.py +0 -229
- src/fastmcp/utilities/json_schema.py +59 -0
- src/fastmcp/utilities/types.py +13 -0
- tests/server/test_server_interactions.py +2 -2
- tests/tools/test_tool.py +206 -0
- tests/tools/test_tool_manager.py +20 -3
- tests/utilities/test_func_metadata.py +0 -481
- tests/utilities/test_json_schema.py +110 -0
- tests/utilities/test_typeadapter.py +244 -0
- uv.lock +0 -0
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.
|
| 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 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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=
|
| 106 |
name=func_name,
|
| 107 |
description=func_doc,
|
| 108 |
-
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 |
-
|
| 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="
|
| 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
|
| 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 ==
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|