Spaces:
Running
Running
Merge branch 'main' into auth
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- docs/clients/client.mdx +1 -1
- docs/clients/tools.mdx +112 -14
- docs/patterns/tool-transformation.mdx +39 -1
- docs/servers/tools.mdx +209 -35
- pyproject.toml +1 -0
- src/fastmcp/client/client.py +68 -14
- src/fastmcp/contrib/component_manager/README.md +170 -0
- src/fastmcp/contrib/component_manager/__init__.py +4 -0
- src/fastmcp/contrib/component_manager/component_manager.py +186 -0
- src/fastmcp/contrib/component_manager/component_service.py +225 -0
- src/fastmcp/contrib/component_manager/example.py +59 -0
- src/fastmcp/server/low_level.py +4 -2
- src/fastmcp/server/openapi.py +8 -7
- src/fastmcp/server/proxy.py +13 -8
- src/fastmcp/server/server.py +13 -7
- src/fastmcp/tools/tool.py +203 -21
- src/fastmcp/tools/tool_manager.py +3 -5
- src/fastmcp/tools/tool_transform.py +126 -27
- src/fastmcp/utilities/json_schema_type.py +646 -0
- src/fastmcp/utilities/openapi.py +10 -0
- src/fastmcp/utilities/types.py +53 -19
- tests/auth/test_oauth_client.py +3 -1
- tests/client/test_client.py +4 -3
- tests/client/test_notifications.py +6 -6
- tests/client/test_openapi.py +2 -2
- tests/client/test_roots.py +1 -3
- tests/client/test_sampling.py +3 -6
- tests/client/test_stdio.py +9 -9
- tests/client/test_streamable_http.py +1 -5
- tests/contrib/test_bulk_tool_caller.py +4 -1
- tests/contrib/test_component_manager.py +743 -0
- tests/deprecated/test_mount_import_arg_order.py +2 -2
- tests/server/http/test_http_dependencies.py +4 -6
- tests/server/openapi/test_openapi.py +32 -12
- tests/server/openapi/test_openapi_path_parameters.py +2 -11
- tests/server/test_import_server.py +6 -6
- tests/server/test_mount.py +49 -36
- tests/server/test_proxy.py +8 -6
- tests/server/test_server.py +51 -29
- tests/server/test_server_interactions.py +349 -73
- tests/server/test_tool_annotations.py +1 -5
- tests/server/test_tool_exclude_args.py +1 -7
- tests/test_examples.py +12 -12
- tests/tools/test_tool.py +698 -10
- tests/tools/test_tool_manager.py +42 -17
- tests/tools/test_tool_transform.py +321 -44
- tests/utilities/openapi/test_openapi.py +23 -0
- tests/utilities/test_json_schema_type.py +1441 -0
- tests/utilities/test_mcp_config.py +2 -2
- tests/utilities/test_types.py +27 -0
docs/clients/client.mdx
CHANGED
|
@@ -109,7 +109,7 @@ config = {
|
|
| 109 |
},
|
| 110 |
"local_server": {
|
| 111 |
# Local stdio server
|
| 112 |
-
"transport": "stdio"
|
| 113 |
"command": "python",
|
| 114 |
"args": ["./server.py", "--verbose"],
|
| 115 |
"env": {"DEBUG": "true"},
|
|
|
|
| 109 |
},
|
| 110 |
"local_server": {
|
| 111 |
# Local stdio server
|
| 112 |
+
"transport": "stdio",
|
| 113 |
"command": "python",
|
| 114 |
"args": ["./server.py", "--verbose"],
|
| 115 |
"env": {"DEBUG": "true"},
|
docs/clients/tools.mdx
CHANGED
|
@@ -37,10 +37,13 @@ Execute a tool using `call_tool()` with the tool name and arguments:
|
|
| 37 |
async with client:
|
| 38 |
# Simple tool call
|
| 39 |
result = await client.call_tool("add", {"a": 5, "b": 3})
|
| 40 |
-
# result ->
|
| 41 |
|
| 42 |
-
# Access
|
| 43 |
-
print(result
|
|
|
|
|
|
|
|
|
|
| 44 |
```
|
| 45 |
|
| 46 |
### Advanced Execution Options
|
|
@@ -72,21 +75,97 @@ async with client:
|
|
| 72 |
|
| 73 |
## Handling Results
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
```python
|
|
|
|
|
|
|
|
|
|
| 82 |
async with client:
|
| 83 |
result = await client.call_tool("get_weather", {"city": "London"})
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
```
|
| 91 |
|
| 92 |
## Error Handling
|
|
@@ -101,14 +180,32 @@ from fastmcp.exceptions import ToolError
|
|
| 101 |
async with client:
|
| 102 |
try:
|
| 103 |
result = await client.call_tool("potentially_failing_tool", {"param": "value"})
|
| 104 |
-
print("Tool succeeded:", result)
|
| 105 |
except ToolError as e:
|
| 106 |
print(f"Tool failed: {e}")
|
| 107 |
```
|
| 108 |
|
| 109 |
### Manual Error Checking
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
```python
|
| 114 |
async with client:
|
|
@@ -119,6 +216,7 @@ async with client:
|
|
| 119 |
print(f"Tool failed: {result.content}")
|
| 120 |
else:
|
| 121 |
print(f"Tool succeeded: {result.content}")
|
|
|
|
| 122 |
```
|
| 123 |
|
| 124 |
## Argument Handling
|
|
|
|
| 37 |
async with client:
|
| 38 |
# Simple tool call
|
| 39 |
result = await client.call_tool("add", {"a": 5, "b": 3})
|
| 40 |
+
# result -> CallToolResult with structured and unstructured data
|
| 41 |
|
| 42 |
+
# Access structured data (automatically deserialized)
|
| 43 |
+
print(result.data) # 8 (int) or {"result": 8} for primitive types
|
| 44 |
+
|
| 45 |
+
# Access traditional content blocks
|
| 46 |
+
print(result.content[0].text) # "8" (TextContent)
|
| 47 |
```
|
| 48 |
|
| 49 |
### Advanced Execution Options
|
|
|
|
| 75 |
|
| 76 |
## Handling Results
|
| 77 |
|
| 78 |
+
<VersionBadge version="2.10.0" />
|
| 79 |
+
|
| 80 |
+
Tool execution returns a `CallToolResult` object with both structured and traditional content. FastMCP's standout feature is the `.data` property, which doesn't just provide raw JSON but actually hydrates complete Python objects including complex types like datetimes, UUIDs, and custom classes.
|
| 81 |
+
|
| 82 |
+
### CallToolResult Properties
|
| 83 |
+
|
| 84 |
+
<Card icon="code" title="CallToolResult Properties">
|
| 85 |
+
<ResponseField name=".data" type="Any">
|
| 86 |
+
**FastMCP exclusive**: Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). Goes beyond JSON to provide complete object reconstruction from output schemas.
|
| 87 |
+
</ResponseField>
|
| 88 |
+
|
| 89 |
+
<ResponseField name=".content" type="list[mcp.types.ContentBlock]">
|
| 90 |
+
Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.) available from all MCP servers.
|
| 91 |
+
</ResponseField>
|
| 92 |
+
|
| 93 |
+
<ResponseField name=".structured_content" type="dict[str, Any] | None">
|
| 94 |
+
Standard MCP structured JSON data as sent by the server, available from all MCP servers that support structured outputs.
|
| 95 |
+
</ResponseField>
|
| 96 |
|
| 97 |
+
<ResponseField name=".is_error" type="bool">
|
| 98 |
+
Boolean indicating if the tool execution failed.
|
| 99 |
+
</ResponseField>
|
| 100 |
+
</Card>
|
| 101 |
+
|
| 102 |
+
### Structured Data Access
|
| 103 |
+
|
| 104 |
+
FastMCP's `.data` property provides fully hydrated Python objects, not just JSON dictionaries. This includes complex type reconstruction:
|
| 105 |
|
| 106 |
```python
|
| 107 |
+
from datetime import datetime
|
| 108 |
+
from uuid import UUID
|
| 109 |
+
|
| 110 |
async with client:
|
| 111 |
result = await client.call_tool("get_weather", {"city": "London"})
|
| 112 |
|
| 113 |
+
# FastMCP reconstructs complete Python objects from the server's output schema
|
| 114 |
+
weather = result.data # Server-defined WeatherReport object
|
| 115 |
+
print(f"Temperature: {weather.temperature}°C at {weather.timestamp}")
|
| 116 |
+
print(f"Station: {weather.station_id}")
|
| 117 |
+
print(f"Humidity: {weather.humidity}%")
|
| 118 |
+
|
| 119 |
+
# The timestamp is a real datetime object, not a string!
|
| 120 |
+
assert isinstance(weather.timestamp, datetime)
|
| 121 |
+
assert isinstance(weather.station_id, UUID)
|
| 122 |
+
|
| 123 |
+
# Compare with raw structured JSON (standard MCP)
|
| 124 |
+
print(f"Raw JSON: {result.structured_content}")
|
| 125 |
+
# {"temperature": 20, "timestamp": "2024-01-15T14:30:00Z", "station_id": "123e4567-..."}
|
| 126 |
+
|
| 127 |
+
# Traditional content blocks (standard MCP)
|
| 128 |
+
print(f"Text content: {result.content[0].text}")
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### Fallback Behavior
|
| 132 |
+
|
| 133 |
+
For tools without output schemas or when deserialization fails, `.data` will be `None`:
|
| 134 |
+
|
| 135 |
+
```python
|
| 136 |
+
async with client:
|
| 137 |
+
result = await client.call_tool("legacy_tool", {"param": "value"})
|
| 138 |
+
|
| 139 |
+
if result.data is not None:
|
| 140 |
+
# Structured output available and successfully deserialized
|
| 141 |
+
print(f"Structured: {result.data}")
|
| 142 |
+
else:
|
| 143 |
+
# No structured output or deserialization failed - use content blocks
|
| 144 |
+
for content in result.content:
|
| 145 |
+
if hasattr(content, 'text'):
|
| 146 |
+
print(f"Text result: {content.text}")
|
| 147 |
+
elif hasattr(content, 'data'):
|
| 148 |
+
print(f"Binary data: {len(content.data)} bytes")
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Primitive Type Unwrapping
|
| 152 |
+
|
| 153 |
+
<Tip>
|
| 154 |
+
FastMCP servers automatically wrap non-object results (like `int`, `str`, `bool`) in a `{"result": value}` structure to create valid structured outputs. FastMCP clients understand this convention and automatically unwrap the value in `.data` for convenience, so you get the original primitive value instead of a wrapper object.
|
| 155 |
+
</Tip>
|
| 156 |
+
|
| 157 |
+
```python
|
| 158 |
+
async with client:
|
| 159 |
+
result = await client.call_tool("calculate_sum", {"a": 5, "b": 3})
|
| 160 |
+
|
| 161 |
+
# FastMCP client automatically unwraps for convenience
|
| 162 |
+
print(result.data) # 8 (int) - the original value
|
| 163 |
+
|
| 164 |
+
# Raw structured content shows the server-side wrapping
|
| 165 |
+
print(result.structured_content) # {"result": 8}
|
| 166 |
+
|
| 167 |
+
# Other MCP clients would need to manually access ["result"]
|
| 168 |
+
# value = result.structured_content["result"] # Not needed with FastMCP!
|
| 169 |
```
|
| 170 |
|
| 171 |
## Error Handling
|
|
|
|
| 180 |
async with client:
|
| 181 |
try:
|
| 182 |
result = await client.call_tool("potentially_failing_tool", {"param": "value"})
|
| 183 |
+
print("Tool succeeded:", result.data)
|
| 184 |
except ToolError as e:
|
| 185 |
print(f"Tool failed: {e}")
|
| 186 |
```
|
| 187 |
|
| 188 |
### Manual Error Checking
|
| 189 |
|
| 190 |
+
You can disable automatic error raising and manually check the result:
|
| 191 |
+
|
| 192 |
+
```python
|
| 193 |
+
async with client:
|
| 194 |
+
result = await client.call_tool(
|
| 195 |
+
"potentially_failing_tool",
|
| 196 |
+
{"param": "value"},
|
| 197 |
+
raise_on_error=False
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if result.is_error:
|
| 201 |
+
print(f"Tool failed: {result.content[0].text}")
|
| 202 |
+
else:
|
| 203 |
+
print(f"Tool succeeded: {result.data}")
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
### Raw MCP Protocol Access
|
| 207 |
+
|
| 208 |
+
For complete control, use `call_tool_mcp()` which returns the raw MCP protocol object:
|
| 209 |
|
| 210 |
```python
|
| 211 |
async with client:
|
|
|
|
| 216 |
print(f"Tool failed: {result.content}")
|
| 217 |
else:
|
| 218 |
print(f"Tool succeeded: {result.content}")
|
| 219 |
+
# Note: No automatic deserialization with call_tool_mcp()
|
| 220 |
```
|
| 221 |
|
| 222 |
## Argument Handling
|
docs/patterns/tool-transformation.mdx
CHANGED
|
@@ -89,6 +89,7 @@ The `Tool.from_tool()` class method is the primary way to create a transformed t
|
|
| 89 |
- `description`: An optional description for the new tool.
|
| 90 |
- `transform_args`: A dictionary of `ArgTransform` objects, one for each argument you want to modify.
|
| 91 |
- `transform_fn`: An optional function that will be called instead of the parent tool's logic.
|
|
|
|
| 92 |
- `tags`: An optional set of tags for the new tool.
|
| 93 |
- `annotations`: An optional set of `ToolAnnotations` for the new tool.
|
| 94 |
- `serializer`: An optional function that will be called to serialize the result of the new tool.
|
|
@@ -439,7 +440,44 @@ mcp.add_tool(new_tool)
|
|
| 439 |
|
| 440 |
<Tip>
|
| 441 |
In the above example, `**kwargs` receives the renamed argument `b`, not the original argument `y`. It is therefore recommended to use with `forward()`, not `forward_raw()`.
|
| 442 |
-
</Tip>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
|
| 444 |
## Common Patterns
|
| 445 |
|
|
|
|
| 89 |
- `description`: An optional description for the new tool.
|
| 90 |
- `transform_args`: A dictionary of `ArgTransform` objects, one for each argument you want to modify.
|
| 91 |
- `transform_fn`: An optional function that will be called instead of the parent tool's logic.
|
| 92 |
+
- `output_schema`: Control output schema and structured outputs (see [Output Schema Control](#output-schema-control)).
|
| 93 |
- `tags`: An optional set of tags for the new tool.
|
| 94 |
- `annotations`: An optional set of `ToolAnnotations` for the new tool.
|
| 95 |
- `serializer`: An optional function that will be called to serialize the result of the new tool.
|
|
|
|
| 440 |
|
| 441 |
<Tip>
|
| 442 |
In the above example, `**kwargs` receives the renamed argument `b`, not the original argument `y`. It is therefore recommended to use with `forward()`, not `forward_raw()`.
|
| 443 |
+
</Tip>
|
| 444 |
+
|
| 445 |
+
## Output Schema Control
|
| 446 |
+
|
| 447 |
+
<VersionBadge version="2.10.0" />
|
| 448 |
+
|
| 449 |
+
Transformed tools inherit output schemas from their parent by default, but you can control this behavior:
|
| 450 |
+
|
| 451 |
+
**Inherit from Parent (Default)**
|
| 452 |
+
```python
|
| 453 |
+
Tool.from_tool(parent_tool, name="renamed_tool")
|
| 454 |
+
```
|
| 455 |
+
The transformed tool automatically uses the parent tool's output schema and structured output behavior.
|
| 456 |
+
|
| 457 |
+
**Custom Output Schema**
|
| 458 |
+
```python
|
| 459 |
+
Tool.from_tool(parent_tool, output_schema={
|
| 460 |
+
"type": "object",
|
| 461 |
+
"properties": {"status": {"type": "string"}}
|
| 462 |
+
})
|
| 463 |
+
```
|
| 464 |
+
Provide your own schema that differs from the parent. The tool must return data matching this schema.
|
| 465 |
+
|
| 466 |
+
**Remove Output Schema**
|
| 467 |
+
```python
|
| 468 |
+
Tool.from_tool(parent_tool, output_schema=False)
|
| 469 |
+
```
|
| 470 |
+
Removes the output schema declaration. Automatic structured content still works for object-like returns (dict, dataclass, Pydantic models) but primitive types won't be structured.
|
| 471 |
+
|
| 472 |
+
**Full Control with Transform Functions**
|
| 473 |
+
```python
|
| 474 |
+
async def custom_output(**kwargs) -> ToolResult:
|
| 475 |
+
result = await forward(**kwargs)
|
| 476 |
+
return ToolResult(content=[...], structured_content={...})
|
| 477 |
+
|
| 478 |
+
Tool.from_tool(parent_tool, transform_fn=custom_output)
|
| 479 |
+
```
|
| 480 |
+
Use a transform function returning `ToolResult` for complete control over both content blocks and structured outputs.
|
| 481 |
|
| 482 |
## Common Patterns
|
| 483 |
|
docs/servers/tools.mdx
CHANGED
|
@@ -288,55 +288,229 @@ Use `async def` when your tool needs to perform operations that might wait for e
|
|
| 288 |
|
| 289 |
### Return Values
|
| 290 |
|
| 291 |
-
FastMCP automatically converts the value returned by your function into the appropriate MCP content format for the client:
|
| 292 |
|
| 293 |
-
- **
|
| 294 |
-
- **`dict`, `list`, Pydantic `BaseModel`**: Serialized to a JSON string and sent as `TextContent`.
|
| 295 |
-
- **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (often within an `EmbeddedResource`).
|
| 296 |
-
- **`fastmcp.utilities.types.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
|
| 297 |
-
- **`fastmcp.utilities.types.Audio`**: A helper class for easily returning audio data. Sent as `AudioContent`.
|
| 298 |
-
- **`fastmcp.utilities.types.File`**: A helper class for easily returning binary data as base64-encoded content. Sent as `EmbeddedResource`.
|
| 299 |
-
- **A list of any of the above**: Automatically converts each item appropriately.
|
| 300 |
-
- **`None`**: Results in an empty response (no content is sent back to the client).
|
| 301 |
|
| 302 |
-
|
| 303 |
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
from fastmcp import FastMCP
|
| 310 |
-
from fastmcp.utilities.types import Image
|
| 311 |
-
import io
|
| 312 |
|
| 313 |
-
|
| 314 |
-
from PIL import Image as PILImage
|
| 315 |
-
except ImportError:
|
| 316 |
-
raise ImportError("Please install the `pillow` library to run this example.")
|
| 317 |
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
@mcp.tool
|
| 321 |
-
def
|
| 322 |
-
"""
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
-
|
| 327 |
-
buffer = io.BytesIO()
|
| 328 |
-
img.save(buffer, format="PNG")
|
| 329 |
-
img_bytes = buffer.getvalue()
|
| 330 |
|
| 331 |
-
|
| 332 |
-
return Image(data=img_bytes, format="png")
|
| 333 |
|
|
|
|
|
|
|
| 334 |
@mcp.tool
|
| 335 |
-
def
|
| 336 |
-
"""
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
### Error Handling
|
| 342 |
|
|
|
|
| 288 |
|
| 289 |
### Return Values
|
| 290 |
|
|
|
|
| 291 |
|
| 292 |
+
FastMCP tools can return data in two complementary formats: **traditional content blocks** (like text and images) and **structured outputs** (machine-readable JSON). When you add return type annotations, FastMCP automatically generates **output schemas** to validate the structured data and enables clients to deserialize results back to Python objects.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
+
Understanding how these three concepts work together:
|
| 295 |
|
| 296 |
+
- **Return Values**: What your Python function returns (determines both content blocks and structured data)
|
| 297 |
+
- **Structured Outputs**: JSON data sent alongside traditional content for machine processing
|
| 298 |
+
- **Output Schemas**: JSON Schema declarations that describe and validate the structured output format
|
| 299 |
|
| 300 |
+
The following sections explain each concept in detail.
|
| 301 |
+
|
| 302 |
+
#### Content Blocks
|
| 303 |
+
|
| 304 |
+
FastMCP automatically converts tool return values into appropriate MCP content blocks:
|
| 305 |
+
|
| 306 |
+
- **`str`**: Sent as `TextContent`
|
| 307 |
+
- **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (within an `EmbeddedResource`)
|
| 308 |
+
- **`fastmcp.utilities.types.Image`**: Sent as `ImageContent`
|
| 309 |
+
- **`fastmcp.utilities.types.Audio`**: Sent as `AudioContent`
|
| 310 |
+
- **`fastmcp.utilities.types.File`**: Sent as base64-encoded `EmbeddedResource`
|
| 311 |
+
- **A list of any of the above**: Converts each item appropriately
|
| 312 |
+
- **`None`**: Results in an empty response
|
| 313 |
+
|
| 314 |
+
#### Structured Output
|
| 315 |
+
|
| 316 |
+
<VersionBadge version="2.10.0" />
|
| 317 |
+
|
| 318 |
+
The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content) structured content, which is a new way to return data from tools. Structured content is a JSON object that is sent alongside traditional content. FastMCP automatically creates structured outputs alongside traditional content when your tool returns data that has a JSON object representation. This provides machine-readable JSON data that clients can deserialize back to Python objects.
|
| 319 |
+
|
| 320 |
+
**Automatic Structured Content Rules:**
|
| 321 |
+
- **Object-like results** (`dict`, Pydantic models, dataclasses) → Always become structured content (even without output schema)
|
| 322 |
+
- **Non-object results** (`int`, `str`, `list`) → Only become structured content if there's an output schema to validate/serialize them
|
| 323 |
+
- **All results** → Always become traditional content blocks for backward compatibility
|
| 324 |
+
|
| 325 |
+
<Note>
|
| 326 |
+
This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns.
|
| 327 |
+
</Note>
|
| 328 |
+
|
| 329 |
+
##### Object-like Results (Automatic Structured Content)
|
| 330 |
+
|
| 331 |
+
<CodeGroup>
|
| 332 |
+
```python Dict Return (No Schema Needed)
|
| 333 |
+
@mcp.tool
|
| 334 |
+
def get_user_data(user_id: str) -> dict:
|
| 335 |
+
"""Get user data without type annotation."""
|
| 336 |
+
return {"name": "Alice", "age": 30, "active": True}
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
```json Traditional Content
|
| 340 |
+
"{\n \"name\": \"Alice\",\n \"age\": 30,\n \"active\": true\n}"
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
```json Structured Content (Automatic)
|
| 344 |
+
{
|
| 345 |
+
"name": "Alice",
|
| 346 |
+
"age": 30,
|
| 347 |
+
"active": true
|
| 348 |
+
}
|
| 349 |
+
```
|
| 350 |
+
</CodeGroup>
|
| 351 |
+
|
| 352 |
+
##### Non-object Results (Schema Required)
|
| 353 |
+
|
| 354 |
+
<CodeGroup>
|
| 355 |
+
```python Integer Return (No Schema)
|
| 356 |
+
@mcp.tool
|
| 357 |
+
def calculate_sum(a: int, b: int):
|
| 358 |
+
"""Calculate sum without return annotation."""
|
| 359 |
+
return a + b # Returns 8
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
```json Traditional Content Only
|
| 363 |
+
"8"
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
```python Integer Return (With Schema)
|
| 367 |
+
@mcp.tool
|
| 368 |
+
def calculate_sum(a: int, b: int) -> int:
|
| 369 |
+
"""Calculate sum with return annotation."""
|
| 370 |
+
return a + b # Returns 8
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
```json Traditional Content
|
| 374 |
+
"8"
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
```json Structured Content (From Schema)
|
| 378 |
+
{
|
| 379 |
+
"result": 8
|
| 380 |
+
}
|
| 381 |
+
```
|
| 382 |
+
</CodeGroup>
|
| 383 |
+
|
| 384 |
+
##### Complex Type Example
|
| 385 |
+
|
| 386 |
+
<CodeGroup>
|
| 387 |
+
```python Tool Definition
|
| 388 |
+
from dataclasses import dataclass
|
| 389 |
from fastmcp import FastMCP
|
|
|
|
|
|
|
| 390 |
|
| 391 |
+
mcp = FastMCP()
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
+
@dataclass
|
| 394 |
+
class Person:
|
| 395 |
+
name: str
|
| 396 |
+
age: int
|
| 397 |
+
email: str
|
| 398 |
|
| 399 |
@mcp.tool
|
| 400 |
+
def get_user_profile(user_id: str) -> Person:
|
| 401 |
+
"""Get a user's profile information."""
|
| 402 |
+
return Person(name="Alice", age=30, email="alice@example.com")
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
```json Generated Output Schema
|
| 406 |
+
{
|
| 407 |
+
"properties": {
|
| 408 |
+
"name": {"title": "Name", "type": "string"},
|
| 409 |
+
"age": {"title": "Age", "type": "integer"},
|
| 410 |
+
"email": {"title": "Email", "type": "string"}
|
| 411 |
+
},
|
| 412 |
+
"required": ["name", "age", "email"],
|
| 413 |
+
"title": "Person",
|
| 414 |
+
"type": "object"
|
| 415 |
+
}
|
| 416 |
+
```
|
| 417 |
+
|
| 418 |
+
```json Structured Output
|
| 419 |
+
{
|
| 420 |
+
"name": "Alice",
|
| 421 |
+
"age": 30,
|
| 422 |
+
"email": "alice@example.com"
|
| 423 |
+
}
|
| 424 |
+
```
|
| 425 |
+
</CodeGroup>
|
| 426 |
+
|
| 427 |
+
#### Output Schemas
|
| 428 |
+
|
| 429 |
+
<VersionBadge version="2.10.0" />
|
| 430 |
+
|
| 431 |
+
The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) output schemas, which are a new way to describe the expected output format of a tool. When an output schema is provided, the tool *must* return structured output that matches the schema.
|
| 432 |
+
|
| 433 |
+
When you add return type annotations to your functions, FastMCP automatically generates JSON schemas that describe the expected output format. These schemas help MCP clients understand and validate the structured data they receive.
|
| 434 |
|
| 435 |
+
##### Primitive Type Wrapping
|
|
|
|
|
|
|
|
|
|
| 436 |
|
| 437 |
+
For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output:
|
|
|
|
| 438 |
|
| 439 |
+
<CodeGroup>
|
| 440 |
+
```python Primitive Return Type
|
| 441 |
@mcp.tool
|
| 442 |
+
def calculate_sum(a: int, b: int) -> int:
|
| 443 |
+
"""Add two numbers together."""
|
| 444 |
+
return a + b
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
```json Generated Schema (Wrapped)
|
| 448 |
+
{
|
| 449 |
+
"type": "object",
|
| 450 |
+
"properties": {
|
| 451 |
+
"result": {"type": "integer"}
|
| 452 |
+
},
|
| 453 |
+
"x-fastmcp-wrap-result": true
|
| 454 |
+
}
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
```json Structured Output
|
| 458 |
+
{
|
| 459 |
+
"result": 8
|
| 460 |
+
}
|
| 461 |
```
|
| 462 |
+
</CodeGroup>
|
| 463 |
+
|
| 464 |
+
##### Manual Schema Control
|
| 465 |
+
|
| 466 |
+
You can override the automatically generated schema by providing a custom `output_schema`:
|
| 467 |
+
|
| 468 |
+
```python
|
| 469 |
+
@mcp.tool(output_schema={
|
| 470 |
+
"type": "object",
|
| 471 |
+
"properties": {
|
| 472 |
+
"data": {"type": "string"},
|
| 473 |
+
"metadata": {"type": "object"}
|
| 474 |
+
}
|
| 475 |
+
})
|
| 476 |
+
def custom_schema_tool() -> dict:
|
| 477 |
+
"""Tool with custom output schema."""
|
| 478 |
+
return {"data": "Hello", "metadata": {"version": "1.0"}}
|
| 479 |
+
```
|
| 480 |
+
|
| 481 |
+
Schema generation works for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses.
|
| 482 |
+
|
| 483 |
+
<Warning>
|
| 484 |
+
**Important Constraints**:
|
| 485 |
+
- Output schemas must be object types (`"type": "object"`)
|
| 486 |
+
- If you provide an output schema, your tool **must** return structured output that matches it
|
| 487 |
+
- However, you can provide structured output without an output schema (using `ToolResult`)
|
| 488 |
+
</Warning>
|
| 489 |
+
|
| 490 |
+
#### Full Control with ToolResult
|
| 491 |
+
|
| 492 |
+
For complete control over both traditional content and structured output, return a `ToolResult` object:
|
| 493 |
+
|
| 494 |
+
```python
|
| 495 |
+
from fastmcp.tools.tool import ToolResult
|
| 496 |
+
|
| 497 |
+
@mcp.tool
|
| 498 |
+
def advanced_tool() -> ToolResult:
|
| 499 |
+
"""Tool with full control over output."""
|
| 500 |
+
return ToolResult(
|
| 501 |
+
content=[TextContent(text="Human-readable summary")],
|
| 502 |
+
structured_content={"data": "value", "count": 42}
|
| 503 |
+
)
|
| 504 |
+
```
|
| 505 |
+
|
| 506 |
+
When returning `ToolResult`:
|
| 507 |
+
- You control exactly what content and structured data is sent
|
| 508 |
+
- Output schemas are optional - structured content can be provided without a schema
|
| 509 |
+
- Clients receive both traditional content blocks and structured data
|
| 510 |
+
|
| 511 |
+
<Note>
|
| 512 |
+
If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted but the tool will still function normally with traditional content.
|
| 513 |
+
</Note>
|
| 514 |
|
| 515 |
### Error Handling
|
| 516 |
|
pyproject.toml
CHANGED
|
@@ -12,6 +12,7 @@ dependencies = [
|
|
| 12 |
"rich>=13.9.4",
|
| 13 |
"typer>=0.15.2",
|
| 14 |
"authlib>=1.5.2",
|
|
|
|
| 15 |
]
|
| 16 |
requires-python = ">=3.10"
|
| 17 |
readme = "README.md"
|
|
|
|
| 12 |
"rich>=13.9.4",
|
| 13 |
"typer>=0.15.2",
|
| 14 |
"authlib>=1.5.2",
|
| 15 |
+
"pydantic[email]>=2.11.7",
|
| 16 |
]
|
| 17 |
requires-python = ">=3.10"
|
| 18 |
readme = "README.md"
|
src/fastmcp/client/client.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
|
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import datetime
|
| 3 |
from contextlib import AsyncExitStack, asynccontextmanager
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Any, Generic, Literal, cast, overload
|
| 6 |
|
|
@@ -10,7 +13,6 @@ import mcp.types
|
|
| 10 |
import pydantic_core
|
| 11 |
from exceptiongroup import catch
|
| 12 |
from mcp import ClientSession
|
| 13 |
-
from mcp.types import ContentBlock
|
| 14 |
from pydantic import AnyUrl
|
| 15 |
|
| 16 |
import fastmcp
|
|
@@ -30,7 +32,10 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
|
|
| 30 |
from fastmcp.exceptions import ToolError
|
| 31 |
from fastmcp.server import FastMCP
|
| 32 |
from fastmcp.utilities.exceptions import get_catch_handlers
|
|
|
|
|
|
|
| 33 |
from fastmcp.utilities.mcp_config import MCPConfig
|
|
|
|
| 34 |
|
| 35 |
from .transports import (
|
| 36 |
ClientTransportT,
|
|
@@ -56,6 +61,8 @@ __all__ = [
|
|
| 56 |
"ProgressHandler",
|
| 57 |
]
|
| 58 |
|
|
|
|
|
|
|
| 59 |
|
| 60 |
class Client(Generic[ClientTransportT]):
|
| 61 |
"""
|
|
@@ -99,34 +106,39 @@ class Client(Generic[ClientTransportT]):
|
|
| 99 |
cls,
|
| 100 |
transport: ClientTransportT,
|
| 101 |
**kwargs: Any,
|
| 102 |
-
) ->
|
| 103 |
|
| 104 |
@overload
|
| 105 |
def __new__(
|
| 106 |
cls, transport: AnyUrl, **kwargs
|
| 107 |
-
) ->
|
| 108 |
|
| 109 |
@overload
|
| 110 |
def __new__(
|
| 111 |
cls, transport: FastMCP | FastMCP1Server, **kwargs
|
| 112 |
-
) ->
|
| 113 |
|
| 114 |
@overload
|
| 115 |
def __new__(
|
| 116 |
cls, transport: Path, **kwargs
|
| 117 |
-
) ->
|
| 118 |
|
| 119 |
@overload
|
| 120 |
def __new__(
|
| 121 |
cls, transport: MCPConfig | dict[str, Any], **kwargs
|
| 122 |
-
) ->
|
| 123 |
|
| 124 |
@overload
|
| 125 |
def __new__(
|
| 126 |
cls, transport: str, **kwargs
|
| 127 |
-
) ->
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
instance = super().__new__(cls)
|
| 131 |
return instance
|
| 132 |
|
|
@@ -675,7 +687,8 @@ class Client(Generic[ClientTransportT]):
|
|
| 675 |
arguments: dict[str, Any] | None = None,
|
| 676 |
timeout: datetime.timedelta | float | int | None = None,
|
| 677 |
progress_handler: ProgressHandler | None = None,
|
| 678 |
-
|
|
|
|
| 679 |
"""Call a tool on the server.
|
| 680 |
|
| 681 |
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
|
|
@@ -687,8 +700,13 @@ class Client(Generic[ClientTransportT]):
|
|
| 687 |
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
| 688 |
|
| 689 |
Returns:
|
| 690 |
-
|
| 691 |
-
The content returned by the tool.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
|
| 693 |
Raises:
|
| 694 |
ToolError: If the tool call results in an error.
|
|
@@ -700,7 +718,43 @@ class Client(Generic[ClientTransportT]):
|
|
| 700 |
timeout=timeout,
|
| 701 |
progress_handler=progress_handler,
|
| 702 |
)
|
| 703 |
-
|
|
|
|
| 704 |
msg = cast(mcp.types.TextContent, result.content[0]).text
|
| 705 |
raise ToolError(msg)
|
| 706 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
import asyncio
|
| 4 |
import datetime
|
| 5 |
from contextlib import AsyncExitStack, asynccontextmanager
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any, Generic, Literal, cast, overload
|
| 9 |
|
|
|
|
| 13 |
import pydantic_core
|
| 14 |
from exceptiongroup import catch
|
| 15 |
from mcp import ClientSession
|
|
|
|
| 16 |
from pydantic import AnyUrl
|
| 17 |
|
| 18 |
import fastmcp
|
|
|
|
| 32 |
from fastmcp.exceptions import ToolError
|
| 33 |
from fastmcp.server import FastMCP
|
| 34 |
from fastmcp.utilities.exceptions import get_catch_handlers
|
| 35 |
+
from fastmcp.utilities.json_schema_type import json_schema_to_type
|
| 36 |
+
from fastmcp.utilities.logging import get_logger
|
| 37 |
from fastmcp.utilities.mcp_config import MCPConfig
|
| 38 |
+
from fastmcp.utilities.types import get_cached_typeadapter
|
| 39 |
|
| 40 |
from .transports import (
|
| 41 |
ClientTransportT,
|
|
|
|
| 61 |
"ProgressHandler",
|
| 62 |
]
|
| 63 |
|
| 64 |
+
logger = get_logger(__name__)
|
| 65 |
+
|
| 66 |
|
| 67 |
class Client(Generic[ClientTransportT]):
|
| 68 |
"""
|
|
|
|
| 106 |
cls,
|
| 107 |
transport: ClientTransportT,
|
| 108 |
**kwargs: Any,
|
| 109 |
+
) -> Client[ClientTransportT]: ...
|
| 110 |
|
| 111 |
@overload
|
| 112 |
def __new__(
|
| 113 |
cls, transport: AnyUrl, **kwargs
|
| 114 |
+
) -> Client[SSETransport | StreamableHttpTransport]: ...
|
| 115 |
|
| 116 |
@overload
|
| 117 |
def __new__(
|
| 118 |
cls, transport: FastMCP | FastMCP1Server, **kwargs
|
| 119 |
+
) -> Client[FastMCPTransport]: ...
|
| 120 |
|
| 121 |
@overload
|
| 122 |
def __new__(
|
| 123 |
cls, transport: Path, **kwargs
|
| 124 |
+
) -> Client[PythonStdioTransport | NodeStdioTransport]: ...
|
| 125 |
|
| 126 |
@overload
|
| 127 |
def __new__(
|
| 128 |
cls, transport: MCPConfig | dict[str, Any], **kwargs
|
| 129 |
+
) -> Client[MCPConfigTransport]: ...
|
| 130 |
|
| 131 |
@overload
|
| 132 |
def __new__(
|
| 133 |
cls, transport: str, **kwargs
|
| 134 |
+
) -> Client[
|
| 135 |
+
PythonStdioTransport
|
| 136 |
+
| NodeStdioTransport
|
| 137 |
+
| SSETransport
|
| 138 |
+
| StreamableHttpTransport
|
| 139 |
+
]: ...
|
| 140 |
+
|
| 141 |
+
def __new__(cls, transport, **kwargs) -> Client:
|
| 142 |
instance = super().__new__(cls)
|
| 143 |
return instance
|
| 144 |
|
|
|
|
| 687 |
arguments: dict[str, Any] | None = None,
|
| 688 |
timeout: datetime.timedelta | float | int | None = None,
|
| 689 |
progress_handler: ProgressHandler | None = None,
|
| 690 |
+
raise_on_error: bool = True,
|
| 691 |
+
) -> CallToolResult:
|
| 692 |
"""Call a tool on the server.
|
| 693 |
|
| 694 |
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
|
|
|
|
| 700 |
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
| 701 |
|
| 702 |
Returns:
|
| 703 |
+
CallToolResult:
|
| 704 |
+
The content returned by the tool. If the tool returns structured
|
| 705 |
+
outputs, they are returned as a dataclass (if an output schema
|
| 706 |
+
is available) or a dictionary; otherwise, a list of content
|
| 707 |
+
blocks is returned. Note: to receive both structured and
|
| 708 |
+
unstructured outputs, use call_tool_mcp instead and access the
|
| 709 |
+
raw result object.
|
| 710 |
|
| 711 |
Raises:
|
| 712 |
ToolError: If the tool call results in an error.
|
|
|
|
| 718 |
timeout=timeout,
|
| 719 |
progress_handler=progress_handler,
|
| 720 |
)
|
| 721 |
+
data = None
|
| 722 |
+
if result.isError and raise_on_error:
|
| 723 |
msg = cast(mcp.types.TextContent, result.content[0]).text
|
| 724 |
raise ToolError(msg)
|
| 725 |
+
elif result.structuredContent:
|
| 726 |
+
try:
|
| 727 |
+
if name not in self.session._tool_output_schemas:
|
| 728 |
+
await self.session.list_tools()
|
| 729 |
+
if name in self.session._tool_output_schemas:
|
| 730 |
+
output_schema = self.session._tool_output_schemas.get(name)
|
| 731 |
+
if output_schema:
|
| 732 |
+
if output_schema.get("x-fastmcp-wrap-result"):
|
| 733 |
+
output_schema = output_schema.get("properties", {}).get(
|
| 734 |
+
"result"
|
| 735 |
+
)
|
| 736 |
+
structured_content = result.structuredContent.get("result")
|
| 737 |
+
else:
|
| 738 |
+
structured_content = result.structuredContent
|
| 739 |
+
output_type = json_schema_to_type(output_schema)
|
| 740 |
+
type_adapter = get_cached_typeadapter(output_type)
|
| 741 |
+
data = type_adapter.validate_python(structured_content)
|
| 742 |
+
else:
|
| 743 |
+
data = result.structuredContent
|
| 744 |
+
except Exception as e:
|
| 745 |
+
logger.error(f"Error parsing structured content: {e}")
|
| 746 |
+
|
| 747 |
+
return CallToolResult(
|
| 748 |
+
content=result.content,
|
| 749 |
+
structured_content=result.structuredContent,
|
| 750 |
+
data=data,
|
| 751 |
+
is_error=result.isError,
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
@dataclass
|
| 756 |
+
class CallToolResult:
|
| 757 |
+
content: list[mcp.types.ContentBlock]
|
| 758 |
+
structured_content: dict[str, Any] | None
|
| 759 |
+
data: Any = None
|
| 760 |
+
is_error: bool = False
|
src/fastmcp/contrib/component_manager/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Component Manager – Contrib Module for FastMCP
|
| 2 |
+
|
| 3 |
+
The **Component Manager** provides a unified API for enabling and disabling tools, resources, and prompts at runtime in a FastMCP server. This module is useful for dynamic control over which components are active, enabling advanced features like feature toggling, admin interfaces, or automation workflows.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 🔧 Features
|
| 8 |
+
|
| 9 |
+
- Enable/disable **tools**, **resources**, and **prompts** via HTTP endpoints.
|
| 10 |
+
- Supports **local** and **mounted (server)** components.
|
| 11 |
+
- Customizable **API root path**.
|
| 12 |
+
- Optional **Auth scopes** for secured access.
|
| 13 |
+
- Fully integrates with FastMCP with minimal configuration.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 📦 Installation
|
| 18 |
+
|
| 19 |
+
This module is part of the `fastmcp.contrib` package. No separate installation is required if you're already using **FastMCP**.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 🚀 Usage
|
| 24 |
+
|
| 25 |
+
### Basic Setup
|
| 26 |
+
|
| 27 |
+
```python
|
| 28 |
+
from fastmcp import FastMCP
|
| 29 |
+
from fastmcp.contrib.component_manager import set_up_component_manager
|
| 30 |
+
|
| 31 |
+
mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.")
|
| 32 |
+
set_up_component_manager(server=mcp)
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## 🔗 API Endpoints
|
| 38 |
+
|
| 39 |
+
All endpoints are registered at `/` by default, or under the custom path if one is provided.
|
| 40 |
+
|
| 41 |
+
### Tools
|
| 42 |
+
|
| 43 |
+
```http
|
| 44 |
+
POST /tools/{tool_name}/enable
|
| 45 |
+
POST /tools/{tool_name}/disable
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### Resources
|
| 49 |
+
|
| 50 |
+
```http
|
| 51 |
+
POST /resources/{uri:path}/enable
|
| 52 |
+
POST /resources/{uri:path}/disable
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
* Supports template URIs as well
|
| 56 |
+
```http
|
| 57 |
+
POST /resources/example://test/{id}/enable
|
| 58 |
+
POST /resources/example://test/{id}/disable
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Prompts
|
| 62 |
+
|
| 63 |
+
```http
|
| 64 |
+
POST /prompts/{prompt_name}/enable
|
| 65 |
+
POST /prompts/{prompt_name}/disable
|
| 66 |
+
```
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
#### 🧪 Example Response
|
| 70 |
+
|
| 71 |
+
```http
|
| 72 |
+
HTTP/1.1 200 OK
|
| 73 |
+
Content-Type: application/json
|
| 74 |
+
|
| 75 |
+
{
|
| 76 |
+
"message": "Disabled tool: example_tool"
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## ⚙️ Configuration Options
|
| 84 |
+
|
| 85 |
+
### Custom Root Path
|
| 86 |
+
|
| 87 |
+
To mount the API under a different path:
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
set_up_component_manager(server=mcp, path="/admin")
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### Securing Endpoints with Auth Scopes
|
| 94 |
+
|
| 95 |
+
If your server uses authentication:
|
| 96 |
+
|
| 97 |
+
```python
|
| 98 |
+
mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
|
| 99 |
+
set_up_component_manager(server=mcp, required_scopes=["write", "read"])
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## 🧪 Example: Enabling a Tool with Curl
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
curl -X POST \
|
| 108 |
+
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
| 109 |
+
-H "Content-Type: application/json" \
|
| 110 |
+
http://localhost:8001/tools/example_tool/enable
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## 🧱 Working with Mounted Servers
|
| 116 |
+
|
| 117 |
+
You can also combine different configurations when working with mounted servers — for example, using different scopes:
|
| 118 |
+
|
| 119 |
+
```python
|
| 120 |
+
mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
|
| 121 |
+
set_up_component_manager(server=mcp, required_scopes=["mcp:write"])
|
| 122 |
+
|
| 123 |
+
mounted = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth)
|
| 124 |
+
set_up_component_manager(server=mounted, required_scopes=["mounted:write"])
|
| 125 |
+
|
| 126 |
+
mcp.mount(server=mounted, prefix="mo")
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
This allows you to grant different levels of access:
|
| 130 |
+
|
| 131 |
+
```bash
|
| 132 |
+
# Accessing the main server gives you control over both local and mounted components
|
| 133 |
+
curl -X POST \
|
| 134 |
+
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
| 135 |
+
-H "Content-Type: application/json" \
|
| 136 |
+
http://localhost:8001/tools/mo_example_tool/enable
|
| 137 |
+
|
| 138 |
+
# Accessing the mounted server gives you control only over its own components
|
| 139 |
+
curl -X POST \
|
| 140 |
+
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
| 141 |
+
-H "Content-Type: application/json" \
|
| 142 |
+
http://localhost:8002/tools/example_tool/enable
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## ⚙️ How It Works
|
| 148 |
+
|
| 149 |
+
- `set_up_component_manager()` registers API routes for tools, resources, and prompts.
|
| 150 |
+
- The `ComponentService` class exposes async methods to enable/disable components.
|
| 151 |
+
- Each endpoint returns a success message in JSON or a 404 error if the component isn't found.
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## 🧩 Extending
|
| 156 |
+
|
| 157 |
+
You can subclass `ComponentService` for custom behavior or mount its routes elsewhere as needed.
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## Maintenance Notice
|
| 162 |
+
|
| 163 |
+
This module is not officially maintained by the core FastMCP team. It is an independent extension developed by [gorocode](https://github.com/gorocode).
|
| 164 |
+
|
| 165 |
+
If you encounter any issues or wish to contribute, please feel free to open an issue or submit a pull request, and kindly notify me. I'd love to stay up to date.
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
## 📄 License
|
| 169 |
+
|
| 170 |
+
This module follows the license of the main [FastMCP](https://github.com/jlowin/fastmcp) project.
|
src/fastmcp/contrib/component_manager/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .component_manager import set_up_component_manager
|
| 2 |
+
from .component_service import ComponentService
|
| 3 |
+
|
| 4 |
+
__all__ = ["set_up_component_manager", "ComponentService"]
|
src/fastmcp/contrib/component_manager/component_manager.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Routes and helpers for managing tools, resources, and prompts in FastMCP.
|
| 3 |
+
Provides endpoints for enabling/disabling components via HTTP, with optional authentication scopes.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
|
| 9 |
+
from starlette.applications import Starlette
|
| 10 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 11 |
+
from starlette.requests import Request
|
| 12 |
+
from starlette.responses import JSONResponse
|
| 13 |
+
from starlette.routing import Mount, Route
|
| 14 |
+
|
| 15 |
+
from fastmcp.contrib.component_manager.component_service import ComponentService
|
| 16 |
+
from fastmcp.exceptions import NotFoundError
|
| 17 |
+
from fastmcp.server.server import FastMCP
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def set_up_component_manager(
|
| 21 |
+
server: FastMCP, path: str = "/", required_scopes: list[str] | None = None
|
| 22 |
+
):
|
| 23 |
+
"""Set up routes for enabling/disabling tools, resources, and prompts.
|
| 24 |
+
Args:
|
| 25 |
+
server: The FastMCP server instance
|
| 26 |
+
path: Path used to mount all component-related routes on the server
|
| 27 |
+
required_scopes: Optional list of scopes required for these routes. Applies only if authentication is enabled.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
service = ComponentService(server)
|
| 31 |
+
routes: list[Route] = []
|
| 32 |
+
mounts: list[Mount] = []
|
| 33 |
+
route_configs = {
|
| 34 |
+
"tool": {
|
| 35 |
+
"param": "tool_name",
|
| 36 |
+
"enable": service._enable_tool,
|
| 37 |
+
"disable": service._disable_tool,
|
| 38 |
+
},
|
| 39 |
+
"resource": {
|
| 40 |
+
"param": "uri:path",
|
| 41 |
+
"enable": service._enable_resource,
|
| 42 |
+
"disable": service._disable_resource,
|
| 43 |
+
},
|
| 44 |
+
"prompt": {
|
| 45 |
+
"param": "prompt_name",
|
| 46 |
+
"enable": service._enable_prompt,
|
| 47 |
+
"disable": service._disable_prompt,
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if required_scopes is None:
|
| 52 |
+
routes.extend(build_component_manager_endpoints(route_configs, path))
|
| 53 |
+
else:
|
| 54 |
+
if path != "/":
|
| 55 |
+
mounts.append(
|
| 56 |
+
build_component_manager_mount(route_configs, path, required_scopes)
|
| 57 |
+
)
|
| 58 |
+
else:
|
| 59 |
+
mounts.append(
|
| 60 |
+
build_component_manager_mount(
|
| 61 |
+
{"tool": route_configs["tool"]}, "/tools", required_scopes
|
| 62 |
+
)
|
| 63 |
+
)
|
| 64 |
+
mounts.append(
|
| 65 |
+
build_component_manager_mount(
|
| 66 |
+
{"resource": route_configs["resource"]},
|
| 67 |
+
"/resources",
|
| 68 |
+
required_scopes,
|
| 69 |
+
)
|
| 70 |
+
)
|
| 71 |
+
mounts.append(
|
| 72 |
+
build_component_manager_mount(
|
| 73 |
+
{"prompt": route_configs["prompt"]}, "/prompts", required_scopes
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
server._additional_http_routes.extend(routes)
|
| 78 |
+
server._additional_http_routes.extend(mounts)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def make_endpoint(action, component, config):
|
| 82 |
+
"""
|
| 83 |
+
Factory for creating Starlette endpoint functions for enabling/disabling a component.
|
| 84 |
+
Args:
|
| 85 |
+
action: 'enable' or 'disable'
|
| 86 |
+
component: The component type (e.g., 'tool', 'resource', or 'prompt')
|
| 87 |
+
config: Dict with param and handler functions for the component
|
| 88 |
+
Returns:
|
| 89 |
+
An async endpoint function for Starlette.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
async def endpoint(request: Request):
|
| 93 |
+
name = request.path_params[config["param"].split(":")[0]]
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
await config[action](name)
|
| 97 |
+
return JSONResponse(
|
| 98 |
+
{"message": f"{action.capitalize()}d {component}: {name}"}
|
| 99 |
+
)
|
| 100 |
+
except NotFoundError:
|
| 101 |
+
raise StarletteHTTPException(
|
| 102 |
+
status_code=404,
|
| 103 |
+
detail=f"Unknown {component}: {name}",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return endpoint
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def make_route(action, component, config, required_scopes, root_path) -> Route:
|
| 110 |
+
"""
|
| 111 |
+
Creates a Starlette Route for enabling/disabling a component.
|
| 112 |
+
Args:
|
| 113 |
+
action: 'enable' or 'disable'
|
| 114 |
+
component: The component type
|
| 115 |
+
config: Dict with param and handler functions
|
| 116 |
+
required_scopes: Optional list of required auth scopes
|
| 117 |
+
root_path: The base path for the route
|
| 118 |
+
Returns:
|
| 119 |
+
A Starlette Route object.
|
| 120 |
+
"""
|
| 121 |
+
endpoint = make_endpoint(action, component, config)
|
| 122 |
+
|
| 123 |
+
if required_scopes is not None and root_path in [
|
| 124 |
+
"/tools",
|
| 125 |
+
"/resources",
|
| 126 |
+
"/prompts",
|
| 127 |
+
]:
|
| 128 |
+
path = f"/{{{config['param']}}}/{action}"
|
| 129 |
+
else:
|
| 130 |
+
if root_path != "/" and required_scopes is None:
|
| 131 |
+
path = f"{root_path}/{component}s/{{{config['param']}}}/{action}"
|
| 132 |
+
else:
|
| 133 |
+
path = f"/{component}s/{{{config['param']}}}/{action}"
|
| 134 |
+
|
| 135 |
+
return Route(path, endpoint=endpoint, methods=["POST"])
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def build_component_manager_endpoints(
|
| 139 |
+
route_configs, root_path, required_scopes=None
|
| 140 |
+
) -> list[Route]:
|
| 141 |
+
"""
|
| 142 |
+
Build a list of Starlette Route objects for all components/actions.
|
| 143 |
+
Args:
|
| 144 |
+
route_configs: Dict describing component types and their handlers
|
| 145 |
+
root_path: The base path for the routes
|
| 146 |
+
required_scopes: Optional list of required auth scopes
|
| 147 |
+
Returns:
|
| 148 |
+
List of Starlette Route objects for component management.
|
| 149 |
+
"""
|
| 150 |
+
component_management_routes: list[Route] = []
|
| 151 |
+
|
| 152 |
+
for component in route_configs:
|
| 153 |
+
config: dict[str, Any] = route_configs[component]
|
| 154 |
+
for action in ["enable", "disable"]:
|
| 155 |
+
component_management_routes.append(
|
| 156 |
+
make_route(action, component, config, required_scopes, root_path)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return component_management_routes
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def build_component_manager_mount(route_configs, root_path, required_scopes) -> Mount:
|
| 163 |
+
"""
|
| 164 |
+
Build a Starlette Mount with authentication for component management routes.
|
| 165 |
+
Args:
|
| 166 |
+
route_configs: Dict describing component types and their handlers
|
| 167 |
+
root_path: The base path for the mount
|
| 168 |
+
required_scopes: List of required auth scopes
|
| 169 |
+
Returns:
|
| 170 |
+
A Starlette Mount object with authentication middleware.
|
| 171 |
+
"""
|
| 172 |
+
component_management_routes: list[Route] = []
|
| 173 |
+
|
| 174 |
+
for component in route_configs:
|
| 175 |
+
config: dict[str, Any] = route_configs[component]
|
| 176 |
+
for action in ["enable", "disable"]:
|
| 177 |
+
component_management_routes.append(
|
| 178 |
+
make_route(action, component, config, required_scopes, root_path)
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
return Mount(
|
| 182 |
+
f"{root_path}",
|
| 183 |
+
app=RequireAuthMiddleware(
|
| 184 |
+
Starlette(routes=component_management_routes), required_scopes
|
| 185 |
+
),
|
| 186 |
+
)
|
src/fastmcp/contrib/component_manager/component_service.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ComponentService: Provides async management of tools, resources, and prompts for FastMCP servers.
|
| 3 |
+
Handles enabling/disabling components both locally and across mounted servers.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastmcp.exceptions import NotFoundError
|
| 7 |
+
from fastmcp.prompts.prompt import Prompt
|
| 8 |
+
from fastmcp.resources.resource import Resource
|
| 9 |
+
from fastmcp.resources.template import ResourceTemplate
|
| 10 |
+
from fastmcp.server.server import FastMCP, has_resource_prefix, remove_resource_prefix
|
| 11 |
+
from fastmcp.tools.tool import Tool
|
| 12 |
+
from fastmcp.utilities.logging import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ComponentService:
|
| 18 |
+
"""Service for managing components like tools, resources, and prompts."""
|
| 19 |
+
|
| 20 |
+
def __init__(self, server: FastMCP):
|
| 21 |
+
self._server = server
|
| 22 |
+
self._tool_manager = server._tool_manager
|
| 23 |
+
self._resource_manager = server._resource_manager
|
| 24 |
+
self._prompt_manager = server._prompt_manager
|
| 25 |
+
|
| 26 |
+
async def _enable_tool(self, key: str) -> Tool:
|
| 27 |
+
"""Handle 'enableTool' requests.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
key: The key of the tool to enable
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
The tool that was enabled
|
| 34 |
+
"""
|
| 35 |
+
logger.debug("Enabling tool: %s", key)
|
| 36 |
+
|
| 37 |
+
# 1. Check local tools first. The server will have already applied its filter.
|
| 38 |
+
if key in self._server._tool_manager._tools:
|
| 39 |
+
tool: Tool = await self._server.get_tool(key)
|
| 40 |
+
tool.enable()
|
| 41 |
+
return tool
|
| 42 |
+
|
| 43 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 44 |
+
for mounted in reversed(self._tool_manager._mounted_servers):
|
| 45 |
+
if mounted.prefix:
|
| 46 |
+
if key.startswith(f"{mounted.prefix}_"):
|
| 47 |
+
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
| 48 |
+
mounted_service = ComponentService(mounted.server)
|
| 49 |
+
tool = await mounted_service._enable_tool(tool_key)
|
| 50 |
+
return tool
|
| 51 |
+
else:
|
| 52 |
+
continue
|
| 53 |
+
raise NotFoundError(f"Unknown tool: {key}")
|
| 54 |
+
|
| 55 |
+
async def _disable_tool(self, key: str) -> Tool:
|
| 56 |
+
"""Handle 'disableTool' requests.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
key: The key of the tool to disable
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
The tool that was disabled
|
| 63 |
+
"""
|
| 64 |
+
logger.debug("Disable tool: %s", key)
|
| 65 |
+
|
| 66 |
+
# 1. Check local tools first. The server will have already applied its filter.
|
| 67 |
+
if key in self._server._tool_manager._tools:
|
| 68 |
+
tool: Tool = await self._server.get_tool(key)
|
| 69 |
+
tool.disable()
|
| 70 |
+
return tool
|
| 71 |
+
|
| 72 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 73 |
+
for mounted in reversed(self._tool_manager._mounted_servers):
|
| 74 |
+
if mounted.prefix:
|
| 75 |
+
if key.startswith(f"{mounted.prefix}_"):
|
| 76 |
+
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
| 77 |
+
mounted_service = ComponentService(mounted.server)
|
| 78 |
+
tool = await mounted_service._disable_tool(tool_key)
|
| 79 |
+
return tool
|
| 80 |
+
else:
|
| 81 |
+
continue
|
| 82 |
+
raise NotFoundError(f"Unknown tool: {key}")
|
| 83 |
+
|
| 84 |
+
async def _enable_resource(self, key: str) -> Resource | ResourceTemplate:
|
| 85 |
+
"""Handle 'enableResource' requests.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
key: The key of the resource to enable
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
The resource that was enabled
|
| 92 |
+
"""
|
| 93 |
+
logger.debug("Enabling resource: %s", key)
|
| 94 |
+
|
| 95 |
+
# 1. Check local resources first. The server will have already applied its filter.
|
| 96 |
+
if key in self._resource_manager._resources:
|
| 97 |
+
resource: Resource = await self._server.get_resource(key)
|
| 98 |
+
resource.enable()
|
| 99 |
+
return resource
|
| 100 |
+
if key in self._resource_manager._templates:
|
| 101 |
+
template: ResourceTemplate = await self._server.get_resource_template(key)
|
| 102 |
+
template.enable()
|
| 103 |
+
return template
|
| 104 |
+
|
| 105 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 106 |
+
for mounted in reversed(self._resource_manager._mounted_servers):
|
| 107 |
+
if mounted.prefix:
|
| 108 |
+
if has_resource_prefix(
|
| 109 |
+
key,
|
| 110 |
+
mounted.prefix,
|
| 111 |
+
mounted.resource_prefix_format,
|
| 112 |
+
):
|
| 113 |
+
key = remove_resource_prefix(
|
| 114 |
+
key,
|
| 115 |
+
mounted.prefix,
|
| 116 |
+
mounted.resource_prefix_format,
|
| 117 |
+
)
|
| 118 |
+
mounted_service = ComponentService(mounted.server)
|
| 119 |
+
mounted_resource: (
|
| 120 |
+
Resource | ResourceTemplate
|
| 121 |
+
) = await mounted_service._enable_resource(key)
|
| 122 |
+
return mounted_resource
|
| 123 |
+
else:
|
| 124 |
+
continue
|
| 125 |
+
raise NotFoundError(f"Unknown resource: {key}")
|
| 126 |
+
|
| 127 |
+
async def _disable_resource(self, key: str) -> Resource | ResourceTemplate:
|
| 128 |
+
"""Handle 'disableResource' requests.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
key: The key of the resource to disable
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
The resource that was disabled
|
| 135 |
+
"""
|
| 136 |
+
logger.debug("Disable resource: %s", key)
|
| 137 |
+
|
| 138 |
+
# 1. Check local resources first. The server will have already applied its filter.
|
| 139 |
+
if key in self._resource_manager._resources:
|
| 140 |
+
resource: Resource = await self._server.get_resource(key)
|
| 141 |
+
resource.disable()
|
| 142 |
+
return resource
|
| 143 |
+
if key in self._resource_manager._templates:
|
| 144 |
+
template: ResourceTemplate = await self._server.get_resource_template(key)
|
| 145 |
+
template.disable()
|
| 146 |
+
return template
|
| 147 |
+
|
| 148 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 149 |
+
for mounted in reversed(self._resource_manager._mounted_servers):
|
| 150 |
+
if mounted.prefix:
|
| 151 |
+
if has_resource_prefix(
|
| 152 |
+
key,
|
| 153 |
+
mounted.prefix,
|
| 154 |
+
mounted.resource_prefix_format,
|
| 155 |
+
):
|
| 156 |
+
key = remove_resource_prefix(
|
| 157 |
+
key,
|
| 158 |
+
mounted.prefix,
|
| 159 |
+
mounted.resource_prefix_format,
|
| 160 |
+
)
|
| 161 |
+
mounted_service = ComponentService(mounted.server)
|
| 162 |
+
mounted_resource: (
|
| 163 |
+
Resource | ResourceTemplate
|
| 164 |
+
) = await mounted_service._disable_resource(key)
|
| 165 |
+
return mounted_resource
|
| 166 |
+
else:
|
| 167 |
+
continue
|
| 168 |
+
raise NotFoundError(f"Unknown resource: {key}")
|
| 169 |
+
|
| 170 |
+
async def _enable_prompt(self, key: str) -> Prompt:
|
| 171 |
+
"""Handle 'enablePrompt' requests.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
key: The key of the prompt to enable
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
The prompt that was enable
|
| 178 |
+
"""
|
| 179 |
+
logger.debug("Enabling prompt: %s", key)
|
| 180 |
+
|
| 181 |
+
# 1. Check local prompts first. The server will have already applied its filter.
|
| 182 |
+
if key in self._server._prompt_manager._prompts:
|
| 183 |
+
prompt: Prompt = await self._server.get_prompt(key)
|
| 184 |
+
prompt.enable()
|
| 185 |
+
return prompt
|
| 186 |
+
|
| 187 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 188 |
+
for mounted in reversed(self._prompt_manager._mounted_servers):
|
| 189 |
+
if mounted.prefix:
|
| 190 |
+
if key.startswith(f"{mounted.prefix}_"):
|
| 191 |
+
prompt_key = key.removeprefix(f"{mounted.prefix}_")
|
| 192 |
+
mounted_service = ComponentService(mounted.server)
|
| 193 |
+
prompt = await mounted_service._enable_prompt(prompt_key)
|
| 194 |
+
return prompt
|
| 195 |
+
else:
|
| 196 |
+
continue
|
| 197 |
+
raise NotFoundError(f"Unknown prompt: {key}")
|
| 198 |
+
|
| 199 |
+
async def _disable_prompt(self, key: str) -> Prompt:
|
| 200 |
+
"""Handle 'disablePrompt' requests.
|
| 201 |
+
|
| 202 |
+
Args:
|
| 203 |
+
key: The key of the prompt to disable
|
| 204 |
+
|
| 205 |
+
Returns:
|
| 206 |
+
The prompt that was disabled
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
# 1. Check local prompts first. The server will have already applied its filter.
|
| 210 |
+
if key in self._server._prompt_manager._prompts:
|
| 211 |
+
prompt: Prompt = await self._server.get_prompt(key)
|
| 212 |
+
prompt.disable()
|
| 213 |
+
return prompt
|
| 214 |
+
|
| 215 |
+
# 2. Check mounted servers using the filtered protocol path.
|
| 216 |
+
for mounted in reversed(self._prompt_manager._mounted_servers):
|
| 217 |
+
if mounted.prefix:
|
| 218 |
+
if key.startswith(f"{mounted.prefix}_"):
|
| 219 |
+
prompt_key = key.removeprefix(f"{mounted.prefix}_")
|
| 220 |
+
mounted_service = ComponentService(mounted.server)
|
| 221 |
+
prompt = await mounted_service._disable_prompt(prompt_key)
|
| 222 |
+
return prompt
|
| 223 |
+
else:
|
| 224 |
+
continue
|
| 225 |
+
raise NotFoundError(f"Unknown prompt: {key}")
|
src/fastmcp/contrib/component_manager/example.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastmcp import FastMCP
|
| 2 |
+
from fastmcp.contrib.component_manager import set_up_component_manager
|
| 3 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 4 |
+
|
| 5 |
+
key_pair = RSAKeyPair.generate()
|
| 6 |
+
|
| 7 |
+
auth = BearerAuthProvider(
|
| 8 |
+
public_key=key_pair.public_key,
|
| 9 |
+
issuer="https://dev.example.com",
|
| 10 |
+
audience="my-dev-server",
|
| 11 |
+
required_scopes=["mcp:read"],
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
# Build main server
|
| 15 |
+
mcp_token = key_pair.create_token(
|
| 16 |
+
subject="dev-user",
|
| 17 |
+
issuer="https://dev.example.com",
|
| 18 |
+
audience="my-dev-server",
|
| 19 |
+
scopes=["mcp:write", "mcp:read"],
|
| 20 |
+
)
|
| 21 |
+
mcp = FastMCP(
|
| 22 |
+
name="Component Manager",
|
| 23 |
+
instructions="This is a test server with component manager.",
|
| 24 |
+
auth=auth,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Set up main server component manager
|
| 28 |
+
set_up_component_manager(server=mcp, required_scopes=["mcp:write"])
|
| 29 |
+
|
| 30 |
+
# Build mounted server
|
| 31 |
+
mounted_token = key_pair.create_token(
|
| 32 |
+
subject="dev-user",
|
| 33 |
+
issuer="https://dev.example.com",
|
| 34 |
+
audience="my-dev-server",
|
| 35 |
+
scopes=["mounted:write", "mcp:read"],
|
| 36 |
+
)
|
| 37 |
+
mounted = FastMCP(
|
| 38 |
+
name="Component Manager",
|
| 39 |
+
instructions="This is a test server with component manager.",
|
| 40 |
+
auth=auth,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Set up mounted server component manager
|
| 44 |
+
set_up_component_manager(server=mounted, required_scopes=["mounted:write"])
|
| 45 |
+
|
| 46 |
+
# Mount
|
| 47 |
+
mcp.mount(server=mounted, prefix="mo")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@mcp.resource("resource://greeting")
|
| 51 |
+
def get_greeting() -> str:
|
| 52 |
+
"""Provides a simple greeting message."""
|
| 53 |
+
return "Hello from FastMCP Resources!"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@mounted.tool("greeting")
|
| 57 |
+
def get_info() -> str:
|
| 58 |
+
"""Provides a simple info."""
|
| 59 |
+
return "You are using component manager contrib module!"
|
src/fastmcp/server/low_level.py
CHANGED
|
@@ -4,12 +4,14 @@ from mcp.server.lowlevel.server import (
|
|
| 4 |
LifespanResultT,
|
| 5 |
NotificationOptions,
|
| 6 |
RequestT,
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
)
|
| 9 |
from mcp.server.models import InitializationOptions
|
| 10 |
|
| 11 |
|
| 12 |
-
class LowLevelServer(
|
| 13 |
def __init__(self, *args, **kwargs):
|
| 14 |
super().__init__(*args, **kwargs)
|
| 15 |
# FastMCP servers support notifications for all components
|
|
|
|
| 4 |
LifespanResultT,
|
| 5 |
NotificationOptions,
|
| 6 |
RequestT,
|
| 7 |
+
)
|
| 8 |
+
from mcp.server.lowlevel.server import (
|
| 9 |
+
Server as _Server,
|
| 10 |
)
|
| 11 |
from mcp.server.models import InitializationOptions
|
| 12 |
|
| 13 |
|
| 14 |
+
class LowLevelServer(_Server[LifespanResultT, RequestT]):
|
| 15 |
def __init__(self, *args, **kwargs):
|
| 16 |
super().__init__(*args, **kwargs)
|
| 17 |
# FastMCP servers support notifications for all components
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -13,7 +13,7 @@ from re import Pattern
|
|
| 13 |
from typing import TYPE_CHECKING, Any, Literal
|
| 14 |
|
| 15 |
import httpx
|
| 16 |
-
from mcp.types import
|
| 17 |
from pydantic.networks import AnyUrl
|
| 18 |
|
| 19 |
import fastmcp
|
|
@@ -21,7 +21,7 @@ from fastmcp.exceptions import ToolError
|
|
| 21 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 22 |
from fastmcp.server.dependencies import get_http_headers
|
| 23 |
from fastmcp.server.server import FastMCP
|
| 24 |
-
from fastmcp.tools.tool import Tool,
|
| 25 |
from fastmcp.utilities import openapi
|
| 26 |
from fastmcp.utilities.logging import get_logger
|
| 27 |
from fastmcp.utilities.openapi import (
|
|
@@ -254,7 +254,7 @@ class OpenAPITool(Tool):
|
|
| 254 |
"""Custom representation to prevent recursion errors when printing."""
|
| 255 |
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
| 256 |
|
| 257 |
-
async def run(self, arguments: dict[str, Any]) ->
|
| 258 |
"""Execute the HTTP request based on the route configuration."""
|
| 259 |
|
| 260 |
# Prepare URL
|
|
@@ -450,10 +450,11 @@ class OpenAPITool(Tool):
|
|
| 450 |
# Try to parse as JSON first
|
| 451 |
try:
|
| 452 |
result = response.json()
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
|
|
|
| 457 |
|
| 458 |
except httpx.HTTPStatusError as e:
|
| 459 |
# Handle HTTP errors (4xx, 5xx)
|
|
|
|
| 13 |
from typing import TYPE_CHECKING, Any, Literal
|
| 14 |
|
| 15 |
import httpx
|
| 16 |
+
from mcp.types import ToolAnnotations
|
| 17 |
from pydantic.networks import AnyUrl
|
| 18 |
|
| 19 |
import fastmcp
|
|
|
|
| 21 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 22 |
from fastmcp.server.dependencies import get_http_headers
|
| 23 |
from fastmcp.server.server import FastMCP
|
| 24 |
+
from fastmcp.tools.tool import Tool, ToolResult
|
| 25 |
from fastmcp.utilities import openapi
|
| 26 |
from fastmcp.utilities.logging import get_logger
|
| 27 |
from fastmcp.utilities.openapi import (
|
|
|
|
| 254 |
"""Custom representation to prevent recursion errors when printing."""
|
| 255 |
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
| 256 |
|
| 257 |
+
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
| 258 |
"""Execute the HTTP request based on the route configuration."""
|
| 259 |
|
| 260 |
# Prepare URL
|
|
|
|
| 450 |
# Try to parse as JSON first
|
| 451 |
try:
|
| 452 |
result = response.json()
|
| 453 |
+
if not isinstance(result, dict):
|
| 454 |
+
result = {"result": result}
|
| 455 |
+
return ToolResult(structured_content=result)
|
| 456 |
+
except json.JSONDecodeError:
|
| 457 |
+
return ToolResult(content=response.text)
|
| 458 |
|
| 459 |
except httpx.HTTPStatusError as e:
|
| 460 |
# Handle HTTP errors (4xx, 5xx)
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -8,7 +8,6 @@ from mcp.shared.exceptions import McpError
|
|
| 8 |
from mcp.types import (
|
| 9 |
METHOD_NOT_FOUND,
|
| 10 |
BlobResourceContents,
|
| 11 |
-
ContentBlock,
|
| 12 |
GetPromptResult,
|
| 13 |
TextResourceContents,
|
| 14 |
)
|
|
@@ -23,7 +22,7 @@ from fastmcp.resources import Resource, ResourceTemplate
|
|
| 23 |
from fastmcp.resources.resource_manager import ResourceManager
|
| 24 |
from fastmcp.server.context import Context
|
| 25 |
from fastmcp.server.server import FastMCP
|
| 26 |
-
from fastmcp.tools.tool import Tool
|
| 27 |
from fastmcp.tools.tool_manager import ToolManager
|
| 28 |
from fastmcp.utilities.logging import get_logger
|
| 29 |
|
|
@@ -67,9 +66,7 @@ class ProxyToolManager(ToolManager):
|
|
| 67 |
tools_dict = await self.get_tools()
|
| 68 |
return list(tools_dict.values())
|
| 69 |
|
| 70 |
-
async def call_tool(
|
| 71 |
-
self, key: str, arguments: dict[str, Any]
|
| 72 |
-
) -> list[ContentBlock]:
|
| 73 |
"""Calls a tool, trying local/mounted first, then proxy if not found."""
|
| 74 |
try:
|
| 75 |
# First try local and mounted tools
|
|
@@ -77,7 +74,11 @@ class ProxyToolManager(ToolManager):
|
|
| 77 |
except NotFoundError:
|
| 78 |
# If not found locally, try proxy
|
| 79 |
async with self.client:
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
class ProxyResourceManager(ResourceManager):
|
|
@@ -226,13 +227,14 @@ class ProxyTool(Tool):
|
|
| 226 |
description=mcp_tool.description,
|
| 227 |
parameters=mcp_tool.inputSchema,
|
| 228 |
annotations=mcp_tool.annotations,
|
|
|
|
| 229 |
)
|
| 230 |
|
| 231 |
async def run(
|
| 232 |
self,
|
| 233 |
arguments: dict[str, Any],
|
| 234 |
context: Context | None = None,
|
| 235 |
-
) ->
|
| 236 |
"""Executes the tool by making a call through the client."""
|
| 237 |
# This is where the remote execution logic lives.
|
| 238 |
async with self._client:
|
|
@@ -242,7 +244,10 @@ class ProxyTool(Tool):
|
|
| 242 |
)
|
| 243 |
if result.isError:
|
| 244 |
raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
|
| 245 |
-
return
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
|
| 248 |
class ProxyResource(Resource):
|
|
|
|
| 8 |
from mcp.types import (
|
| 9 |
METHOD_NOT_FOUND,
|
| 10 |
BlobResourceContents,
|
|
|
|
| 11 |
GetPromptResult,
|
| 12 |
TextResourceContents,
|
| 13 |
)
|
|
|
|
| 22 |
from fastmcp.resources.resource_manager import ResourceManager
|
| 23 |
from fastmcp.server.context import Context
|
| 24 |
from fastmcp.server.server import FastMCP
|
| 25 |
+
from fastmcp.tools.tool import Tool, ToolResult
|
| 26 |
from fastmcp.tools.tool_manager import ToolManager
|
| 27 |
from fastmcp.utilities.logging import get_logger
|
| 28 |
|
|
|
|
| 66 |
tools_dict = await self.get_tools()
|
| 67 |
return list(tools_dict.values())
|
| 68 |
|
| 69 |
+
async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
|
|
|
|
|
|
|
| 70 |
"""Calls a tool, trying local/mounted first, then proxy if not found."""
|
| 71 |
try:
|
| 72 |
# First try local and mounted tools
|
|
|
|
| 74 |
except NotFoundError:
|
| 75 |
# If not found locally, try proxy
|
| 76 |
async with self.client:
|
| 77 |
+
result = await self.client.call_tool(key, arguments)
|
| 78 |
+
return ToolResult(
|
| 79 |
+
content=result.content,
|
| 80 |
+
structured_content=result.structured_content,
|
| 81 |
+
)
|
| 82 |
|
| 83 |
|
| 84 |
class ProxyResourceManager(ResourceManager):
|
|
|
|
| 227 |
description=mcp_tool.description,
|
| 228 |
parameters=mcp_tool.inputSchema,
|
| 229 |
annotations=mcp_tool.annotations,
|
| 230 |
+
output_schema=mcp_tool.outputSchema,
|
| 231 |
)
|
| 232 |
|
| 233 |
async def run(
|
| 234 |
self,
|
| 235 |
arguments: dict[str, Any],
|
| 236 |
context: Context | None = None,
|
| 237 |
+
) -> ToolResult:
|
| 238 |
"""Executes the tool by making a call through the client."""
|
| 239 |
# This is where the remote execution logic lives.
|
| 240 |
async with self._client:
|
|
|
|
| 244 |
)
|
| 245 |
if result.isError:
|
| 246 |
raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
|
| 247 |
+
return ToolResult(
|
| 248 |
+
content=result.content,
|
| 249 |
+
structured_content=result.structuredContent,
|
| 250 |
+
)
|
| 251 |
|
| 252 |
|
| 253 |
class ProxyResource(Resource):
|
src/fastmcp/server/server.py
CHANGED
|
@@ -58,11 +58,12 @@ from fastmcp.server.low_level import LowLevelServer
|
|
| 58 |
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 59 |
from fastmcp.settings import Settings
|
| 60 |
from fastmcp.tools import ToolManager
|
| 61 |
-
from fastmcp.tools.tool import FunctionTool, Tool
|
| 62 |
from fastmcp.utilities.cache import TimedCache
|
| 63 |
from fastmcp.utilities.components import FastMCPComponent
|
| 64 |
from fastmcp.utilities.logging import get_logger
|
| 65 |
from fastmcp.utilities.mcp_config import MCPConfig
|
|
|
|
| 66 |
|
| 67 |
if TYPE_CHECKING:
|
| 68 |
from fastmcp.client import Client
|
|
@@ -592,7 +593,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 592 |
|
| 593 |
async def _mcp_call_tool(
|
| 594 |
self, key: str, arguments: dict[str, Any]
|
| 595 |
-
) -> list[ContentBlock]:
|
| 596 |
"""
|
| 597 |
Handle MCP 'callTool' requests.
|
| 598 |
|
|
@@ -609,22 +610,21 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 609 |
|
| 610 |
async with fastmcp.server.context.Context(fastmcp=self):
|
| 611 |
try:
|
| 612 |
-
|
|
|
|
| 613 |
except DisabledError:
|
| 614 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 615 |
except NotFoundError:
|
| 616 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 617 |
|
| 618 |
-
async def _call_tool(
|
| 619 |
-
self, key: str, arguments: dict[str, Any]
|
| 620 |
-
) -> list[ContentBlock]:
|
| 621 |
"""
|
| 622 |
Applies this server's middleware and delegates the filtered call to the manager.
|
| 623 |
"""
|
| 624 |
|
| 625 |
async def _handler(
|
| 626 |
context: MiddlewareContext[mcp.types.CallToolRequestParams],
|
| 627 |
-
) ->
|
| 628 |
tool = await self._tool_manager.get_tool(context.message.name)
|
| 629 |
if not self._should_enable_component(tool):
|
| 630 |
raise NotFoundError(f"Unknown tool: {context.message.name!r}")
|
|
@@ -792,6 +792,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 792 |
name: str | None = None,
|
| 793 |
description: str | None = None,
|
| 794 |
tags: set[str] | None = None,
|
|
|
|
| 795 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 796 |
exclude_args: list[str] | None = None,
|
| 797 |
enabled: bool | None = None,
|
|
@@ -805,6 +806,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 805 |
name: str | None = None,
|
| 806 |
description: str | None = None,
|
| 807 |
tags: set[str] | None = None,
|
|
|
|
| 808 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 809 |
exclude_args: list[str] | None = None,
|
| 810 |
enabled: bool | None = None,
|
|
@@ -817,6 +819,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 817 |
name: str | None = None,
|
| 818 |
description: str | None = None,
|
| 819 |
tags: set[str] | None = None,
|
|
|
|
| 820 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 821 |
exclude_args: list[str] | None = None,
|
| 822 |
enabled: bool | None = None,
|
|
@@ -839,6 +842,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 839 |
name: Optional name for the tool (keyword-only, alternative to name_or_fn)
|
| 840 |
description: Optional description of what the tool does
|
| 841 |
tags: Optional set of tags for categorizing the tool
|
|
|
|
| 842 |
annotations: Optional annotations about the tool's behavior
|
| 843 |
exclude_args: Optional list of argument names to exclude from the tool schema
|
| 844 |
enabled: Optional boolean to enable or disable the tool
|
|
@@ -895,6 +899,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 895 |
name=tool_name,
|
| 896 |
description=description,
|
| 897 |
tags=tags,
|
|
|
|
| 898 |
annotations=annotations,
|
| 899 |
exclude_args=exclude_args,
|
| 900 |
serializer=self._tool_serializer,
|
|
@@ -925,6 +930,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 925 |
name=tool_name,
|
| 926 |
description=description,
|
| 927 |
tags=tags,
|
|
|
|
| 928 |
annotations=annotations,
|
| 929 |
exclude_args=exclude_args,
|
| 930 |
enabled=enabled,
|
|
|
|
| 58 |
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
| 59 |
from fastmcp.settings import Settings
|
| 60 |
from fastmcp.tools import ToolManager
|
| 61 |
+
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
| 62 |
from fastmcp.utilities.cache import TimedCache
|
| 63 |
from fastmcp.utilities.components import FastMCPComponent
|
| 64 |
from fastmcp.utilities.logging import get_logger
|
| 65 |
from fastmcp.utilities.mcp_config import MCPConfig
|
| 66 |
+
from fastmcp.utilities.types import NotSet, NotSetT
|
| 67 |
|
| 68 |
if TYPE_CHECKING:
|
| 69 |
from fastmcp.client import Client
|
|
|
|
| 593 |
|
| 594 |
async def _mcp_call_tool(
|
| 595 |
self, key: str, arguments: dict[str, Any]
|
| 596 |
+
) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
|
| 597 |
"""
|
| 598 |
Handle MCP 'callTool' requests.
|
| 599 |
|
|
|
|
| 610 |
|
| 611 |
async with fastmcp.server.context.Context(fastmcp=self):
|
| 612 |
try:
|
| 613 |
+
result = await self._call_tool(key, arguments)
|
| 614 |
+
return result.to_mcp_result()
|
| 615 |
except DisabledError:
|
| 616 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 617 |
except NotFoundError:
|
| 618 |
raise NotFoundError(f"Unknown tool: {key}")
|
| 619 |
|
| 620 |
+
async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
|
|
|
|
|
|
|
| 621 |
"""
|
| 622 |
Applies this server's middleware and delegates the filtered call to the manager.
|
| 623 |
"""
|
| 624 |
|
| 625 |
async def _handler(
|
| 626 |
context: MiddlewareContext[mcp.types.CallToolRequestParams],
|
| 627 |
+
) -> ToolResult:
|
| 628 |
tool = await self._tool_manager.get_tool(context.message.name)
|
| 629 |
if not self._should_enable_component(tool):
|
| 630 |
raise NotFoundError(f"Unknown tool: {context.message.name!r}")
|
|
|
|
| 792 |
name: str | None = None,
|
| 793 |
description: str | None = None,
|
| 794 |
tags: set[str] | None = None,
|
| 795 |
+
output_schema: dict[str, Any] | None | NotSetT = NotSet,
|
| 796 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 797 |
exclude_args: list[str] | None = None,
|
| 798 |
enabled: bool | None = None,
|
|
|
|
| 806 |
name: str | None = None,
|
| 807 |
description: str | None = None,
|
| 808 |
tags: set[str] | None = None,
|
| 809 |
+
output_schema: dict[str, Any] | None | NotSetT = NotSet,
|
| 810 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 811 |
exclude_args: list[str] | None = None,
|
| 812 |
enabled: bool | None = None,
|
|
|
|
| 819 |
name: str | None = None,
|
| 820 |
description: str | None = None,
|
| 821 |
tags: set[str] | None = None,
|
| 822 |
+
output_schema: dict[str, Any] | None | NotSetT = NotSet,
|
| 823 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 824 |
exclude_args: list[str] | None = None,
|
| 825 |
enabled: bool | None = None,
|
|
|
|
| 842 |
name: Optional name for the tool (keyword-only, alternative to name_or_fn)
|
| 843 |
description: Optional description of what the tool does
|
| 844 |
tags: Optional set of tags for categorizing the tool
|
| 845 |
+
output_schema: Optional JSON schema for the tool's output
|
| 846 |
annotations: Optional annotations about the tool's behavior
|
| 847 |
exclude_args: Optional list of argument names to exclude from the tool schema
|
| 848 |
enabled: Optional boolean to enable or disable the tool
|
|
|
|
| 899 |
name=tool_name,
|
| 900 |
description=description,
|
| 901 |
tags=tags,
|
| 902 |
+
output_schema=output_schema,
|
| 903 |
annotations=annotations,
|
| 904 |
exclude_args=exclude_args,
|
| 905 |
serializer=self._tool_serializer,
|
|
|
|
| 930 |
name=tool_name,
|
| 931 |
description=description,
|
| 932 |
tags=tags,
|
| 933 |
+
output_schema=output_schema,
|
| 934 |
annotations=annotations,
|
| 935 |
exclude_args=exclude_args,
|
| 936 |
enabled=enabled,
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -3,12 +3,13 @@ from __future__ import annotations
|
|
| 3 |
import inspect
|
| 4 |
from collections.abc import Callable
|
| 5 |
from dataclasses import dataclass
|
| 6 |
-
from typing import TYPE_CHECKING, Any
|
| 7 |
|
|
|
|
| 8 |
import pydantic_core
|
| 9 |
from mcp.types import ContentBlock, TextContent, ToolAnnotations
|
| 10 |
from mcp.types import Tool as MCPTool
|
| 11 |
-
from pydantic import Field
|
| 12 |
|
| 13 |
from fastmcp.server.dependencies import get_context
|
| 14 |
from fastmcp.utilities.components import FastMCPComponent
|
|
@@ -18,8 +19,11 @@ from fastmcp.utilities.types import (
|
|
| 18 |
Audio,
|
| 19 |
File,
|
| 20 |
Image,
|
|
|
|
|
|
|
| 21 |
find_kwarg_by_type,
|
| 22 |
get_cached_typeadapter,
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
if TYPE_CHECKING:
|
|
@@ -28,20 +32,91 @@ if TYPE_CHECKING:
|
|
| 28 |
logger = get_logger(__name__)
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def default_serializer(data: Any) -> str:
|
| 32 |
return pydantic_core.to_json(data, fallback=str, indent=2).decode()
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
class Tool(FastMCPComponent):
|
| 36 |
"""Internal tool registration info."""
|
| 37 |
|
| 38 |
-
parameters:
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
def enable(self) -> None:
|
| 47 |
super().enable()
|
|
@@ -64,6 +139,7 @@ class Tool(FastMCPComponent):
|
|
| 64 |
"name": self.name,
|
| 65 |
"description": self.description,
|
| 66 |
"inputSchema": self.parameters,
|
|
|
|
| 67 |
"annotations": self.annotations,
|
| 68 |
}
|
| 69 |
return MCPTool(**kwargs | overrides)
|
|
@@ -76,6 +152,7 @@ class Tool(FastMCPComponent):
|
|
| 76 |
tags: set[str] | None = None,
|
| 77 |
annotations: ToolAnnotations | None = None,
|
| 78 |
exclude_args: list[str] | None = None,
|
|
|
|
| 79 |
serializer: Callable[[Any], str] | None = None,
|
| 80 |
enabled: bool | None = None,
|
| 81 |
) -> FunctionTool:
|
|
@@ -87,12 +164,21 @@ class Tool(FastMCPComponent):
|
|
| 87 |
tags=tags,
|
| 88 |
annotations=annotations,
|
| 89 |
exclude_args=exclude_args,
|
|
|
|
| 90 |
serializer=serializer,
|
| 91 |
enabled=enabled,
|
| 92 |
)
|
| 93 |
|
| 94 |
-
async def run(self, arguments: dict[str, Any]) ->
|
| 95 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
raise NotImplementedError("Subclasses must implement run()")
|
| 97 |
|
| 98 |
@classmethod
|
|
@@ -105,6 +191,7 @@ class Tool(FastMCPComponent):
|
|
| 105 |
description: str | None = None,
|
| 106 |
tags: set[str] | None = None,
|
| 107 |
annotations: ToolAnnotations | None = None,
|
|
|
|
| 108 |
serializer: Callable[[Any], str] | None = None,
|
| 109 |
enabled: bool | None = None,
|
| 110 |
) -> TransformedTool:
|
|
@@ -118,6 +205,7 @@ class Tool(FastMCPComponent):
|
|
| 118 |
description=description,
|
| 119 |
tags=tags,
|
| 120 |
annotations=annotations,
|
|
|
|
| 121 |
serializer=serializer,
|
| 122 |
enabled=enabled,
|
| 123 |
)
|
|
@@ -135,6 +223,7 @@ class FunctionTool(Tool):
|
|
| 135 |
tags: set[str] | None = None,
|
| 136 |
annotations: ToolAnnotations | None = None,
|
| 137 |
exclude_args: list[str] | None = None,
|
|
|
|
| 138 |
serializer: Callable[[Any], str] | None = None,
|
| 139 |
enabled: bool | None = None,
|
| 140 |
) -> FunctionTool:
|
|
@@ -145,18 +234,32 @@ class FunctionTool(Tool):
|
|
| 145 |
if name is None and parsed_fn.name == "<lambda>":
|
| 146 |
raise ValueError("You must provide a name for lambda functions")
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
return cls(
|
| 149 |
fn=parsed_fn.fn,
|
| 150 |
name=name or parsed_fn.name,
|
| 151 |
description=description or parsed_fn.description,
|
| 152 |
-
parameters=parsed_fn.
|
| 153 |
-
|
| 154 |
annotations=annotations,
|
|
|
|
| 155 |
serializer=serializer,
|
| 156 |
enabled=enabled if enabled is not None else True,
|
| 157 |
)
|
| 158 |
|
| 159 |
-
async def run(self, arguments: dict[str, Any]) ->
|
| 160 |
"""Run the tool with arguments."""
|
| 161 |
from fastmcp.server.context import Context
|
| 162 |
|
|
@@ -168,10 +271,37 @@ class FunctionTool(Tool):
|
|
| 168 |
|
| 169 |
type_adapter = get_cached_typeadapter(self.fn)
|
| 170 |
result = type_adapter.validate_python(arguments)
|
|
|
|
| 171 |
if inspect.isawaitable(result):
|
| 172 |
result = await result
|
| 173 |
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
|
| 177 |
@dataclass
|
|
@@ -179,13 +309,15 @@ class ParsedFunction:
|
|
| 179 |
fn: Callable[..., Any]
|
| 180 |
name: str
|
| 181 |
description: str | None
|
| 182 |
-
|
|
|
|
| 183 |
|
| 184 |
@classmethod
|
| 185 |
def from_function(
|
| 186 |
cls,
|
| 187 |
fn: Callable[..., Any],
|
| 188 |
exclude_args: list[str] | None = None,
|
|
|
|
| 189 |
validate: bool = True,
|
| 190 |
) -> ParsedFunction:
|
| 191 |
from fastmcp.server.context import Context
|
|
@@ -225,9 +357,6 @@ class ParsedFunction:
|
|
| 225 |
if isinstance(fn, staticmethod):
|
| 226 |
fn = fn.__func__
|
| 227 |
|
| 228 |
-
type_adapter = get_cached_typeadapter(fn)
|
| 229 |
-
schema = type_adapter.json_schema()
|
| 230 |
-
|
| 231 |
prune_params: list[str] = []
|
| 232 |
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
| 233 |
if context_kwarg:
|
|
@@ -235,12 +364,65 @@ class ParsedFunction:
|
|
| 235 |
if exclude_args:
|
| 236 |
prune_params.extend(exclude_args)
|
| 237 |
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
return cls(
|
| 240 |
fn=fn,
|
| 241 |
name=fn_name,
|
| 242 |
description=fn_doc,
|
| 243 |
-
|
|
|
|
| 244 |
)
|
| 245 |
|
| 246 |
|
|
|
|
| 3 |
import inspect
|
| 4 |
from collections.abc import Callable
|
| 5 |
from dataclasses import dataclass
|
| 6 |
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
| 7 |
|
| 8 |
+
import mcp.types
|
| 9 |
import pydantic_core
|
| 10 |
from mcp.types import ContentBlock, TextContent, ToolAnnotations
|
| 11 |
from mcp.types import Tool as MCPTool
|
| 12 |
+
from pydantic import Field, PydanticSchemaGenerationError
|
| 13 |
|
| 14 |
from fastmcp.server.dependencies import get_context
|
| 15 |
from fastmcp.utilities.components import FastMCPComponent
|
|
|
|
| 19 |
Audio,
|
| 20 |
File,
|
| 21 |
Image,
|
| 22 |
+
NotSet,
|
| 23 |
+
NotSetT,
|
| 24 |
find_kwarg_by_type,
|
| 25 |
get_cached_typeadapter,
|
| 26 |
+
replace_type,
|
| 27 |
)
|
| 28 |
|
| 29 |
if TYPE_CHECKING:
|
|
|
|
| 32 |
logger = get_logger(__name__)
|
| 33 |
|
| 34 |
|
| 35 |
+
class _UnserializableType:
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
def default_serializer(data: Any) -> str:
|
| 40 |
return pydantic_core.to_json(data, fallback=str, indent=2).decode()
|
| 41 |
|
| 42 |
|
| 43 |
+
def _wrap_schema_if_needed(schema: dict[str, Any] | None) -> dict[str, Any] | None:
|
| 44 |
+
"""Wrap non-object schemas with result property for structured output.
|
| 45 |
+
|
| 46 |
+
This wrapping allows primitive types (int, str, etc.) to be returned as
|
| 47 |
+
structured content by placing them under a "result" key.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
schema: The JSON schema to potentially wrap
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Wrapped schema if needed, or original schema if already an object type
|
| 54 |
+
"""
|
| 55 |
+
if schema and schema.get("type") != "object":
|
| 56 |
+
return {
|
| 57 |
+
"type": "object",
|
| 58 |
+
"properties": {"result": schema},
|
| 59 |
+
"x-fastmcp-wrap-result": True,
|
| 60 |
+
}
|
| 61 |
+
return schema
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ToolResult:
|
| 65 |
+
def __init__(
|
| 66 |
+
self,
|
| 67 |
+
content: list[ContentBlock] | Any | None = None,
|
| 68 |
+
structured_content: dict[str, Any] | Any | None = None,
|
| 69 |
+
):
|
| 70 |
+
if content is None and structured_content is None:
|
| 71 |
+
raise ValueError("Either content or structured_content must be provided")
|
| 72 |
+
elif content is None:
|
| 73 |
+
content = structured_content
|
| 74 |
+
|
| 75 |
+
self.content = _convert_to_content(content)
|
| 76 |
+
|
| 77 |
+
if structured_content is not None:
|
| 78 |
+
try:
|
| 79 |
+
structured_content = pydantic_core.to_jsonable_python(
|
| 80 |
+
structured_content
|
| 81 |
+
)
|
| 82 |
+
except pydantic_core.PydanticSerializationError as e:
|
| 83 |
+
logger.error(
|
| 84 |
+
f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}"
|
| 85 |
+
)
|
| 86 |
+
raise
|
| 87 |
+
if not isinstance(structured_content, dict):
|
| 88 |
+
raise ValueError(
|
| 89 |
+
"structured_content must be a dict or None. "
|
| 90 |
+
f"Got {type(structured_content).__name__}: {structured_content!r}. "
|
| 91 |
+
"Tools should wrap non-dict values based on their output_schema."
|
| 92 |
+
)
|
| 93 |
+
self.structured_content: dict[str, Any] | None = structured_content
|
| 94 |
+
|
| 95 |
+
def to_mcp_result(
|
| 96 |
+
self,
|
| 97 |
+
) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
|
| 98 |
+
if self.structured_content is None:
|
| 99 |
+
return self.content
|
| 100 |
+
return self.content, self.structured_content
|
| 101 |
+
|
| 102 |
+
|
| 103 |
class Tool(FastMCPComponent):
|
| 104 |
"""Internal tool registration info."""
|
| 105 |
|
| 106 |
+
parameters: Annotated[
|
| 107 |
+
dict[str, Any], Field(description="JSON schema for tool parameters")
|
| 108 |
+
]
|
| 109 |
+
output_schema: Annotated[
|
| 110 |
+
dict[str, Any] | None, Field(description="JSON schema for tool output")
|
| 111 |
+
] = None
|
| 112 |
+
annotations: Annotated[
|
| 113 |
+
ToolAnnotations | None,
|
| 114 |
+
Field(description="Additional annotations about the tool"),
|
| 115 |
+
] = None
|
| 116 |
+
serializer: Annotated[
|
| 117 |
+
Callable[[Any], str] | None,
|
| 118 |
+
Field(description="Optional custom serializer for tool results"),
|
| 119 |
+
] = None
|
| 120 |
|
| 121 |
def enable(self) -> None:
|
| 122 |
super().enable()
|
|
|
|
| 139 |
"name": self.name,
|
| 140 |
"description": self.description,
|
| 141 |
"inputSchema": self.parameters,
|
| 142 |
+
"outputSchema": self.output_schema,
|
| 143 |
"annotations": self.annotations,
|
| 144 |
}
|
| 145 |
return MCPTool(**kwargs | overrides)
|
|
|
|
| 152 |
tags: set[str] | None = None,
|
| 153 |
annotations: ToolAnnotations | None = None,
|
| 154 |
exclude_args: list[str] | None = None,
|
| 155 |
+
output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
|
| 156 |
serializer: Callable[[Any], str] | None = None,
|
| 157 |
enabled: bool | None = None,
|
| 158 |
) -> FunctionTool:
|
|
|
|
| 164 |
tags=tags,
|
| 165 |
annotations=annotations,
|
| 166 |
exclude_args=exclude_args,
|
| 167 |
+
output_schema=output_schema,
|
| 168 |
serializer=serializer,
|
| 169 |
enabled=enabled,
|
| 170 |
)
|
| 171 |
|
| 172 |
+
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
| 173 |
+
"""
|
| 174 |
+
Run the tool with arguments.
|
| 175 |
+
|
| 176 |
+
This method is not implemented in the base Tool class and must be
|
| 177 |
+
implemented by subclasses.
|
| 178 |
+
|
| 179 |
+
`run()` can EITHER return a list of ContentBlocks, or a tuple of
|
| 180 |
+
(list of ContentBlocks, dict of structured output).
|
| 181 |
+
"""
|
| 182 |
raise NotImplementedError("Subclasses must implement run()")
|
| 183 |
|
| 184 |
@classmethod
|
|
|
|
| 191 |
description: str | None = None,
|
| 192 |
tags: set[str] | None = None,
|
| 193 |
annotations: ToolAnnotations | None = None,
|
| 194 |
+
output_schema: dict[str, Any] | None | Literal[False] = None,
|
| 195 |
serializer: Callable[[Any], str] | None = None,
|
| 196 |
enabled: bool | None = None,
|
| 197 |
) -> TransformedTool:
|
|
|
|
| 205 |
description=description,
|
| 206 |
tags=tags,
|
| 207 |
annotations=annotations,
|
| 208 |
+
output_schema=output_schema,
|
| 209 |
serializer=serializer,
|
| 210 |
enabled=enabled,
|
| 211 |
)
|
|
|
|
| 223 |
tags: set[str] | None = None,
|
| 224 |
annotations: ToolAnnotations | None = None,
|
| 225 |
exclude_args: list[str] | None = None,
|
| 226 |
+
output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
|
| 227 |
serializer: Callable[[Any], str] | None = None,
|
| 228 |
enabled: bool | None = None,
|
| 229 |
) -> FunctionTool:
|
|
|
|
| 234 |
if name is None and parsed_fn.name == "<lambda>":
|
| 235 |
raise ValueError("You must provide a name for lambda functions")
|
| 236 |
|
| 237 |
+
if isinstance(output_schema, NotSetT):
|
| 238 |
+
output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
|
| 239 |
+
elif output_schema is False:
|
| 240 |
+
output_schema = None
|
| 241 |
+
# Note: explicit schemas (dict) are used as-is without auto-wrapping
|
| 242 |
+
|
| 243 |
+
# Validate that explicit schemas are object type for structured content
|
| 244 |
+
if output_schema is not None and isinstance(output_schema, dict):
|
| 245 |
+
if output_schema.get("type") != "object":
|
| 246 |
+
raise ValueError(
|
| 247 |
+
f'Output schemas must have "type" set to "object" due to MCP spec limitations. Received: {output_schema!r}'
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
return cls(
|
| 251 |
fn=parsed_fn.fn,
|
| 252 |
name=name or parsed_fn.name,
|
| 253 |
description=description or parsed_fn.description,
|
| 254 |
+
parameters=parsed_fn.input_schema,
|
| 255 |
+
output_schema=output_schema,
|
| 256 |
annotations=annotations,
|
| 257 |
+
tags=tags or set(),
|
| 258 |
serializer=serializer,
|
| 259 |
enabled=enabled if enabled is not None else True,
|
| 260 |
)
|
| 261 |
|
| 262 |
+
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
| 263 |
"""Run the tool with arguments."""
|
| 264 |
from fastmcp.server.context import Context
|
| 265 |
|
|
|
|
| 271 |
|
| 272 |
type_adapter = get_cached_typeadapter(self.fn)
|
| 273 |
result = type_adapter.validate_python(arguments)
|
| 274 |
+
|
| 275 |
if inspect.isawaitable(result):
|
| 276 |
result = await result
|
| 277 |
|
| 278 |
+
if isinstance(result, ToolResult):
|
| 279 |
+
return result
|
| 280 |
+
|
| 281 |
+
unstructured_result = _convert_to_content(result, serializer=self.serializer)
|
| 282 |
+
|
| 283 |
+
structured_output = None
|
| 284 |
+
# First handle structured content based on output schema, if any
|
| 285 |
+
if self.output_schema is not None:
|
| 286 |
+
if self.output_schema.get("x-fastmcp-wrap-result"):
|
| 287 |
+
# Schema says wrap - always wrap in result key
|
| 288 |
+
structured_output = {"result": result}
|
| 289 |
+
else:
|
| 290 |
+
structured_output = result
|
| 291 |
+
# If no output schema, try to serialize the result. If it is a dict, use
|
| 292 |
+
# it as structured content. If it is not a dict, ignore it.
|
| 293 |
+
if structured_output is None:
|
| 294 |
+
try:
|
| 295 |
+
structured_output = pydantic_core.to_jsonable_python(result)
|
| 296 |
+
if not isinstance(structured_output, dict):
|
| 297 |
+
structured_output = None
|
| 298 |
+
except Exception:
|
| 299 |
+
pass
|
| 300 |
+
|
| 301 |
+
return ToolResult(
|
| 302 |
+
content=unstructured_result,
|
| 303 |
+
structured_content=structured_output,
|
| 304 |
+
)
|
| 305 |
|
| 306 |
|
| 307 |
@dataclass
|
|
|
|
| 309 |
fn: Callable[..., Any]
|
| 310 |
name: str
|
| 311 |
description: str | None
|
| 312 |
+
input_schema: dict[str, Any]
|
| 313 |
+
output_schema: dict[str, Any] | None
|
| 314 |
|
| 315 |
@classmethod
|
| 316 |
def from_function(
|
| 317 |
cls,
|
| 318 |
fn: Callable[..., Any],
|
| 319 |
exclude_args: list[str] | None = None,
|
| 320 |
+
ignore_response_types: list[type] | None = None,
|
| 321 |
validate: bool = True,
|
| 322 |
) -> ParsedFunction:
|
| 323 |
from fastmcp.server.context import Context
|
|
|
|
| 357 |
if isinstance(fn, staticmethod):
|
| 358 |
fn = fn.__func__
|
| 359 |
|
|
|
|
|
|
|
|
|
|
| 360 |
prune_params: list[str] = []
|
| 361 |
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
| 362 |
if context_kwarg:
|
|
|
|
| 364 |
if exclude_args:
|
| 365 |
prune_params.extend(exclude_args)
|
| 366 |
|
| 367 |
+
input_type_adapter = get_cached_typeadapter(fn)
|
| 368 |
+
input_schema = input_type_adapter.json_schema()
|
| 369 |
+
input_schema = compress_schema(input_schema, prune_params=prune_params)
|
| 370 |
+
|
| 371 |
+
output_schema = None
|
| 372 |
+
output_type = inspect.signature(fn).return_annotation
|
| 373 |
+
|
| 374 |
+
if output_type not in (inspect._empty, None, Any, ...):
|
| 375 |
+
# there are a variety of types that we don't want to attempt to
|
| 376 |
+
# serialize because they are either used by FastMCP internally,
|
| 377 |
+
# or are MCP content types that explicitly don't form structured
|
| 378 |
+
# content. By replacing them with an explicitly unserializable type,
|
| 379 |
+
# we ensure that no output schema is automatically generated.
|
| 380 |
+
output_type = replace_type(
|
| 381 |
+
output_type,
|
| 382 |
+
{
|
| 383 |
+
t: _UnserializableType
|
| 384 |
+
for t in (
|
| 385 |
+
Image,
|
| 386 |
+
Audio,
|
| 387 |
+
File,
|
| 388 |
+
ToolResult,
|
| 389 |
+
mcp.types.TextContent,
|
| 390 |
+
mcp.types.ImageContent,
|
| 391 |
+
mcp.types.AudioContent,
|
| 392 |
+
mcp.types.ResourceLink,
|
| 393 |
+
mcp.types.EmbeddedResource,
|
| 394 |
+
)
|
| 395 |
+
},
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
try:
|
| 399 |
+
output_type_adapter = get_cached_typeadapter(output_type)
|
| 400 |
+
output_schema = output_type_adapter.json_schema()
|
| 401 |
+
except PydanticSchemaGenerationError as e:
|
| 402 |
+
if "_UnserializableType" not in str(e):
|
| 403 |
+
logger.debug(f"Unable to generate schema for type {output_type!r}")
|
| 404 |
+
|
| 405 |
+
return cls(
|
| 406 |
+
fn=fn,
|
| 407 |
+
name=fn_name,
|
| 408 |
+
description=fn_doc,
|
| 409 |
+
input_schema=input_schema,
|
| 410 |
+
output_schema=output_schema or None,
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
try:
|
| 414 |
+
output_type_adapter = get_cached_typeadapter(output_type)
|
| 415 |
+
output_schema = output_type_adapter.json_schema()
|
| 416 |
+
except PydanticSchemaGenerationError as e:
|
| 417 |
+
if "_UnserializableType" not in str(e):
|
| 418 |
+
logger.debug(f"Unable to generate schema for type {output_type!r}")
|
| 419 |
+
|
| 420 |
return cls(
|
| 421 |
fn=fn,
|
| 422 |
name=fn_name,
|
| 423 |
description=fn_doc,
|
| 424 |
+
input_schema=input_schema,
|
| 425 |
+
output_schema=output_schema or None,
|
| 426 |
)
|
| 427 |
|
| 428 |
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -4,12 +4,12 @@ import warnings
|
|
| 4 |
from collections.abc import Callable
|
| 5 |
from typing import TYPE_CHECKING, Any
|
| 6 |
|
| 7 |
-
from mcp.types import
|
| 8 |
|
| 9 |
from fastmcp import settings
|
| 10 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 11 |
from fastmcp.settings import DuplicateBehavior
|
| 12 |
-
from fastmcp.tools.tool import Tool
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
|
| 15 |
if TYPE_CHECKING:
|
|
@@ -169,9 +169,7 @@ class ToolManager:
|
|
| 169 |
else:
|
| 170 |
raise NotFoundError(f"Tool {key!r} not found")
|
| 171 |
|
| 172 |
-
async def call_tool(
|
| 173 |
-
self, key: str, arguments: dict[str, Any]
|
| 174 |
-
) -> list[ContentBlock]:
|
| 175 |
"""
|
| 176 |
Internal API for servers: Finds and calls a tool, respecting the
|
| 177 |
filtered protocol path.
|
|
|
|
| 4 |
from collections.abc import Callable
|
| 5 |
from typing import TYPE_CHECKING, Any
|
| 6 |
|
| 7 |
+
from mcp.types import ToolAnnotations
|
| 8 |
|
| 9 |
from fastmcp import settings
|
| 10 |
from fastmcp.exceptions import NotFoundError, ToolError
|
| 11 |
from fastmcp.settings import DuplicateBehavior
|
| 12 |
+
from fastmcp.tools.tool import Tool, ToolResult
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
|
| 15 |
if TYPE_CHECKING:
|
|
|
|
| 169 |
else:
|
| 170 |
raise NotFoundError(f"Tool {key!r} not found")
|
| 171 |
|
| 172 |
+
async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
|
|
|
|
|
|
|
| 173 |
"""
|
| 174 |
Internal API for servers: Finds and calls a tool, respecting the
|
| 175 |
filtered protocol path.
|
src/fastmcp/tools/tool_transform.py
CHANGED
|
@@ -4,20 +4,17 @@ import inspect
|
|
| 4 |
from collections.abc import Callable
|
| 5 |
from contextvars import ContextVar
|
| 6 |
from dataclasses import dataclass
|
| 7 |
-
from types import EllipsisType
|
| 8 |
from typing import Any, Literal
|
| 9 |
|
| 10 |
-
from mcp.types import
|
| 11 |
from pydantic import ConfigDict
|
| 12 |
|
| 13 |
-
from fastmcp.tools.tool import ParsedFunction, Tool
|
| 14 |
from fastmcp.utilities.logging import get_logger
|
| 15 |
-
from fastmcp.utilities.types import get_cached_typeadapter
|
| 16 |
|
| 17 |
logger = get_logger(__name__)
|
| 18 |
|
| 19 |
-
NotSet = ...
|
| 20 |
-
|
| 21 |
|
| 22 |
# Context variable to store current transformed tool
|
| 23 |
_current_tool: ContextVar[TransformedTool | None] = ContextVar(
|
|
@@ -25,7 +22,7 @@ _current_tool: ContextVar[TransformedTool | None] = ContextVar(
|
|
| 25 |
)
|
| 26 |
|
| 27 |
|
| 28 |
-
async def forward(**kwargs) ->
|
| 29 |
"""Forward to parent tool with argument transformation applied.
|
| 30 |
|
| 31 |
This function can only be called from within a transformed tool's custom
|
|
@@ -41,7 +38,7 @@ async def forward(**kwargs) -> Any:
|
|
| 41 |
**kwargs: Arguments to forward to the parent tool (using transformed names).
|
| 42 |
|
| 43 |
Returns:
|
| 44 |
-
The
|
| 45 |
|
| 46 |
Raises:
|
| 47 |
RuntimeError: If called outside a transformed tool context.
|
|
@@ -55,7 +52,7 @@ async def forward(**kwargs) -> Any:
|
|
| 55 |
return await tool.forwarding_fn(**kwargs)
|
| 56 |
|
| 57 |
|
| 58 |
-
async def forward_raw(**kwargs) ->
|
| 59 |
"""Forward directly to parent tool without transformation.
|
| 60 |
|
| 61 |
This function bypasses all argument transformation and validation, calling the parent
|
|
@@ -69,7 +66,7 @@ async def forward_raw(**kwargs) -> Any:
|
|
| 69 |
**kwargs: Arguments to pass directly to the parent tool (using original names).
|
| 70 |
|
| 71 |
Returns:
|
| 72 |
-
The
|
| 73 |
|
| 74 |
Raises:
|
| 75 |
RuntimeError: If called outside a transformed tool context.
|
|
@@ -151,14 +148,14 @@ class ArgTransform:
|
|
| 151 |
```
|
| 152 |
"""
|
| 153 |
|
| 154 |
-
name: str |
|
| 155 |
-
description: str |
|
| 156 |
-
default: Any |
|
| 157 |
-
default_factory: Callable[[], Any] |
|
| 158 |
-
type: Any |
|
| 159 |
hide: bool = False
|
| 160 |
-
required: Literal[True] |
|
| 161 |
-
examples: Any |
|
| 162 |
|
| 163 |
def __post_init__(self):
|
| 164 |
"""Validate that only one of default or default_factory is provided."""
|
|
@@ -201,11 +198,12 @@ class TransformedTool(Tool):
|
|
| 201 |
|
| 202 |
This class represents a tool that has been created by transforming another tool.
|
| 203 |
It supports argument renaming, schema modification, custom function injection,
|
| 204 |
-
and provides context for the forward() and forward_raw() functions.
|
| 205 |
|
| 206 |
The transformation can be purely schema-based (argument renaming, dropping, etc.)
|
| 207 |
or can include a custom function that uses forward() to call the parent tool
|
| 208 |
-
with transformed arguments.
|
|
|
|
| 209 |
|
| 210 |
Attributes:
|
| 211 |
parent_tool: The original tool that this tool was transformed from.
|
|
@@ -222,7 +220,7 @@ class TransformedTool(Tool):
|
|
| 222 |
forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
|
| 223 |
transform_args: dict[str, ArgTransform]
|
| 224 |
|
| 225 |
-
async def run(self, arguments: dict[str, Any]) ->
|
| 226 |
"""Run the tool with context set for forward() functions.
|
| 227 |
|
| 228 |
This method executes the tool's function while setting up the context
|
|
@@ -233,8 +231,7 @@ class TransformedTool(Tool):
|
|
| 233 |
arguments: Dictionary of arguments to pass to the tool's function.
|
| 234 |
|
| 235 |
Returns:
|
| 236 |
-
|
| 237 |
-
the tool's output.
|
| 238 |
"""
|
| 239 |
from fastmcp.tools.tool import _convert_to_content
|
| 240 |
|
|
@@ -272,7 +269,57 @@ class TransformedTool(Tool):
|
|
| 272 |
token = _current_tool.set(self)
|
| 273 |
try:
|
| 274 |
result = await self.fn(**arguments)
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
finally:
|
| 277 |
_current_tool.reset(token)
|
| 278 |
|
|
@@ -286,6 +333,7 @@ class TransformedTool(Tool):
|
|
| 286 |
transform_fn: Callable[..., Any] | None = None,
|
| 287 |
transform_args: dict[str, ArgTransform] | None = None,
|
| 288 |
annotations: ToolAnnotations | None = None,
|
|
|
|
| 289 |
serializer: Callable[[Any], str] | None = None,
|
| 290 |
enabled: bool | None = None,
|
| 291 |
) -> TransformedTool:
|
|
@@ -305,6 +353,10 @@ class TransformedTool(Tool):
|
|
| 305 |
description: New description. Defaults to parent's description.
|
| 306 |
tags: New tags. Defaults to parent's tags.
|
| 307 |
annotations: New annotations. Defaults to parent's annotations.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
serializer: New serializer. Defaults to parent's serializer.
|
| 309 |
|
| 310 |
Returns:
|
|
@@ -333,6 +385,26 @@ class TransformedTool(Tool):
|
|
| 333 |
|
| 334 |
Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
|
| 335 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
"""
|
| 337 |
transform_args = transform_args or {}
|
| 338 |
|
|
@@ -348,19 +420,45 @@ class TransformedTool(Tool):
|
|
| 348 |
# Always create the forwarding transform
|
| 349 |
schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
|
| 350 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
if transform_fn is None:
|
| 352 |
# User wants pure transformation - use forwarding_fn as the main function
|
| 353 |
final_fn = forwarding_fn
|
| 354 |
final_schema = schema
|
| 355 |
else:
|
| 356 |
# User provided custom function - merge schemas
|
| 357 |
-
parsed_fn
|
|
|
|
| 358 |
final_fn = transform_fn
|
| 359 |
|
| 360 |
has_kwargs = cls._function_has_kwargs(transform_fn)
|
| 361 |
|
| 362 |
# Validate function parameters against transformed schema
|
| 363 |
-
fn_params = set(parsed_fn.
|
| 364 |
transformed_params = set(schema.get("properties", {}).keys())
|
| 365 |
|
| 366 |
if not has_kwargs:
|
|
@@ -377,7 +475,7 @@ class TransformedTool(Tool):
|
|
| 377 |
# ArgTransform takes precedence over function signature
|
| 378 |
# Start with function schema as base, then override with transformed schema
|
| 379 |
final_schema = cls._merge_schema_with_precedence(
|
| 380 |
-
parsed_fn.
|
| 381 |
)
|
| 382 |
else:
|
| 383 |
# With **kwargs, function can access all transformed params
|
|
@@ -386,7 +484,7 @@ class TransformedTool(Tool):
|
|
| 386 |
|
| 387 |
# Start with function schema as base, then override with transformed schema
|
| 388 |
final_schema = cls._merge_schema_with_precedence(
|
| 389 |
-
parsed_fn.
|
| 390 |
)
|
| 391 |
|
| 392 |
# Additional validation: check for naming conflicts after transformation
|
|
@@ -422,6 +520,7 @@ class TransformedTool(Tool):
|
|
| 422 |
name=name or tool.name,
|
| 423 |
description=final_description,
|
| 424 |
parameters=final_schema,
|
|
|
|
| 425 |
tags=tags or tool.tags,
|
| 426 |
annotations=annotations or tool.annotations,
|
| 427 |
serializer=serializer or tool.serializer,
|
|
|
|
| 4 |
from collections.abc import Callable
|
| 5 |
from contextvars import ContextVar
|
| 6 |
from dataclasses import dataclass
|
|
|
|
| 7 |
from typing import Any, Literal
|
| 8 |
|
| 9 |
+
from mcp.types import ToolAnnotations
|
| 10 |
from pydantic import ConfigDict
|
| 11 |
|
| 12 |
+
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _wrap_schema_if_needed
|
| 13 |
from fastmcp.utilities.logging import get_logger
|
| 14 |
+
from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
|
| 15 |
|
| 16 |
logger = get_logger(__name__)
|
| 17 |
|
|
|
|
|
|
|
| 18 |
|
| 19 |
# Context variable to store current transformed tool
|
| 20 |
_current_tool: ContextVar[TransformedTool | None] = ContextVar(
|
|
|
|
| 22 |
)
|
| 23 |
|
| 24 |
|
| 25 |
+
async def forward(**kwargs) -> ToolResult:
|
| 26 |
"""Forward to parent tool with argument transformation applied.
|
| 27 |
|
| 28 |
This function can only be called from within a transformed tool's custom
|
|
|
|
| 38 |
**kwargs: Arguments to forward to the parent tool (using transformed names).
|
| 39 |
|
| 40 |
Returns:
|
| 41 |
+
The ToolResult from the parent tool execution.
|
| 42 |
|
| 43 |
Raises:
|
| 44 |
RuntimeError: If called outside a transformed tool context.
|
|
|
|
| 52 |
return await tool.forwarding_fn(**kwargs)
|
| 53 |
|
| 54 |
|
| 55 |
+
async def forward_raw(**kwargs) -> ToolResult:
|
| 56 |
"""Forward directly to parent tool without transformation.
|
| 57 |
|
| 58 |
This function bypasses all argument transformation and validation, calling the parent
|
|
|
|
| 66 |
**kwargs: Arguments to pass directly to the parent tool (using original names).
|
| 67 |
|
| 68 |
Returns:
|
| 69 |
+
The ToolResult from the parent tool execution.
|
| 70 |
|
| 71 |
Raises:
|
| 72 |
RuntimeError: If called outside a transformed tool context.
|
|
|
|
| 148 |
```
|
| 149 |
"""
|
| 150 |
|
| 151 |
+
name: str | NotSetT = NotSet
|
| 152 |
+
description: str | NotSetT = NotSet
|
| 153 |
+
default: Any | NotSetT = NotSet
|
| 154 |
+
default_factory: Callable[[], Any] | NotSetT = NotSet
|
| 155 |
+
type: Any | NotSetT = NotSet
|
| 156 |
hide: bool = False
|
| 157 |
+
required: Literal[True] | NotSetT = NotSet
|
| 158 |
+
examples: Any | NotSetT = NotSet
|
| 159 |
|
| 160 |
def __post_init__(self):
|
| 161 |
"""Validate that only one of default or default_factory is provided."""
|
|
|
|
| 198 |
|
| 199 |
This class represents a tool that has been created by transforming another tool.
|
| 200 |
It supports argument renaming, schema modification, custom function injection,
|
| 201 |
+
structured output control, and provides context for the forward() and forward_raw() functions.
|
| 202 |
|
| 203 |
The transformation can be purely schema-based (argument renaming, dropping, etc.)
|
| 204 |
or can include a custom function that uses forward() to call the parent tool
|
| 205 |
+
with transformed arguments. Output schemas and structured outputs are automatically
|
| 206 |
+
inherited from the parent tool but can be overridden or disabled.
|
| 207 |
|
| 208 |
Attributes:
|
| 209 |
parent_tool: The original tool that this tool was transformed from.
|
|
|
|
| 220 |
forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
|
| 221 |
transform_args: dict[str, ArgTransform]
|
| 222 |
|
| 223 |
+
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
| 224 |
"""Run the tool with context set for forward() functions.
|
| 225 |
|
| 226 |
This method executes the tool's function while setting up the context
|
|
|
|
| 231 |
arguments: Dictionary of arguments to pass to the tool's function.
|
| 232 |
|
| 233 |
Returns:
|
| 234 |
+
ToolResult object containing content and optional structured output.
|
|
|
|
| 235 |
"""
|
| 236 |
from fastmcp.tools.tool import _convert_to_content
|
| 237 |
|
|
|
|
| 269 |
token = _current_tool.set(self)
|
| 270 |
try:
|
| 271 |
result = await self.fn(**arguments)
|
| 272 |
+
|
| 273 |
+
# If transform function returns ToolResult, respect our output_schema setting
|
| 274 |
+
if isinstance(result, ToolResult):
|
| 275 |
+
if self.output_schema is None:
|
| 276 |
+
# Check if this is from a custom function that returns ToolResult
|
| 277 |
+
import inspect
|
| 278 |
+
|
| 279 |
+
return_annotation = inspect.signature(self.fn).return_annotation
|
| 280 |
+
if return_annotation is ToolResult:
|
| 281 |
+
# Custom function returns ToolResult - preserve its content
|
| 282 |
+
return result
|
| 283 |
+
else:
|
| 284 |
+
# Forwarded call with disabled schema - strip structured content
|
| 285 |
+
return ToolResult(
|
| 286 |
+
content=result.content,
|
| 287 |
+
structured_content=None,
|
| 288 |
+
)
|
| 289 |
+
elif self.output_schema.get(
|
| 290 |
+
"type"
|
| 291 |
+
) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
|
| 292 |
+
# Non-object explicit schemas disable structured content
|
| 293 |
+
return ToolResult(
|
| 294 |
+
content=result.content,
|
| 295 |
+
structured_content=None,
|
| 296 |
+
)
|
| 297 |
+
else:
|
| 298 |
+
return result
|
| 299 |
+
|
| 300 |
+
# Otherwise convert to content and create ToolResult with proper structured content
|
| 301 |
+
from fastmcp.tools.tool import _convert_to_content
|
| 302 |
+
|
| 303 |
+
unstructured_result = _convert_to_content(
|
| 304 |
+
result, serializer=self.serializer
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
# Handle structured content based on output schema
|
| 308 |
+
if self.output_schema is not None:
|
| 309 |
+
if self.output_schema.get("x-fastmcp-wrap-result"):
|
| 310 |
+
# Schema says wrap - always wrap in result key
|
| 311 |
+
structured_output = {"result": result}
|
| 312 |
+
else:
|
| 313 |
+
# Object schemas - use result directly
|
| 314 |
+
# User is responsible for returning dict-compatible data
|
| 315 |
+
structured_output = result
|
| 316 |
+
else:
|
| 317 |
+
structured_output = None
|
| 318 |
+
|
| 319 |
+
return ToolResult(
|
| 320 |
+
content=unstructured_result,
|
| 321 |
+
structured_content=structured_output,
|
| 322 |
+
)
|
| 323 |
finally:
|
| 324 |
_current_tool.reset(token)
|
| 325 |
|
|
|
|
| 333 |
transform_fn: Callable[..., Any] | None = None,
|
| 334 |
transform_args: dict[str, ArgTransform] | None = None,
|
| 335 |
annotations: ToolAnnotations | None = None,
|
| 336 |
+
output_schema: dict[str, Any] | None | Literal[False] = None,
|
| 337 |
serializer: Callable[[Any], str] | None = None,
|
| 338 |
enabled: bool | None = None,
|
| 339 |
) -> TransformedTool:
|
|
|
|
| 353 |
description: New description. Defaults to parent's description.
|
| 354 |
tags: New tags. Defaults to parent's tags.
|
| 355 |
annotations: New annotations. Defaults to parent's annotations.
|
| 356 |
+
output_schema: Control output schema for structured outputs:
|
| 357 |
+
- None (default): Inherit from transform_fn if available, then parent tool
|
| 358 |
+
- dict: Use custom output schema
|
| 359 |
+
- False: Disable output schema and structured outputs
|
| 360 |
serializer: New serializer. Defaults to parent's serializer.
|
| 361 |
|
| 362 |
Returns:
|
|
|
|
| 385 |
|
| 386 |
Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
|
| 387 |
```
|
| 388 |
+
|
| 389 |
+
# Control structured outputs and schemas
|
| 390 |
+
```python
|
| 391 |
+
# Custom output schema
|
| 392 |
+
Tool.from_tool(parent, output_schema={
|
| 393 |
+
"type": "object",
|
| 394 |
+
"properties": {"status": {"type": "string"}}
|
| 395 |
+
})
|
| 396 |
+
|
| 397 |
+
# Disable structured outputs
|
| 398 |
+
Tool.from_tool(parent, output_schema=False)
|
| 399 |
+
|
| 400 |
+
# Return ToolResult for full control
|
| 401 |
+
async def custom_output(**kwargs) -> ToolResult:
|
| 402 |
+
result = await forward(**kwargs)
|
| 403 |
+
return ToolResult(
|
| 404 |
+
content=[TextContent(text="Summary")],
|
| 405 |
+
structured_content={"processed": True}
|
| 406 |
+
)
|
| 407 |
+
```
|
| 408 |
"""
|
| 409 |
transform_args = transform_args or {}
|
| 410 |
|
|
|
|
| 420 |
# Always create the forwarding transform
|
| 421 |
schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
|
| 422 |
|
| 423 |
+
# Handle output schema with smart fallback
|
| 424 |
+
if output_schema is False:
|
| 425 |
+
final_output_schema = None
|
| 426 |
+
elif output_schema is not None:
|
| 427 |
+
# Explicit schema provided - use as-is
|
| 428 |
+
final_output_schema = output_schema
|
| 429 |
+
else:
|
| 430 |
+
# Smart fallback: try custom function, then parent, then None
|
| 431 |
+
if transform_fn is not None:
|
| 432 |
+
parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
|
| 433 |
+
final_output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
|
| 434 |
+
if final_output_schema is None:
|
| 435 |
+
# Check if function returns ToolResult - if so, don't fall back to parent
|
| 436 |
+
import inspect
|
| 437 |
+
|
| 438 |
+
return_annotation = inspect.signature(
|
| 439 |
+
transform_fn
|
| 440 |
+
).return_annotation
|
| 441 |
+
if return_annotation is ToolResult:
|
| 442 |
+
final_output_schema = None
|
| 443 |
+
else:
|
| 444 |
+
final_output_schema = tool.output_schema
|
| 445 |
+
else:
|
| 446 |
+
final_output_schema = tool.output_schema
|
| 447 |
+
|
| 448 |
if transform_fn is None:
|
| 449 |
# User wants pure transformation - use forwarding_fn as the main function
|
| 450 |
final_fn = forwarding_fn
|
| 451 |
final_schema = schema
|
| 452 |
else:
|
| 453 |
# User provided custom function - merge schemas
|
| 454 |
+
if "parsed_fn" not in locals():
|
| 455 |
+
parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
|
| 456 |
final_fn = transform_fn
|
| 457 |
|
| 458 |
has_kwargs = cls._function_has_kwargs(transform_fn)
|
| 459 |
|
| 460 |
# Validate function parameters against transformed schema
|
| 461 |
+
fn_params = set(parsed_fn.input_schema.get("properties", {}).keys())
|
| 462 |
transformed_params = set(schema.get("properties", {}).keys())
|
| 463 |
|
| 464 |
if not has_kwargs:
|
|
|
|
| 475 |
# ArgTransform takes precedence over function signature
|
| 476 |
# Start with function schema as base, then override with transformed schema
|
| 477 |
final_schema = cls._merge_schema_with_precedence(
|
| 478 |
+
parsed_fn.input_schema, schema
|
| 479 |
)
|
| 480 |
else:
|
| 481 |
# With **kwargs, function can access all transformed params
|
|
|
|
| 484 |
|
| 485 |
# Start with function schema as base, then override with transformed schema
|
| 486 |
final_schema = cls._merge_schema_with_precedence(
|
| 487 |
+
parsed_fn.input_schema, schema
|
| 488 |
)
|
| 489 |
|
| 490 |
# Additional validation: check for naming conflicts after transformation
|
|
|
|
| 520 |
name=name or tool.name,
|
| 521 |
description=final_description,
|
| 522 |
parameters=final_schema,
|
| 523 |
+
output_schema=final_output_schema,
|
| 524 |
tags=tags or tool.tags,
|
| 525 |
annotations=annotations or tool.annotations,
|
| 526 |
serializer=serializer or tool.serializer,
|
src/fastmcp/utilities/json_schema_type.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert JSON Schema to Python types with validation.
|
| 2 |
+
|
| 3 |
+
The json_schema_to_type function converts a JSON Schema into a Python type that can be used
|
| 4 |
+
for validation with Pydantic. It supports:
|
| 5 |
+
|
| 6 |
+
- Basic types (string, number, integer, boolean, null)
|
| 7 |
+
- Complex types (arrays, objects)
|
| 8 |
+
- Format constraints (date-time, email, uri)
|
| 9 |
+
- Numeric constraints (minimum, maximum, multipleOf)
|
| 10 |
+
- String constraints (minLength, maxLength, pattern)
|
| 11 |
+
- Array constraints (minItems, maxItems, uniqueItems)
|
| 12 |
+
- Object properties with defaults
|
| 13 |
+
- References and recursive schemas
|
| 14 |
+
- Enums and constants
|
| 15 |
+
- Union types
|
| 16 |
+
|
| 17 |
+
Example:
|
| 18 |
+
```python
|
| 19 |
+
schema = {
|
| 20 |
+
"type": "object",
|
| 21 |
+
"properties": {
|
| 22 |
+
"name": {"type": "string", "minLength": 1},
|
| 23 |
+
"age": {"type": "integer", "minimum": 0},
|
| 24 |
+
"email": {"type": "string", "format": "email"}
|
| 25 |
+
},
|
| 26 |
+
"required": ["name", "age"]
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# Name is optional and will be inferred from schema's "title" property if not provided
|
| 30 |
+
Person = json_schema_to_type(schema)
|
| 31 |
+
# Creates a validated dataclass with name, age, and optional email fields
|
| 32 |
+
```
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import hashlib
|
| 38 |
+
import json
|
| 39 |
+
import re
|
| 40 |
+
from collections.abc import Callable, Mapping
|
| 41 |
+
from copy import deepcopy
|
| 42 |
+
from dataclasses import MISSING, field, make_dataclass
|
| 43 |
+
from datetime import datetime
|
| 44 |
+
from enum import Enum
|
| 45 |
+
from typing import (
|
| 46 |
+
Annotated,
|
| 47 |
+
Any,
|
| 48 |
+
ForwardRef,
|
| 49 |
+
Literal,
|
| 50 |
+
Union,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
from pydantic import (
|
| 54 |
+
AnyUrl,
|
| 55 |
+
BaseModel,
|
| 56 |
+
ConfigDict,
|
| 57 |
+
EmailStr,
|
| 58 |
+
Field,
|
| 59 |
+
Json,
|
| 60 |
+
StringConstraints,
|
| 61 |
+
model_validator,
|
| 62 |
+
)
|
| 63 |
+
from typing_extensions import NotRequired, TypedDict
|
| 64 |
+
|
| 65 |
+
__all__ = ["json_schema_to_type", "JSONSchema"]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
FORMAT_TYPES: dict[str, Any] = {
|
| 69 |
+
"date-time": datetime,
|
| 70 |
+
"email": EmailStr,
|
| 71 |
+
"uri": AnyUrl,
|
| 72 |
+
"json": Json,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
_classes: dict[tuple[str, Any], type | None] = {}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class JSONSchema(TypedDict):
|
| 79 |
+
type: NotRequired[str | list[str]]
|
| 80 |
+
properties: NotRequired[dict[str, JSONSchema]]
|
| 81 |
+
required: NotRequired[list[str]]
|
| 82 |
+
additionalProperties: NotRequired[bool | JSONSchema]
|
| 83 |
+
items: NotRequired[JSONSchema | list[JSONSchema]]
|
| 84 |
+
enum: NotRequired[list[Any]]
|
| 85 |
+
const: NotRequired[Any]
|
| 86 |
+
default: NotRequired[Any]
|
| 87 |
+
description: NotRequired[str]
|
| 88 |
+
title: NotRequired[str]
|
| 89 |
+
examples: NotRequired[list[Any]]
|
| 90 |
+
format: NotRequired[str]
|
| 91 |
+
allOf: NotRequired[list[JSONSchema]]
|
| 92 |
+
anyOf: NotRequired[list[JSONSchema]]
|
| 93 |
+
oneOf: NotRequired[list[JSONSchema]]
|
| 94 |
+
not_: NotRequired[JSONSchema]
|
| 95 |
+
definitions: NotRequired[dict[str, JSONSchema]]
|
| 96 |
+
dependencies: NotRequired[dict[str, JSONSchema | list[str]]]
|
| 97 |
+
pattern: NotRequired[str]
|
| 98 |
+
minLength: NotRequired[int]
|
| 99 |
+
maxLength: NotRequired[int]
|
| 100 |
+
minimum: NotRequired[int | float]
|
| 101 |
+
maximum: NotRequired[int | float]
|
| 102 |
+
exclusiveMinimum: NotRequired[int | float]
|
| 103 |
+
exclusiveMaximum: NotRequired[int | float]
|
| 104 |
+
multipleOf: NotRequired[int | float]
|
| 105 |
+
uniqueItems: NotRequired[bool]
|
| 106 |
+
minItems: NotRequired[int]
|
| 107 |
+
maxItems: NotRequired[int]
|
| 108 |
+
additionalItems: NotRequired[bool | JSONSchema]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def json_schema_to_type(
|
| 112 |
+
schema: Mapping[str, Any],
|
| 113 |
+
name: str | None = None,
|
| 114 |
+
) -> type:
|
| 115 |
+
"""Convert JSON schema to appropriate Python type with validation.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
schema: A JSON Schema dictionary defining the type structure and validation rules
|
| 119 |
+
name: Optional name for object schemas. Only allowed when schema type is "object".
|
| 120 |
+
If not provided for objects, name will be inferred from schema's "title"
|
| 121 |
+
property or default to "Root".
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
A Python type (typically a dataclass for objects) with Pydantic validation
|
| 125 |
+
|
| 126 |
+
Raises:
|
| 127 |
+
ValueError: If a name is provided for a non-object schema
|
| 128 |
+
|
| 129 |
+
Examples:
|
| 130 |
+
Create a dataclass from an object schema:
|
| 131 |
+
```python
|
| 132 |
+
schema = {
|
| 133 |
+
"type": "object",
|
| 134 |
+
"title": "Person",
|
| 135 |
+
"properties": {
|
| 136 |
+
"name": {"type": "string", "minLength": 1},
|
| 137 |
+
"age": {"type": "integer", "minimum": 0},
|
| 138 |
+
"email": {"type": "string", "format": "email"}
|
| 139 |
+
},
|
| 140 |
+
"required": ["name", "age"]
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
Person = json_schema_to_type(schema)
|
| 144 |
+
# Creates a dataclass with name, age, and optional email fields:
|
| 145 |
+
# @dataclass
|
| 146 |
+
# class Person:
|
| 147 |
+
# name: str
|
| 148 |
+
# age: int
|
| 149 |
+
# email: str | None = None
|
| 150 |
+
```
|
| 151 |
+
Person(name="John", age=30)
|
| 152 |
+
|
| 153 |
+
Create a scalar type with constraints:
|
| 154 |
+
```python
|
| 155 |
+
schema = {
|
| 156 |
+
"type": "string",
|
| 157 |
+
"minLength": 3,
|
| 158 |
+
"pattern": "^[A-Z][a-z]+$"
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
NameType = json_schema_to_type(schema)
|
| 162 |
+
# Creates Annotated[str, StringConstraints(min_length=3, pattern="^[A-Z][a-z]+$")]
|
| 163 |
+
|
| 164 |
+
@dataclass
|
| 165 |
+
class Name:
|
| 166 |
+
name: NameType
|
| 167 |
+
```
|
| 168 |
+
"""
|
| 169 |
+
# Always use the top-level schema for references
|
| 170 |
+
if schema.get("type") == "object":
|
| 171 |
+
# If no properties defined but has additionalProperties, return typed dict
|
| 172 |
+
if not schema.get("properties") and schema.get("additionalProperties"):
|
| 173 |
+
additional_props = schema["additionalProperties"]
|
| 174 |
+
if additional_props is True:
|
| 175 |
+
return dict[str, Any] # type: ignore - additionalProperties: true means dict[str, Any]
|
| 176 |
+
else:
|
| 177 |
+
# Handle typed dictionaries like dict[str, str]
|
| 178 |
+
value_type = _schema_to_type(additional_props, schemas=schema)
|
| 179 |
+
return dict[str, value_type] # type: ignore
|
| 180 |
+
# If no properties and no additionalProperties, default to dict[str, Any] for safety
|
| 181 |
+
elif not schema.get("properties") and not schema.get("additionalProperties"):
|
| 182 |
+
return dict[str, Any] # type: ignore
|
| 183 |
+
# If has properties AND additionalProperties is True, use Pydantic BaseModel
|
| 184 |
+
elif schema.get("properties") and schema.get("additionalProperties") is True:
|
| 185 |
+
return _create_pydantic_model(schema, name, schemas=schema)
|
| 186 |
+
# Otherwise use fast dataclass
|
| 187 |
+
return _create_dataclass(schema, name, schemas=schema)
|
| 188 |
+
elif name:
|
| 189 |
+
raise ValueError(f"Can not apply name to non-object schema: {name}")
|
| 190 |
+
result = _schema_to_type(schema, schemas=schema)
|
| 191 |
+
return result # type: ignore[return-value]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _hash_schema(schema: Mapping[str, Any]) -> str:
|
| 195 |
+
"""Generate a deterministic hash for schema caching."""
|
| 196 |
+
return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _resolve_ref(ref: str, schemas: Mapping[str, Any]) -> Mapping[str, Any]:
|
| 200 |
+
"""Resolve JSON Schema reference to target schema."""
|
| 201 |
+
path = ref.replace("#/", "").split("/")
|
| 202 |
+
current = schemas
|
| 203 |
+
for part in path:
|
| 204 |
+
current = current.get(part, {})
|
| 205 |
+
return current
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]:
|
| 209 |
+
"""Create string type with optional constraints."""
|
| 210 |
+
if "const" in schema:
|
| 211 |
+
return Literal[schema["const"]] # type: ignore
|
| 212 |
+
|
| 213 |
+
if fmt := schema.get("format"):
|
| 214 |
+
if fmt == "uri":
|
| 215 |
+
return AnyUrl
|
| 216 |
+
elif fmt == "uri-reference":
|
| 217 |
+
return str
|
| 218 |
+
return FORMAT_TYPES.get(fmt, str)
|
| 219 |
+
|
| 220 |
+
constraints = {
|
| 221 |
+
k: v
|
| 222 |
+
for k, v in {
|
| 223 |
+
"min_length": schema.get("minLength"),
|
| 224 |
+
"max_length": schema.get("maxLength"),
|
| 225 |
+
"pattern": schema.get("pattern"),
|
| 226 |
+
}.items()
|
| 227 |
+
if v is not None
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
return Annotated[str, StringConstraints(**constraints)] if constraints else str
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _create_numeric_type(
|
| 234 |
+
base: type[int | float], schema: Mapping[str, Any]
|
| 235 |
+
) -> type | Annotated[Any, ...]:
|
| 236 |
+
"""Create numeric type with optional constraints."""
|
| 237 |
+
if "const" in schema:
|
| 238 |
+
return Literal[schema["const"]] # type: ignore
|
| 239 |
+
|
| 240 |
+
constraints = {
|
| 241 |
+
k: v
|
| 242 |
+
for k, v in {
|
| 243 |
+
"gt": schema.get("exclusiveMinimum"),
|
| 244 |
+
"ge": schema.get("minimum"),
|
| 245 |
+
"lt": schema.get("exclusiveMaximum"),
|
| 246 |
+
"le": schema.get("maximum"),
|
| 247 |
+
"multiple_of": schema.get("multipleOf"),
|
| 248 |
+
}.items()
|
| 249 |
+
if v is not None
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
return Annotated[base, Field(**constraints)] if constraints else base
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _create_enum(name: str, values: list[Any]) -> type:
|
| 256 |
+
"""Create enum type from list of values."""
|
| 257 |
+
if all(isinstance(v, str) for v in values):
|
| 258 |
+
return Enum(name, {v.upper(): v for v in values}) # type: ignore[return-value]
|
| 259 |
+
return Literal[tuple(values)] # type: ignore[return-value]
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _create_array_type(
|
| 263 |
+
schema: Mapping[str, Any], schemas: Mapping[str, Any]
|
| 264 |
+
) -> type | Annotated[Any, ...]:
|
| 265 |
+
"""Create list/set type with optional constraints."""
|
| 266 |
+
items = schema.get("items", {})
|
| 267 |
+
if isinstance(items, list):
|
| 268 |
+
# Handle positional item schemas
|
| 269 |
+
item_types = [_schema_to_type(s, schemas) for s in items]
|
| 270 |
+
combined = Union[tuple(item_types)] # type: ignore # noqa: UP007
|
| 271 |
+
base = list[combined]
|
| 272 |
+
else:
|
| 273 |
+
# Handle single item schema
|
| 274 |
+
item_type = _schema_to_type(items, schemas)
|
| 275 |
+
base_class = set if schema.get("uniqueItems") else list
|
| 276 |
+
base = base_class[item_type] # type: ignore[misc]
|
| 277 |
+
|
| 278 |
+
constraints = {
|
| 279 |
+
k: v
|
| 280 |
+
for k, v in {
|
| 281 |
+
"min_length": schema.get("minItems"),
|
| 282 |
+
"max_length": schema.get("maxItems"),
|
| 283 |
+
}.items()
|
| 284 |
+
if v is not None
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
return Annotated[base, Field(**constraints)] if constraints else base
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _return_Any() -> Any:
|
| 291 |
+
return Any
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _get_from_type_handler(
|
| 295 |
+
schema: Mapping[str, Any], schemas: Mapping[str, Any]
|
| 296 |
+
) -> Callable[..., Any]:
|
| 297 |
+
"""Get the appropriate type handler for the schema."""
|
| 298 |
+
|
| 299 |
+
type_handlers: dict[str, Callable[..., Any]] = { # TODO
|
| 300 |
+
"string": lambda s: _create_string_type(s), # type: ignore
|
| 301 |
+
"integer": lambda s: _create_numeric_type(int, s), # type: ignore
|
| 302 |
+
"number": lambda s: _create_numeric_type(float, s), # type: ignore
|
| 303 |
+
"boolean": lambda _: bool, # type: ignore
|
| 304 |
+
"null": lambda _: type(None), # type: ignore
|
| 305 |
+
"array": lambda s: _create_array_type(s, schemas), # type: ignore
|
| 306 |
+
"object": lambda s: (
|
| 307 |
+
_create_pydantic_model(s, s.get("title"), schemas)
|
| 308 |
+
if s.get("properties") and s.get("additionalProperties") is True
|
| 309 |
+
else _create_dataclass(s, s.get("title"), schemas)
|
| 310 |
+
), # type: ignore
|
| 311 |
+
}
|
| 312 |
+
return type_handlers.get(schema.get("type", None), _return_Any)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _schema_to_type(
|
| 316 |
+
schema: Mapping[str, Any],
|
| 317 |
+
schemas: Mapping[str, Any],
|
| 318 |
+
) -> type | ForwardRef:
|
| 319 |
+
"""Convert schema to appropriate Python type."""
|
| 320 |
+
if not schema:
|
| 321 |
+
return object
|
| 322 |
+
|
| 323 |
+
if "type" not in schema and "properties" in schema:
|
| 324 |
+
return _create_dataclass(schema, schema.get("title", "<unknown>"), schemas)
|
| 325 |
+
|
| 326 |
+
# Handle references first
|
| 327 |
+
if "$ref" in schema:
|
| 328 |
+
ref = schema["$ref"]
|
| 329 |
+
# Handle self-reference
|
| 330 |
+
if ref == "#":
|
| 331 |
+
return ForwardRef(schema.get("title", "Root")) # type: ignore[return-value]
|
| 332 |
+
return _schema_to_type(_resolve_ref(ref, schemas), schemas)
|
| 333 |
+
|
| 334 |
+
if "const" in schema:
|
| 335 |
+
return Literal[schema["const"]] # type: ignore
|
| 336 |
+
|
| 337 |
+
if "enum" in schema:
|
| 338 |
+
return _create_enum(f"Enum_{len(_classes)}", schema["enum"])
|
| 339 |
+
|
| 340 |
+
# Handle anyOf unions
|
| 341 |
+
if "anyOf" in schema:
|
| 342 |
+
types: list[type | Any] = []
|
| 343 |
+
for subschema in schema["anyOf"]:
|
| 344 |
+
# Special handling for dict-like objects in unions
|
| 345 |
+
if (
|
| 346 |
+
subschema.get("type") == "object"
|
| 347 |
+
and not subschema.get("properties")
|
| 348 |
+
and subschema.get("additionalProperties")
|
| 349 |
+
):
|
| 350 |
+
# This is a dict type, handle it directly
|
| 351 |
+
additional_props = subschema["additionalProperties"]
|
| 352 |
+
if additional_props is True:
|
| 353 |
+
types.append(dict[str, Any]) # type: ignore
|
| 354 |
+
else:
|
| 355 |
+
value_type = _schema_to_type(additional_props, schemas)
|
| 356 |
+
types.append(dict[str, value_type]) # type: ignore
|
| 357 |
+
else:
|
| 358 |
+
types.append(_schema_to_type(subschema, schemas))
|
| 359 |
+
|
| 360 |
+
# Check if one of the types is None (null)
|
| 361 |
+
has_null = type(None) in types
|
| 362 |
+
types = [t for t in types if t is not type(None)]
|
| 363 |
+
|
| 364 |
+
if len(types) == 0:
|
| 365 |
+
return type(None)
|
| 366 |
+
elif len(types) == 1:
|
| 367 |
+
if has_null:
|
| 368 |
+
return types[0] | None # type: ignore
|
| 369 |
+
else:
|
| 370 |
+
return types[0]
|
| 371 |
+
else:
|
| 372 |
+
if has_null:
|
| 373 |
+
return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007
|
| 374 |
+
else:
|
| 375 |
+
return Union[tuple(types)] # type: ignore # noqa: UP007
|
| 376 |
+
|
| 377 |
+
schema_type = schema.get("type")
|
| 378 |
+
if not schema_type:
|
| 379 |
+
return Any # type: ignore[return-value]
|
| 380 |
+
|
| 381 |
+
if isinstance(schema_type, list):
|
| 382 |
+
# Create a copy of the schema for each type, but keep all constraints
|
| 383 |
+
types: list[type | Any] = []
|
| 384 |
+
for t in schema_type:
|
| 385 |
+
type_schema = dict(schema)
|
| 386 |
+
type_schema["type"] = t
|
| 387 |
+
types.append(_schema_to_type(type_schema, schemas))
|
| 388 |
+
has_null = type(None) in types
|
| 389 |
+
types = [t for t in types if t is not type(None)]
|
| 390 |
+
if has_null:
|
| 391 |
+
if len(types) == 1:
|
| 392 |
+
return types[0] | None # type: ignore
|
| 393 |
+
else:
|
| 394 |
+
return Union[tuple(types + [type(None)])] # type: ignore # noqa: UP007
|
| 395 |
+
return Union[tuple(types)] # type: ignore # noqa: UP007
|
| 396 |
+
|
| 397 |
+
return _get_from_type_handler(schema, schemas)(schema)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _sanitize_name(name: str) -> str:
|
| 401 |
+
"""Convert string to valid Python identifier."""
|
| 402 |
+
# Step 1: replace everything except [0-9a-zA-Z_] with underscores
|
| 403 |
+
cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
|
| 404 |
+
# Step 2: deduplicate underscores
|
| 405 |
+
cleaned = re.sub(r"__+", "_", cleaned)
|
| 406 |
+
# Step 3: if the first char of original name isn't a letter, prepend field_
|
| 407 |
+
if not name or not re.match(r"[a-zA-Z]", name[0]):
|
| 408 |
+
cleaned = f"field_{cleaned}"
|
| 409 |
+
# Step 4: deduplicate again and strip trailing underscores
|
| 410 |
+
cleaned = re.sub(r"__+", "_", cleaned).strip("_")
|
| 411 |
+
return cleaned
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _get_default_value(
|
| 415 |
+
schema: dict[str, Any],
|
| 416 |
+
prop_name: str,
|
| 417 |
+
parent_default: dict[str, Any] | None = None,
|
| 418 |
+
) -> Any:
|
| 419 |
+
"""Get default value with proper priority ordering.
|
| 420 |
+
1. Value from parent's default if it exists
|
| 421 |
+
2. Property's own default if it exists
|
| 422 |
+
3. None
|
| 423 |
+
"""
|
| 424 |
+
if parent_default is not None and prop_name in parent_default:
|
| 425 |
+
return parent_default[prop_name]
|
| 426 |
+
return schema.get("default")
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _create_field_with_default(
|
| 430 |
+
field_type: type,
|
| 431 |
+
default_value: Any,
|
| 432 |
+
schema: dict[str, Any],
|
| 433 |
+
) -> Any:
|
| 434 |
+
"""Create a field with simplified default handling."""
|
| 435 |
+
# Always use None as default for complex types
|
| 436 |
+
if isinstance(default_value, dict | list) or default_value is None:
|
| 437 |
+
return field(default=None)
|
| 438 |
+
|
| 439 |
+
# For simple types, use the value directly
|
| 440 |
+
return field(default=default_value)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _create_pydantic_model(
|
| 444 |
+
schema: Mapping[str, Any],
|
| 445 |
+
name: str | None = None,
|
| 446 |
+
schemas: Mapping[str, Any] | None = None,
|
| 447 |
+
) -> type:
|
| 448 |
+
"""Create Pydantic BaseModel from object schema with additionalProperties."""
|
| 449 |
+
name = name or schema.get("title", "Root")
|
| 450 |
+
assert name is not None # Should not be None after the or operation
|
| 451 |
+
sanitized_name = _sanitize_name(name)
|
| 452 |
+
schema_hash = _hash_schema(schema)
|
| 453 |
+
cache_key = (schema_hash, sanitized_name)
|
| 454 |
+
|
| 455 |
+
# Return existing class if already built
|
| 456 |
+
if cache_key in _classes:
|
| 457 |
+
existing = _classes[cache_key]
|
| 458 |
+
if existing is None:
|
| 459 |
+
return ForwardRef(sanitized_name) # type: ignore[return-value]
|
| 460 |
+
return existing
|
| 461 |
+
|
| 462 |
+
# Place placeholder for recursive references
|
| 463 |
+
_classes[cache_key] = None
|
| 464 |
+
|
| 465 |
+
properties = schema.get("properties", {})
|
| 466 |
+
required = schema.get("required", [])
|
| 467 |
+
|
| 468 |
+
# Build field annotations and defaults
|
| 469 |
+
annotations = {}
|
| 470 |
+
defaults = {}
|
| 471 |
+
|
| 472 |
+
for prop_name, prop_schema in properties.items():
|
| 473 |
+
field_type = _schema_to_type(prop_schema, schemas or {})
|
| 474 |
+
|
| 475 |
+
# Handle defaults
|
| 476 |
+
default_value = prop_schema.get("default", MISSING)
|
| 477 |
+
if default_value is not MISSING:
|
| 478 |
+
defaults[prop_name] = default_value
|
| 479 |
+
annotations[prop_name] = field_type
|
| 480 |
+
elif prop_name in required:
|
| 481 |
+
annotations[prop_name] = field_type
|
| 482 |
+
else:
|
| 483 |
+
annotations[prop_name] = Union[field_type, type(None)] # type: ignore[misc] # noqa: UP007
|
| 484 |
+
defaults[prop_name] = None
|
| 485 |
+
|
| 486 |
+
# Create Pydantic model class
|
| 487 |
+
cls_dict = {
|
| 488 |
+
"__annotations__": annotations,
|
| 489 |
+
"model_config": ConfigDict(extra="allow"),
|
| 490 |
+
**defaults,
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
cls = type(sanitized_name, (BaseModel,), cls_dict)
|
| 494 |
+
|
| 495 |
+
# Store completed class
|
| 496 |
+
_classes[cache_key] = cls
|
| 497 |
+
return cls
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _create_dataclass(
|
| 501 |
+
schema: Mapping[str, Any],
|
| 502 |
+
name: str | None = None,
|
| 503 |
+
schemas: Mapping[str, Any] | None = None,
|
| 504 |
+
) -> type:
|
| 505 |
+
"""Create dataclass from object schema."""
|
| 506 |
+
name = name or schema.get("title", "Root")
|
| 507 |
+
# Sanitize name for class creation
|
| 508 |
+
assert name is not None # Should not be None after the or operation
|
| 509 |
+
sanitized_name = _sanitize_name(name)
|
| 510 |
+
schema_hash = _hash_schema(schema)
|
| 511 |
+
cache_key = (schema_hash, sanitized_name)
|
| 512 |
+
original_schema = dict(schema) # Store copy for validator
|
| 513 |
+
|
| 514 |
+
# Return existing class if already built
|
| 515 |
+
if cache_key in _classes:
|
| 516 |
+
existing = _classes[cache_key]
|
| 517 |
+
if existing is None:
|
| 518 |
+
return ForwardRef(sanitized_name) # type: ignore[return-value]
|
| 519 |
+
return existing
|
| 520 |
+
|
| 521 |
+
# Place placeholder for recursive references
|
| 522 |
+
_classes[cache_key] = None
|
| 523 |
+
|
| 524 |
+
if "$ref" in schema:
|
| 525 |
+
ref = schema["$ref"]
|
| 526 |
+
if ref == "#":
|
| 527 |
+
return ForwardRef(sanitized_name) # type: ignore[return-value]
|
| 528 |
+
schema = _resolve_ref(ref, schemas or {})
|
| 529 |
+
|
| 530 |
+
properties = schema.get("properties", {})
|
| 531 |
+
required = schema.get("required", [])
|
| 532 |
+
|
| 533 |
+
fields: list[tuple[Any, ...]] = []
|
| 534 |
+
for prop_name, prop_schema in properties.items():
|
| 535 |
+
field_name = _sanitize_name(prop_name)
|
| 536 |
+
|
| 537 |
+
# Check for self-reference in property
|
| 538 |
+
if prop_schema.get("$ref") == "#":
|
| 539 |
+
field_type = ForwardRef(sanitized_name)
|
| 540 |
+
else:
|
| 541 |
+
field_type = _schema_to_type(prop_schema, schemas or {})
|
| 542 |
+
|
| 543 |
+
default_val = prop_schema.get("default", MISSING)
|
| 544 |
+
is_required = prop_name in required
|
| 545 |
+
|
| 546 |
+
# Include alias in field metadata
|
| 547 |
+
meta = {"alias": prop_name}
|
| 548 |
+
|
| 549 |
+
if default_val is not MISSING:
|
| 550 |
+
if isinstance(default_val, dict | list):
|
| 551 |
+
field_def = field(
|
| 552 |
+
default_factory=lambda d=default_val: deepcopy(d), metadata=meta
|
| 553 |
+
)
|
| 554 |
+
else:
|
| 555 |
+
field_def = field(default=default_val, metadata=meta)
|
| 556 |
+
else:
|
| 557 |
+
if is_required:
|
| 558 |
+
field_def = field(metadata=meta)
|
| 559 |
+
else:
|
| 560 |
+
field_def = field(default=None, metadata=meta)
|
| 561 |
+
|
| 562 |
+
if is_required and default_val is not MISSING:
|
| 563 |
+
fields.append((field_name, field_type, field_def))
|
| 564 |
+
elif is_required:
|
| 565 |
+
fields.append((field_name, field_type, field_def))
|
| 566 |
+
else:
|
| 567 |
+
fields.append((field_name, Union[field_type, type(None)], field_def)) # type: ignore[misc] # noqa: UP007
|
| 568 |
+
|
| 569 |
+
cls = make_dataclass(sanitized_name, fields, kw_only=True)
|
| 570 |
+
|
| 571 |
+
# Add model validator for defaults
|
| 572 |
+
@model_validator(mode="before")
|
| 573 |
+
@classmethod
|
| 574 |
+
def _apply_defaults(cls, data: Mapping[str, Any]):
|
| 575 |
+
if isinstance(data, dict):
|
| 576 |
+
return _merge_defaults(data, original_schema)
|
| 577 |
+
return data
|
| 578 |
+
|
| 579 |
+
setattr(cls, "_apply_defaults", _apply_defaults)
|
| 580 |
+
|
| 581 |
+
# Store completed class
|
| 582 |
+
_classes[cache_key] = cls
|
| 583 |
+
return cls
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def _merge_defaults(
|
| 587 |
+
data: Mapping[str, Any],
|
| 588 |
+
schema: Mapping[str, Any],
|
| 589 |
+
parent_default: Mapping[str, Any] | None = None,
|
| 590 |
+
) -> dict[str, Any]:
|
| 591 |
+
"""Merge defaults with provided data at all levels."""
|
| 592 |
+
# If we have no data
|
| 593 |
+
if not data:
|
| 594 |
+
# Start with parent default if available
|
| 595 |
+
if parent_default:
|
| 596 |
+
result = dict(parent_default)
|
| 597 |
+
# Otherwise use schema default if available
|
| 598 |
+
elif "default" in schema:
|
| 599 |
+
result = dict(schema["default"])
|
| 600 |
+
# Otherwise start empty
|
| 601 |
+
else:
|
| 602 |
+
result = {}
|
| 603 |
+
# If we have data and a parent default, merge them
|
| 604 |
+
elif parent_default:
|
| 605 |
+
result = dict(parent_default)
|
| 606 |
+
for key, value in data.items():
|
| 607 |
+
if (
|
| 608 |
+
isinstance(value, dict)
|
| 609 |
+
and key in result
|
| 610 |
+
and isinstance(result[key], dict)
|
| 611 |
+
):
|
| 612 |
+
# recursively merge nested dicts
|
| 613 |
+
result[key] = _merge_defaults(value, {"properties": {}}, result[key])
|
| 614 |
+
else:
|
| 615 |
+
result[key] = value
|
| 616 |
+
# Otherwise just use the data
|
| 617 |
+
else:
|
| 618 |
+
result = dict(data)
|
| 619 |
+
|
| 620 |
+
# For each property in the schema
|
| 621 |
+
for prop_name, prop_schema in schema.get("properties", {}).items():
|
| 622 |
+
# If property is missing, apply defaults in priority order
|
| 623 |
+
if prop_name not in result:
|
| 624 |
+
if parent_default and prop_name in parent_default:
|
| 625 |
+
result[prop_name] = parent_default[prop_name]
|
| 626 |
+
elif "default" in prop_schema:
|
| 627 |
+
result[prop_name] = prop_schema["default"]
|
| 628 |
+
|
| 629 |
+
# If property exists and is an object, recursively merge
|
| 630 |
+
if (
|
| 631 |
+
prop_name in result
|
| 632 |
+
and isinstance(result[prop_name], dict)
|
| 633 |
+
and prop_schema.get("type") == "object"
|
| 634 |
+
):
|
| 635 |
+
# Get the appropriate default for this nested object
|
| 636 |
+
nested_default = None
|
| 637 |
+
if parent_default and prop_name in parent_default:
|
| 638 |
+
nested_default = parent_default[prop_name]
|
| 639 |
+
elif "default" in prop_schema:
|
| 640 |
+
nested_default = prop_schema["default"]
|
| 641 |
+
|
| 642 |
+
result[prop_name] = _merge_defaults(
|
| 643 |
+
result[prop_name], prop_schema, nested_default
|
| 644 |
+
)
|
| 645 |
+
|
| 646 |
+
return result
|
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -84,6 +84,7 @@ class HTTPRoute(FastMCPBaseModel):
|
|
| 84 |
schema_definitions: dict[str, JsonSchema] = Field(
|
| 85 |
default_factory=dict
|
| 86 |
) # Store component schemas
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
# Export public symbols
|
|
@@ -591,6 +592,14 @@ class OpenAPIParser(
|
|
| 591 |
getattr(operation, "responses", None)
|
| 592 |
)
|
| 593 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
route = HTTPRoute(
|
| 595 |
path=path_str,
|
| 596 |
method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
|
|
@@ -602,6 +611,7 @@ class OpenAPIParser(
|
|
| 602 |
request_body=request_body_info,
|
| 603 |
responses=responses,
|
| 604 |
schema_definitions=schema_definitions,
|
|
|
|
| 605 |
)
|
| 606 |
routes.append(route)
|
| 607 |
logger.info(
|
|
|
|
| 84 |
schema_definitions: dict[str, JsonSchema] = Field(
|
| 85 |
default_factory=dict
|
| 86 |
) # Store component schemas
|
| 87 |
+
extensions: dict[str, Any] = Field(default_factory=dict)
|
| 88 |
|
| 89 |
|
| 90 |
# Export public symbols
|
|
|
|
| 592 |
getattr(operation, "responses", None)
|
| 593 |
)
|
| 594 |
|
| 595 |
+
extensions = {}
|
| 596 |
+
if hasattr(operation, "model_extra") and operation.model_extra:
|
| 597 |
+
extensions = {
|
| 598 |
+
k: v
|
| 599 |
+
for k, v in operation.model_extra.items()
|
| 600 |
+
if k.startswith("x-")
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
route = HTTPRoute(
|
| 604 |
path=path_str,
|
| 605 |
method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
|
|
|
|
| 611 |
request_body=request_body_info,
|
| 612 |
responses=responses,
|
| 613 |
schema_definitions=schema_definitions,
|
| 614 |
+
extensions=extensions,
|
| 615 |
)
|
| 616 |
routes.append(route)
|
| 617 |
logger.info(
|
src/fastmcp/utilities/types.py
CHANGED
|
@@ -6,21 +6,19 @@ import mimetypes
|
|
| 6 |
from collections.abc import Callable
|
| 7 |
from functools import lru_cache
|
| 8 |
from pathlib import Path
|
| 9 |
-
from types import UnionType
|
| 10 |
-
from typing import Annotated, TypeVar, Union, get_args, get_origin
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
AudioContent,
|
| 15 |
-
BlobResourceContents,
|
| 16 |
-
EmbeddedResource,
|
| 17 |
-
ImageContent,
|
| 18 |
-
TextResourceContents,
|
| 19 |
-
)
|
| 20 |
from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
|
| 21 |
|
| 22 |
T = TypeVar("T")
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
class FastMCPBaseModel(BaseModel):
|
| 26 |
"""Base model for FastMCP models."""
|
|
@@ -129,7 +127,7 @@ class Image:
|
|
| 129 |
self,
|
| 130 |
mime_type: str | None = None,
|
| 131 |
annotations: Annotations | None = None,
|
| 132 |
-
) -> ImageContent:
|
| 133 |
"""Convert to MCP ImageContent."""
|
| 134 |
if self.path:
|
| 135 |
with open(self.path, "rb") as f:
|
|
@@ -139,7 +137,7 @@ class Image:
|
|
| 139 |
else:
|
| 140 |
raise ValueError("No image data available")
|
| 141 |
|
| 142 |
-
return ImageContent(
|
| 143 |
type="image",
|
| 144 |
data=data,
|
| 145 |
mimeType=mime_type or self._mime_type,
|
|
@@ -188,7 +186,7 @@ class Audio:
|
|
| 188 |
self,
|
| 189 |
mime_type: str | None = None,
|
| 190 |
annotations: Annotations | None = None,
|
| 191 |
-
) -> AudioContent:
|
| 192 |
if self.path:
|
| 193 |
with open(self.path, "rb") as f:
|
| 194 |
data = base64.b64encode(f.read()).decode()
|
|
@@ -197,7 +195,7 @@ class Audio:
|
|
| 197 |
else:
|
| 198 |
raise ValueError("No audio data available")
|
| 199 |
|
| 200 |
-
return AudioContent(
|
| 201 |
type="audio",
|
| 202 |
data=data,
|
| 203 |
mimeType=mime_type or self._mime_type,
|
|
@@ -248,7 +246,7 @@ class File:
|
|
| 248 |
self,
|
| 249 |
mime_type: str | None = None,
|
| 250 |
annotations: Annotations | None = None,
|
| 251 |
-
) -> EmbeddedResource:
|
| 252 |
if self.path:
|
| 253 |
with open(self.path, "rb") as f:
|
| 254 |
raw_data = f.read()
|
|
@@ -271,21 +269,57 @@ class File:
|
|
| 271 |
text = raw_data.decode("utf-8")
|
| 272 |
except UnicodeDecodeError:
|
| 273 |
text = raw_data.decode("latin-1")
|
| 274 |
-
resource = TextResourceContents(
|
| 275 |
text=text,
|
| 276 |
mimeType=mime,
|
| 277 |
uri=uri,
|
| 278 |
)
|
| 279 |
else:
|
| 280 |
data = base64.b64encode(raw_data).decode()
|
| 281 |
-
resource = BlobResourceContents(
|
| 282 |
blob=data,
|
| 283 |
mimeType=mime,
|
| 284 |
uri=uri,
|
| 285 |
)
|
| 286 |
|
| 287 |
-
return EmbeddedResource(
|
| 288 |
type="resource",
|
| 289 |
resource=resource,
|
| 290 |
annotations=annotations or self.annotations,
|
| 291 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from collections.abc import Callable
|
| 7 |
from functools import lru_cache
|
| 8 |
from pathlib import Path
|
| 9 |
+
from types import EllipsisType, UnionType
|
| 10 |
+
from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin
|
| 11 |
+
|
| 12 |
+
import mcp.types
|
| 13 |
+
from mcp.types import Annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
|
| 15 |
|
| 16 |
T = TypeVar("T")
|
| 17 |
|
| 18 |
+
# sentinel values for optional arguments
|
| 19 |
+
NotSet = ...
|
| 20 |
+
NotSetT: TypeAlias = EllipsisType
|
| 21 |
+
|
| 22 |
|
| 23 |
class FastMCPBaseModel(BaseModel):
|
| 24 |
"""Base model for FastMCP models."""
|
|
|
|
| 127 |
self,
|
| 128 |
mime_type: str | None = None,
|
| 129 |
annotations: Annotations | None = None,
|
| 130 |
+
) -> mcp.types.ImageContent:
|
| 131 |
"""Convert to MCP ImageContent."""
|
| 132 |
if self.path:
|
| 133 |
with open(self.path, "rb") as f:
|
|
|
|
| 137 |
else:
|
| 138 |
raise ValueError("No image data available")
|
| 139 |
|
| 140 |
+
return mcp.types.ImageContent(
|
| 141 |
type="image",
|
| 142 |
data=data,
|
| 143 |
mimeType=mime_type or self._mime_type,
|
|
|
|
| 186 |
self,
|
| 187 |
mime_type: str | None = None,
|
| 188 |
annotations: Annotations | None = None,
|
| 189 |
+
) -> mcp.types.AudioContent:
|
| 190 |
if self.path:
|
| 191 |
with open(self.path, "rb") as f:
|
| 192 |
data = base64.b64encode(f.read()).decode()
|
|
|
|
| 195 |
else:
|
| 196 |
raise ValueError("No audio data available")
|
| 197 |
|
| 198 |
+
return mcp.types.AudioContent(
|
| 199 |
type="audio",
|
| 200 |
data=data,
|
| 201 |
mimeType=mime_type or self._mime_type,
|
|
|
|
| 246 |
self,
|
| 247 |
mime_type: str | None = None,
|
| 248 |
annotations: Annotations | None = None,
|
| 249 |
+
) -> mcp.types.EmbeddedResource:
|
| 250 |
if self.path:
|
| 251 |
with open(self.path, "rb") as f:
|
| 252 |
raw_data = f.read()
|
|
|
|
| 269 |
text = raw_data.decode("utf-8")
|
| 270 |
except UnicodeDecodeError:
|
| 271 |
text = raw_data.decode("latin-1")
|
| 272 |
+
resource = mcp.types.TextResourceContents(
|
| 273 |
text=text,
|
| 274 |
mimeType=mime,
|
| 275 |
uri=uri,
|
| 276 |
)
|
| 277 |
else:
|
| 278 |
data = base64.b64encode(raw_data).decode()
|
| 279 |
+
resource = mcp.types.BlobResourceContents(
|
| 280 |
blob=data,
|
| 281 |
mimeType=mime,
|
| 282 |
uri=uri,
|
| 283 |
)
|
| 284 |
|
| 285 |
+
return mcp.types.EmbeddedResource(
|
| 286 |
type="resource",
|
| 287 |
resource=resource,
|
| 288 |
annotations=annotations or self.annotations,
|
| 289 |
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def replace_type(type_, type_map: dict[type, type]):
|
| 293 |
+
"""
|
| 294 |
+
Given a (possibly generic, nested, or otherwise complex) type, replaces all
|
| 295 |
+
instances of old_type with new_type.
|
| 296 |
+
|
| 297 |
+
This is useful for transforming types when creating tools.
|
| 298 |
+
|
| 299 |
+
Args:
|
| 300 |
+
type_: The type to replace instances of old_type with new_type.
|
| 301 |
+
old_type: The type to replace.
|
| 302 |
+
new_type: The type to replace old_type with.
|
| 303 |
+
|
| 304 |
+
Examples:
|
| 305 |
+
>>> replace_type(list[int | bool], {int: str})
|
| 306 |
+
list[str | bool]
|
| 307 |
+
|
| 308 |
+
>>> replace_type(list[list[int]], {int: str})
|
| 309 |
+
list[list[str]]
|
| 310 |
+
|
| 311 |
+
"""
|
| 312 |
+
if type_ in type_map:
|
| 313 |
+
return type_map[type_]
|
| 314 |
+
|
| 315 |
+
origin = get_origin(type_)
|
| 316 |
+
if not origin:
|
| 317 |
+
return type_
|
| 318 |
+
|
| 319 |
+
args = get_args(type_)
|
| 320 |
+
new_args = tuple(replace_type(arg, type_map) for arg in args)
|
| 321 |
+
|
| 322 |
+
if origin is UnionType:
|
| 323 |
+
return Union[new_args] # type: ignore # noqa: UP007
|
| 324 |
+
else:
|
| 325 |
+
return origin[new_args]
|
tests/auth/test_oauth_client.py
CHANGED
|
@@ -226,7 +226,9 @@ async def test_call_tool(client_with_headless_oauth: Client):
|
|
| 226 |
"""Test that we can call a tool."""
|
| 227 |
async with client_with_headless_oauth:
|
| 228 |
result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
|
| 229 |
-
|
|
|
|
|
|
|
| 230 |
|
| 231 |
|
| 232 |
async def test_list_resources(client_with_headless_oauth: Client):
|
|
|
|
| 226 |
"""Test that we can call a tool."""
|
| 227 |
async with client_with_headless_oauth:
|
| 228 |
result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
|
| 229 |
+
# The add tool returns int which gets wrapped as structured output
|
| 230 |
+
# Client unwraps it and puts the actual int in the data field
|
| 231 |
+
assert result.data == 8
|
| 232 |
|
| 233 |
|
| 234 |
async def test_list_resources(client_with_headless_oauth: Client):
|
tests/client/test_client.py
CHANGED
|
@@ -121,9 +121,10 @@ async def test_call_tool(fastmcp_server):
|
|
| 121 |
async with client:
|
| 122 |
result = await client.call_tool("greet", {"name": "World"})
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
assert "Hello, World!"
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
async def test_call_tool_mcp(fastmcp_server):
|
|
|
|
| 121 |
async with client:
|
| 122 |
result = await client.call_tool("greet", {"name": "World"})
|
| 123 |
|
| 124 |
+
assert result.content[0].text == "Hello, World!" # type: ignore[attr-defined]
|
| 125 |
+
assert result.structured_content == {"result": "Hello, World!"}
|
| 126 |
+
assert result.data == "Hello, World!"
|
| 127 |
+
assert result.is_error is False
|
| 128 |
|
| 129 |
|
| 130 |
async def test_call_tool_mcp(fastmcp_server):
|
tests/client/test_notifications.py
CHANGED
|
@@ -126,7 +126,7 @@ class TestToolNotifications:
|
|
| 126 |
|
| 127 |
# Enable the target tool
|
| 128 |
result = await client.call_tool("enable_target_tool", {})
|
| 129 |
-
assert result
|
| 130 |
|
| 131 |
# Check that notification was sent
|
| 132 |
recording_message_handler.assert_notification_sent(
|
|
@@ -147,7 +147,7 @@ class TestToolNotifications:
|
|
| 147 |
|
| 148 |
# Disable the target tool
|
| 149 |
result = await client.call_tool("disable_target_tool", {})
|
| 150 |
-
assert result
|
| 151 |
|
| 152 |
# Check that notification was sent
|
| 153 |
recording_message_handler.assert_notification_sent(
|
|
@@ -231,7 +231,7 @@ class TestResourceNotifications:
|
|
| 231 |
|
| 232 |
# Enable the target resource
|
| 233 |
result = await client.call_tool("enable_target_resource", {})
|
| 234 |
-
assert result
|
| 235 |
|
| 236 |
# Check that notification was sent
|
| 237 |
recording_message_handler.assert_notification_sent(
|
|
@@ -252,7 +252,7 @@ class TestResourceNotifications:
|
|
| 252 |
|
| 253 |
# Disable the target resource
|
| 254 |
result = await client.call_tool("disable_target_resource", {})
|
| 255 |
-
assert result
|
| 256 |
|
| 257 |
# Check that notification was sent
|
| 258 |
recording_message_handler.assert_notification_sent(
|
|
@@ -313,7 +313,7 @@ class TestPromptNotifications:
|
|
| 313 |
|
| 314 |
# Enable the target prompt
|
| 315 |
result = await client.call_tool("enable_target_prompt", {})
|
| 316 |
-
assert result
|
| 317 |
|
| 318 |
# Check that notification was sent
|
| 319 |
recording_message_handler.assert_notification_sent(
|
|
@@ -334,7 +334,7 @@ class TestPromptNotifications:
|
|
| 334 |
|
| 335 |
# Disable the target prompt
|
| 336 |
result = await client.call_tool("disable_target_prompt", {})
|
| 337 |
-
assert result
|
| 338 |
|
| 339 |
# Check that notification was sent
|
| 340 |
recording_message_handler.assert_notification_sent(
|
|
|
|
| 126 |
|
| 127 |
# Enable the target tool
|
| 128 |
result = await client.call_tool("enable_target_tool", {})
|
| 129 |
+
assert result.data == "Target tool enabled"
|
| 130 |
|
| 131 |
# Check that notification was sent
|
| 132 |
recording_message_handler.assert_notification_sent(
|
|
|
|
| 147 |
|
| 148 |
# Disable the target tool
|
| 149 |
result = await client.call_tool("disable_target_tool", {})
|
| 150 |
+
assert result.data == "Target tool disabled"
|
| 151 |
|
| 152 |
# Check that notification was sent
|
| 153 |
recording_message_handler.assert_notification_sent(
|
|
|
|
| 231 |
|
| 232 |
# Enable the target resource
|
| 233 |
result = await client.call_tool("enable_target_resource", {})
|
| 234 |
+
assert result.data == "Target resource enabled"
|
| 235 |
|
| 236 |
# Check that notification was sent
|
| 237 |
recording_message_handler.assert_notification_sent(
|
|
|
|
| 252 |
|
| 253 |
# Disable the target resource
|
| 254 |
result = await client.call_tool("disable_target_resource", {})
|
| 255 |
+
assert result.data == "Target resource disabled"
|
| 256 |
|
| 257 |
# Check that notification was sent
|
| 258 |
recording_message_handler.assert_notification_sent(
|
|
|
|
| 313 |
|
| 314 |
# Enable the target prompt
|
| 315 |
result = await client.call_tool("enable_target_prompt", {})
|
| 316 |
+
assert result.data == "Target prompt enabled"
|
| 317 |
|
| 318 |
# Check that notification was sent
|
| 319 |
recording_message_handler.assert_notification_sent(
|
|
|
|
| 334 |
|
| 335 |
# Disable the target prompt
|
| 336 |
result = await client.call_tool("disable_target_prompt", {})
|
| 337 |
+
assert result.data == "Target prompt disabled"
|
| 338 |
|
| 339 |
# Check that notification was sent
|
| 340 |
recording_message_handler.assert_notification_sent(
|
tests/client/test_openapi.py
CHANGED
|
@@ -118,7 +118,7 @@ class TestClientHeaders:
|
|
| 118 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 119 |
) as client:
|
| 120 |
result = await client.call_tool("post_headers_headers_post")
|
| 121 |
-
headers
|
| 122 |
assert headers["x-test"] == "test-123"
|
| 123 |
|
| 124 |
async def test_client_headers_shttp_tool(self, shttp_server: str):
|
|
@@ -128,7 +128,7 @@ class TestClientHeaders:
|
|
| 128 |
)
|
| 129 |
) as client:
|
| 130 |
result = await client.call_tool("post_headers_headers_post")
|
| 131 |
-
headers
|
| 132 |
assert headers["x-test"] == "test-123"
|
| 133 |
|
| 134 |
async def test_client_overrides_server_headers(self, shttp_server: str):
|
|
|
|
| 118 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 119 |
) as client:
|
| 120 |
result = await client.call_tool("post_headers_headers_post")
|
| 121 |
+
headers: dict[str, str] = result.data
|
| 122 |
assert headers["x-test"] == "test-123"
|
| 123 |
|
| 124 |
async def test_client_headers_shttp_tool(self, shttp_server: str):
|
|
|
|
| 128 |
)
|
| 129 |
) as client:
|
| 130 |
result = await client.call_tool("post_headers_headers_post")
|
| 131 |
+
headers: dict[str, str] = result.data
|
| 132 |
assert headers["x-test"] == "test-123"
|
| 133 |
|
| 134 |
async def test_client_overrides_server_headers(self, shttp_server: str):
|
tests/client/test_roots.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
import json
|
| 2 |
-
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from fastmcp import Client, Context, FastMCP
|
|
@@ -40,7 +38,7 @@ class TestClientRoots:
|
|
| 40 |
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
|
| 41 |
async with Client(fastmcp_server, roots=roots) as client:
|
| 42 |
result = await client.call_tool("list_roots", {})
|
| 43 |
-
assert
|
| 44 |
"file://x/y/z",
|
| 45 |
"file://x/y/z",
|
| 46 |
]
|
|
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
from fastmcp import Client, Context, FastMCP
|
|
|
|
| 38 |
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
|
| 39 |
async with Client(fastmcp_server, roots=roots) as client:
|
| 40 |
result = await client.call_tool("list_roots", {})
|
| 41 |
+
assert result.data == [
|
| 42 |
"file://x/y/z",
|
| 43 |
"file://x/y/z",
|
| 44 |
]
|
tests/client/test_sampling.py
CHANGED
|
@@ -47,8 +47,7 @@ async def test_simple_sampling(fastmcp_server: FastMCP):
|
|
| 47 |
|
| 48 |
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
| 49 |
result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
|
| 50 |
-
|
| 51 |
-
assert reply.text == "This is the sample message!"
|
| 52 |
|
| 53 |
|
| 54 |
async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
|
|
@@ -62,8 +61,7 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
|
|
| 62 |
result = await client.call_tool(
|
| 63 |
"sample_with_system_prompt", {"message": "Hello, world!"}
|
| 64 |
)
|
| 65 |
-
|
| 66 |
-
assert reply.text == "You love FastMCP"
|
| 67 |
|
| 68 |
|
| 69 |
async def test_sampling_with_messages(fastmcp_server: FastMCP):
|
|
@@ -81,5 +79,4 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
|
|
| 81 |
result = await client.call_tool(
|
| 82 |
"sample_with_messages", {"message": "Hello, world!"}
|
| 83 |
)
|
| 84 |
-
|
| 85 |
-
assert reply.text == "I need to think."
|
|
|
|
| 47 |
|
| 48 |
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
| 49 |
result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
|
| 50 |
+
assert result.data == "This is the sample message!"
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
|
|
|
|
| 61 |
result = await client.call_tool(
|
| 62 |
"sample_with_system_prompt", {"message": "Hello, world!"}
|
| 63 |
)
|
| 64 |
+
assert result.data == "You love FastMCP"
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
async def test_sampling_with_messages(fastmcp_server: FastMCP):
|
|
|
|
| 79 |
result = await client.call_tool(
|
| 80 |
"sample_with_messages", {"message": "Hello, world!"}
|
| 81 |
)
|
| 82 |
+
assert result.data == "I need to think."
|
|
|
tests/client/test_stdio.py
CHANGED
|
@@ -48,11 +48,11 @@ class TestKeepAlive:
|
|
| 48 |
|
| 49 |
async with client:
|
| 50 |
result1 = await client.call_tool("pid")
|
| 51 |
-
pid1 =
|
| 52 |
|
| 53 |
async with client:
|
| 54 |
result2 = await client.call_tool("pid")
|
| 55 |
-
pid2 =
|
| 56 |
|
| 57 |
assert pid1 == pid2
|
| 58 |
|
|
@@ -66,11 +66,11 @@ class TestKeepAlive:
|
|
| 66 |
|
| 67 |
async with client:
|
| 68 |
result1 = await client.call_tool("pid")
|
| 69 |
-
pid1 =
|
| 70 |
|
| 71 |
async with client:
|
| 72 |
result2 = await client.call_tool("pid")
|
| 73 |
-
pid2 =
|
| 74 |
|
| 75 |
assert pid1 != pid2
|
| 76 |
|
|
@@ -80,13 +80,13 @@ class TestKeepAlive:
|
|
| 80 |
|
| 81 |
async with client:
|
| 82 |
result1 = await client.call_tool("pid")
|
| 83 |
-
pid1 =
|
| 84 |
|
| 85 |
await client.close()
|
| 86 |
|
| 87 |
async with client:
|
| 88 |
result2 = await client.call_tool("pid")
|
| 89 |
-
pid2 =
|
| 90 |
|
| 91 |
assert pid1 != pid2
|
| 92 |
|
|
@@ -96,14 +96,14 @@ class TestKeepAlive:
|
|
| 96 |
|
| 97 |
async with client:
|
| 98 |
result1 = await client.call_tool("pid")
|
| 99 |
-
pid1 =
|
| 100 |
|
| 101 |
async with client:
|
| 102 |
result2 = await client.call_tool("pid")
|
| 103 |
-
pid2 =
|
| 104 |
|
| 105 |
result3 = await client.call_tool("pid")
|
| 106 |
-
pid3 =
|
| 107 |
|
| 108 |
assert pid1 == pid2 == pid3
|
| 109 |
|
|
|
|
| 48 |
|
| 49 |
async with client:
|
| 50 |
result1 = await client.call_tool("pid")
|
| 51 |
+
pid1: int = result1.data
|
| 52 |
|
| 53 |
async with client:
|
| 54 |
result2 = await client.call_tool("pid")
|
| 55 |
+
pid2: int = result2.data
|
| 56 |
|
| 57 |
assert pid1 == pid2
|
| 58 |
|
|
|
|
| 66 |
|
| 67 |
async with client:
|
| 68 |
result1 = await client.call_tool("pid")
|
| 69 |
+
pid1: int = result1.data
|
| 70 |
|
| 71 |
async with client:
|
| 72 |
result2 = await client.call_tool("pid")
|
| 73 |
+
pid2: int = result2.data
|
| 74 |
|
| 75 |
assert pid1 != pid2
|
| 76 |
|
|
|
|
| 80 |
|
| 81 |
async with client:
|
| 82 |
result1 = await client.call_tool("pid")
|
| 83 |
+
pid1: int = result1.data
|
| 84 |
|
| 85 |
await client.close()
|
| 86 |
|
| 87 |
async with client:
|
| 88 |
result2 = await client.call_tool("pid")
|
| 89 |
+
pid2: int = result2.data
|
| 90 |
|
| 91 |
assert pid1 != pid2
|
| 92 |
|
|
|
|
| 96 |
|
| 97 |
async with client:
|
| 98 |
result1 = await client.call_tool("pid")
|
| 99 |
+
pid1: int = result1.data
|
| 100 |
|
| 101 |
async with client:
|
| 102 |
result2 = await client.call_tool("pid")
|
| 103 |
+
pid2: int = result2.data
|
| 104 |
|
| 105 |
result3 = await client.call_tool("pid")
|
| 106 |
+
pid3: int = result3.data
|
| 107 |
|
| 108 |
assert pid1 == pid2 == pid3
|
| 109 |
|
tests/client/test_streamable_http.py
CHANGED
|
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock
|
|
| 7 |
import pytest
|
| 8 |
import uvicorn
|
| 9 |
from mcp import McpError
|
| 10 |
-
from mcp.types import TextContent
|
| 11 |
from starlette.applications import Starlette
|
| 12 |
from starlette.routing import Mount
|
| 13 |
|
|
@@ -166,10 +165,7 @@ async def test_greet_with_progress_tool(streamable_http_server: str):
|
|
| 166 |
progress_handler=progress_handler,
|
| 167 |
) as client:
|
| 168 |
result = await client.call_tool("greet_with_progress", {"name": "Alice"})
|
| 169 |
-
|
| 170 |
-
assert isinstance(result, list)
|
| 171 |
-
assert isinstance(result[0], TextContent)
|
| 172 |
-
assert result[0].text == "Hello, Alice!"
|
| 173 |
|
| 174 |
progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress")
|
| 175 |
|
|
|
|
| 7 |
import pytest
|
| 8 |
import uvicorn
|
| 9 |
from mcp import McpError
|
|
|
|
| 10 |
from starlette.applications import Starlette
|
| 11 |
from starlette.routing import Mount
|
| 12 |
|
|
|
|
| 165 |
progress_handler=progress_handler,
|
| 166 |
) as client:
|
| 167 |
result = await client.call_tool("greet_with_progress", {"name": "Alice"})
|
| 168 |
+
assert result.data == "Hello, Alice!"
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress")
|
| 171 |
|
tests/contrib/test_bulk_tool_caller.py
CHANGED
|
@@ -59,7 +59,10 @@ async def no_return_tool(arg1: str) -> None:
|
|
| 59 |
def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
|
| 60 |
"""A tool that returns a result based on the input arguments."""
|
| 61 |
return CallToolRequestResult(
|
| 62 |
-
isError=False,
|
|
|
|
|
|
|
|
|
|
| 63 |
)
|
| 64 |
|
| 65 |
|
|
|
|
| 59 |
def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
|
| 60 |
"""A tool that returns a result based on the input arguments."""
|
| 61 |
return CallToolRequestResult(
|
| 62 |
+
isError=False,
|
| 63 |
+
content=[],
|
| 64 |
+
tool="no_return_tool",
|
| 65 |
+
arguments={"arg1": arg1},
|
| 66 |
)
|
| 67 |
|
| 68 |
|
tests/contrib/test_component_manager.py
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from starlette import status
|
| 3 |
+
from starlette.testclient import TestClient
|
| 4 |
+
|
| 5 |
+
from fastmcp import FastMCP
|
| 6 |
+
from fastmcp.contrib.component_manager import set_up_component_manager
|
| 7 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestComponentManagementRoutes:
|
| 11 |
+
"""Test the component management routes for tools, resources, and prompts."""
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def mounted_mcp(self):
|
| 15 |
+
"""Create a FastMCP server with a mounted sub-server and a tool, resource, and prompt on the sub-server."""
|
| 16 |
+
mounted_mcp = FastMCP("SubServer")
|
| 17 |
+
|
| 18 |
+
@mounted_mcp.tool()
|
| 19 |
+
def mounted_tool() -> str:
|
| 20 |
+
"""Test tool for tool management routes."""
|
| 21 |
+
return "mounted_tool_result"
|
| 22 |
+
|
| 23 |
+
@mounted_mcp.resource("data://mounted_resource")
|
| 24 |
+
def mounted_resource() -> str:
|
| 25 |
+
"""Test resource for tool management routes."""
|
| 26 |
+
return "mounted_resource_result"
|
| 27 |
+
|
| 28 |
+
# Add a test resource
|
| 29 |
+
@mounted_mcp.resource("data://mounted_resource/{id}")
|
| 30 |
+
def test_template(id: str) -> dict:
|
| 31 |
+
"""Test template for tool management routes."""
|
| 32 |
+
return {"id": id, "value": "data"}
|
| 33 |
+
|
| 34 |
+
@mounted_mcp.prompt()
|
| 35 |
+
def mounted_prompt() -> str:
|
| 36 |
+
"""Test prompt for tool management routes."""
|
| 37 |
+
return "mounted_prompt_result"
|
| 38 |
+
|
| 39 |
+
return mounted_mcp
|
| 40 |
+
|
| 41 |
+
@pytest.fixture
|
| 42 |
+
def mcp(self, mounted_mcp):
|
| 43 |
+
"""Create a FastMCP server with test tools, resources, and prompts."""
|
| 44 |
+
mcp = FastMCP("TestServer")
|
| 45 |
+
mcp.mount(mounted_mcp, prefix="sub")
|
| 46 |
+
set_up_component_manager(server=mcp)
|
| 47 |
+
|
| 48 |
+
# Add a test tool
|
| 49 |
+
@mcp.tool
|
| 50 |
+
def test_tool() -> str:
|
| 51 |
+
"""Test tool for tool management routes."""
|
| 52 |
+
return "test_tool_result"
|
| 53 |
+
|
| 54 |
+
# Add a test resource
|
| 55 |
+
@mcp.resource("data://test_resource")
|
| 56 |
+
def test_resource() -> str:
|
| 57 |
+
"""Test resource for tool management routes."""
|
| 58 |
+
return "test_resource_result"
|
| 59 |
+
|
| 60 |
+
# Add a test resource
|
| 61 |
+
@mcp.resource("data://test_resource/{id}")
|
| 62 |
+
def test_template(id: str) -> dict:
|
| 63 |
+
"""Test template for tool management routes."""
|
| 64 |
+
return {"id": id, "value": "data"}
|
| 65 |
+
|
| 66 |
+
# Add a test prompt
|
| 67 |
+
@mcp.prompt
|
| 68 |
+
def test_prompt() -> str:
|
| 69 |
+
"""Test prompt for tool management routes."""
|
| 70 |
+
return "test_prompt_result"
|
| 71 |
+
|
| 72 |
+
return mcp
|
| 73 |
+
|
| 74 |
+
@pytest.fixture
|
| 75 |
+
def client(self, mcp):
|
| 76 |
+
"""Create a test client for the FastMCP server."""
|
| 77 |
+
return TestClient(mcp.http_app())
|
| 78 |
+
|
| 79 |
+
async def test_enable_tool_route(self, client, mcp):
|
| 80 |
+
"""Test enabling a tool via the HTTP route."""
|
| 81 |
+
# First disable the tool
|
| 82 |
+
tool = await mcp._tool_manager.get_tool("test_tool")
|
| 83 |
+
tool.enabled = False
|
| 84 |
+
|
| 85 |
+
# Enable the tool via the HTTP route
|
| 86 |
+
response = client.post("/tools/test_tool/enable")
|
| 87 |
+
|
| 88 |
+
assert response.status_code == status.HTTP_200_OK
|
| 89 |
+
assert response.json() == {"message": "Enabled tool: test_tool"}
|
| 90 |
+
|
| 91 |
+
# Verify the tool is enabled
|
| 92 |
+
tool = await mcp._tool_manager.get_tool("test_tool")
|
| 93 |
+
assert tool.enabled is True
|
| 94 |
+
|
| 95 |
+
async def test_disable_tool_route(self, client, mcp):
|
| 96 |
+
"""Test disabling a tool via the HTTP route."""
|
| 97 |
+
# First ensure the tool is enabled
|
| 98 |
+
tool = await mcp._tool_manager.get_tool("test_tool")
|
| 99 |
+
tool.enabled = True
|
| 100 |
+
|
| 101 |
+
# Disable the tool via the HTTP route
|
| 102 |
+
response = client.post("/tools/test_tool/disable")
|
| 103 |
+
|
| 104 |
+
assert response.status_code == status.HTTP_200_OK
|
| 105 |
+
assert response.json() == {"message": "Disabled tool: test_tool"}
|
| 106 |
+
|
| 107 |
+
# Verify the tool is disabled
|
| 108 |
+
tool = await mcp._tool_manager.get_tool("test_tool")
|
| 109 |
+
assert tool.enabled is False
|
| 110 |
+
|
| 111 |
+
async def test_enable_resource_route(self, client, mcp):
|
| 112 |
+
"""Test enabling a resource via the HTTP route."""
|
| 113 |
+
# First disable the resource
|
| 114 |
+
resource = await mcp._resource_manager.get_resource("data://test_resource")
|
| 115 |
+
resource.enabled = False
|
| 116 |
+
|
| 117 |
+
# Enable the resource via the HTTP route
|
| 118 |
+
response = client.post("/resources/data://test_resource/enable")
|
| 119 |
+
|
| 120 |
+
assert response.status_code == status.HTTP_200_OK
|
| 121 |
+
assert response.json() == {"message": "Enabled resource: data://test_resource"}
|
| 122 |
+
|
| 123 |
+
# Verify the resource is enabled
|
| 124 |
+
resource = await mcp._resource_manager.get_resource("data://test_resource")
|
| 125 |
+
assert resource.enabled is True
|
| 126 |
+
|
| 127 |
+
async def test_disable_resource_route(self, client, mcp):
|
| 128 |
+
"""Test disabling a resource via the HTTP route."""
|
| 129 |
+
# First ensure the resource is enabled
|
| 130 |
+
resource = await mcp._resource_manager.get_resource("data://test_resource")
|
| 131 |
+
resource.enabled = True
|
| 132 |
+
|
| 133 |
+
# Disable the resource via the HTTP route
|
| 134 |
+
response = client.post("/resources/data://test_resource/disable")
|
| 135 |
+
|
| 136 |
+
assert response.status_code == status.HTTP_200_OK
|
| 137 |
+
assert response.json() == {"message": "Disabled resource: data://test_resource"}
|
| 138 |
+
|
| 139 |
+
# Verify the resource is disabled
|
| 140 |
+
resource = await mcp._resource_manager.get_resource("data://test_resource")
|
| 141 |
+
assert resource.enabled is False
|
| 142 |
+
|
| 143 |
+
async def test_enable_template_route(self, client, mcp):
|
| 144 |
+
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
|
| 145 |
+
key = "data://test_resource/{id}"
|
| 146 |
+
resource = mcp._resource_manager._templates[key]
|
| 147 |
+
resource.enabled = False
|
| 148 |
+
response = client.post("/resources/data://test_resource/{id}/enable")
|
| 149 |
+
assert response.status_code == status.HTTP_200_OK
|
| 150 |
+
assert response.json() == {
|
| 151 |
+
"message": "Enabled resource: data://test_resource/{id}"
|
| 152 |
+
}
|
| 153 |
+
assert resource.enabled is True
|
| 154 |
+
|
| 155 |
+
async def test_disable_template_route(self, client, mcp):
|
| 156 |
+
"""Test disabling a resource on a mounted server via the parent server's HTTP route."""
|
| 157 |
+
key = "data://test_resource/{id}"
|
| 158 |
+
resource = mcp._resource_manager._templates[key]
|
| 159 |
+
resource.enabled = True
|
| 160 |
+
response = client.post("/resources/data://test_resource/{id}/disable")
|
| 161 |
+
assert response.status_code == status.HTTP_200_OK
|
| 162 |
+
assert response.json() == {
|
| 163 |
+
"message": "Disabled resource: data://test_resource/{id}"
|
| 164 |
+
}
|
| 165 |
+
assert resource.enabled is False
|
| 166 |
+
|
| 167 |
+
async def test_enable_prompt_route(self, client, mcp):
|
| 168 |
+
"""Test enabling a prompt via the HTTP route."""
|
| 169 |
+
# First disable the prompt
|
| 170 |
+
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
|
| 171 |
+
prompt.enabled = False
|
| 172 |
+
|
| 173 |
+
# Enable the prompt via the HTTP route
|
| 174 |
+
response = client.post("/prompts/test_prompt/enable")
|
| 175 |
+
|
| 176 |
+
assert response.status_code == status.HTTP_200_OK
|
| 177 |
+
assert response.json() == {"message": "Enabled prompt: test_prompt"}
|
| 178 |
+
|
| 179 |
+
# Verify the prompt is enabled
|
| 180 |
+
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
|
| 181 |
+
assert prompt.enabled is True
|
| 182 |
+
|
| 183 |
+
async def test_disable_prompt_route(self, client, mcp):
|
| 184 |
+
"""Test disabling a prompt via the HTTP route."""
|
| 185 |
+
# First ensure the prompt is enabled
|
| 186 |
+
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
|
| 187 |
+
prompt.enabled = True
|
| 188 |
+
|
| 189 |
+
# Disable the prompt via the HTTP route
|
| 190 |
+
response = client.post("/prompts/test_prompt/disable")
|
| 191 |
+
|
| 192 |
+
assert response.status_code == status.HTTP_200_OK
|
| 193 |
+
assert response.json() == {"message": "Disabled prompt: test_prompt"}
|
| 194 |
+
|
| 195 |
+
# Verify the prompt is disabled
|
| 196 |
+
prompt = await mcp._prompt_manager.get_prompt("test_prompt")
|
| 197 |
+
assert prompt.enabled is False
|
| 198 |
+
|
| 199 |
+
async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp):
|
| 200 |
+
"""Test enabling a tool on a mounted server via the parent server's HTTP route."""
|
| 201 |
+
# Disable the tool on the sub-server
|
| 202 |
+
sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
|
| 203 |
+
sub_tool.enabled = False
|
| 204 |
+
# Enable via parent
|
| 205 |
+
response = client.post("/tools/sub_mounted_tool/enable")
|
| 206 |
+
assert response.status_code == status.HTTP_200_OK
|
| 207 |
+
assert response.json() == {"message": "Enabled tool: sub_mounted_tool"}
|
| 208 |
+
# Confirm disabled on sub-server
|
| 209 |
+
assert sub_tool.enabled is True
|
| 210 |
+
|
| 211 |
+
async def test_disable_tool_route_on_mounted_server(self, client, mounted_mcp):
|
| 212 |
+
"""Test disabling a tool on a mounted server via the parent server's HTTP route."""
|
| 213 |
+
# Enable the tool on the sub-server
|
| 214 |
+
sub_tool = await mounted_mcp._tool_manager.get_tool("mounted_tool")
|
| 215 |
+
sub_tool.enabled = True
|
| 216 |
+
# Disable via parent
|
| 217 |
+
response = client.post("/tools/sub_mounted_tool/disable")
|
| 218 |
+
assert response.status_code == status.HTTP_200_OK
|
| 219 |
+
assert response.json() == {"message": "Disabled tool: sub_mounted_tool"}
|
| 220 |
+
# Confirm disabled on sub-server
|
| 221 |
+
assert sub_tool.enabled is False
|
| 222 |
+
|
| 223 |
+
async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp):
|
| 224 |
+
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
|
| 225 |
+
resource = await mounted_mcp._resource_manager.get_resource(
|
| 226 |
+
"data://mounted_resource"
|
| 227 |
+
)
|
| 228 |
+
resource.enabled = False
|
| 229 |
+
response = client.post("/resources/data://sub/mounted_resource/enable")
|
| 230 |
+
assert response.status_code == status.HTTP_200_OK
|
| 231 |
+
assert response.json() == {
|
| 232 |
+
"message": "Enabled resource: data://sub/mounted_resource"
|
| 233 |
+
}
|
| 234 |
+
resource = await mounted_mcp._resource_manager.get_resource(
|
| 235 |
+
"data://mounted_resource"
|
| 236 |
+
)
|
| 237 |
+
assert resource.enabled is True
|
| 238 |
+
|
| 239 |
+
async def test_disable_resource_route_on_mounted_server(self, client, mounted_mcp):
|
| 240 |
+
"""Test disabling a resource on a mounted server via the parent server's HTTP route."""
|
| 241 |
+
resource = await mounted_mcp._resource_manager.get_resource(
|
| 242 |
+
"data://mounted_resource"
|
| 243 |
+
)
|
| 244 |
+
resource.enabled = True
|
| 245 |
+
response = client.post("/resources/data://sub/mounted_resource/disable")
|
| 246 |
+
assert response.status_code == status.HTTP_200_OK
|
| 247 |
+
assert response.json() == {
|
| 248 |
+
"message": "Disabled resource: data://sub/mounted_resource"
|
| 249 |
+
}
|
| 250 |
+
resource = await mounted_mcp._resource_manager.get_resource(
|
| 251 |
+
"data://mounted_resource"
|
| 252 |
+
)
|
| 253 |
+
assert resource.enabled is False
|
| 254 |
+
|
| 255 |
+
async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp):
|
| 256 |
+
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
|
| 257 |
+
key = "data://mounted_resource/{id}"
|
| 258 |
+
resource = mounted_mcp._resource_manager._templates[key]
|
| 259 |
+
resource.enabled = False
|
| 260 |
+
response = client.post("/resources/data://sub/mounted_resource/{id}/enable")
|
| 261 |
+
assert response.status_code == status.HTTP_200_OK
|
| 262 |
+
assert response.json() == {
|
| 263 |
+
"message": "Enabled resource: data://sub/mounted_resource/{id}"
|
| 264 |
+
}
|
| 265 |
+
assert resource.enabled is True
|
| 266 |
+
|
| 267 |
+
async def test_disable_template_route_on_mounted_server(self, client, mounted_mcp):
|
| 268 |
+
"""Test disabling a resource on a mounted server via the parent server's HTTP route."""
|
| 269 |
+
key = "data://mounted_resource/{id}"
|
| 270 |
+
resource = mounted_mcp._resource_manager._templates[key]
|
| 271 |
+
resource.enabled = True
|
| 272 |
+
response = client.post("/resources/data://sub/mounted_resource/{id}/disable")
|
| 273 |
+
assert response.status_code == status.HTTP_200_OK
|
| 274 |
+
assert response.json() == {
|
| 275 |
+
"message": "Disabled resource: data://sub/mounted_resource/{id}"
|
| 276 |
+
}
|
| 277 |
+
assert resource.enabled is False
|
| 278 |
+
|
| 279 |
+
async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp):
|
| 280 |
+
"""Test enabling a prompt on a mounted server via the parent server's HTTP route."""
|
| 281 |
+
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
|
| 282 |
+
prompt.enabled = False
|
| 283 |
+
response = client.post("/prompts/sub_mounted_prompt/enable")
|
| 284 |
+
assert response.status_code == status.HTTP_200_OK
|
| 285 |
+
assert response.json() == {"message": "Enabled prompt: sub_mounted_prompt"}
|
| 286 |
+
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
|
| 287 |
+
assert prompt.enabled is True
|
| 288 |
+
|
| 289 |
+
async def test_disable_prompt_route_on_mounted_server(self, client, mounted_mcp):
|
| 290 |
+
"""Test disabling a prompt on a mounted server via the parent server's HTTP route."""
|
| 291 |
+
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
|
| 292 |
+
prompt.enabled = True
|
| 293 |
+
response = client.post("/prompts/sub_mounted_prompt/disable")
|
| 294 |
+
assert response.status_code == status.HTTP_200_OK
|
| 295 |
+
assert response.json() == {"message": "Disabled prompt: sub_mounted_prompt"}
|
| 296 |
+
prompt = await mounted_mcp._prompt_manager.get_prompt("mounted_prompt")
|
| 297 |
+
assert prompt.enabled is False
|
| 298 |
+
|
| 299 |
+
def test_enable_nonexistent_tool(self, client):
|
| 300 |
+
"""Test enabling a non-existent tool returns 404."""
|
| 301 |
+
response = client.post("/tools/nonexistent_tool/enable")
|
| 302 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 303 |
+
assert response.text == "Unknown tool: nonexistent_tool"
|
| 304 |
+
|
| 305 |
+
def test_disable_nonexistent_tool(self, client):
|
| 306 |
+
"""Test disabling a non-existent tool returns 404."""
|
| 307 |
+
response = client.post("/tools/nonexistent_tool/disable")
|
| 308 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 309 |
+
assert response.text == "Unknown tool: nonexistent_tool"
|
| 310 |
+
|
| 311 |
+
def test_enable_nonexistent_resource(self, client):
|
| 312 |
+
"""Test enabling a non-existent resource returns 404."""
|
| 313 |
+
response = client.post("/resources/nonexistent://resource/enable")
|
| 314 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 315 |
+
assert response.text == "Unknown resource: nonexistent://resource"
|
| 316 |
+
|
| 317 |
+
def test_disable_nonexistent_resource(self, client):
|
| 318 |
+
"""Test disabling a non-existent resource returns 404."""
|
| 319 |
+
response = client.post("/resources/nonexistent://resource/disable")
|
| 320 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 321 |
+
assert response.text == "Unknown resource: nonexistent://resource"
|
| 322 |
+
|
| 323 |
+
def test_enable_nonexistent_prompt(self, client):
|
| 324 |
+
"""Test enabling a non-existent prompt returns 404."""
|
| 325 |
+
response = client.post("/prompts/nonexistent_prompt/enable")
|
| 326 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 327 |
+
assert response.text == "Unknown prompt: nonexistent_prompt"
|
| 328 |
+
|
| 329 |
+
def test_disable_nonexistent_prompt(self, client):
|
| 330 |
+
"""Test disabling a non-existent prompt returns 404."""
|
| 331 |
+
response = client.post("/prompts/nonexistent_prompt/disable")
|
| 332 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 333 |
+
assert response.text == "Unknown prompt: nonexistent_prompt"
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
class TestAuthComponentManagementRoutes:
|
| 337 |
+
"""Test the component management routes with authentication for tools, resources, and prompts."""
|
| 338 |
+
|
| 339 |
+
def setup_method(self):
|
| 340 |
+
"""Set up test fixtures."""
|
| 341 |
+
# Generate a key pair and create an auth provider
|
| 342 |
+
key_pair = RSAKeyPair.generate()
|
| 343 |
+
self.auth = BearerAuthProvider(
|
| 344 |
+
public_key=key_pair.public_key,
|
| 345 |
+
issuer="https://dev.example.com",
|
| 346 |
+
audience="my-dev-server",
|
| 347 |
+
)
|
| 348 |
+
self.mcp = FastMCP("TestServerWithAuth", auth=self.auth)
|
| 349 |
+
set_up_component_manager(
|
| 350 |
+
server=self.mcp, required_scopes=["tool:write", "tool:read"]
|
| 351 |
+
)
|
| 352 |
+
self.token = key_pair.create_token(
|
| 353 |
+
subject="dev-user",
|
| 354 |
+
issuer="https://dev.example.com",
|
| 355 |
+
audience="my-dev-server",
|
| 356 |
+
scopes=["tool:write", "tool:read"],
|
| 357 |
+
)
|
| 358 |
+
self.token_without_scopes = key_pair.create_token(
|
| 359 |
+
subject="dev-user",
|
| 360 |
+
issuer="https://dev.example.com",
|
| 361 |
+
audience="my-dev-server",
|
| 362 |
+
scopes=["tool:read"],
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
# Add test components
|
| 366 |
+
@self.mcp.tool
|
| 367 |
+
def test_tool() -> str:
|
| 368 |
+
"""Test tool for auth testing."""
|
| 369 |
+
return "test_tool_result"
|
| 370 |
+
|
| 371 |
+
@self.mcp.resource("data://test_resource")
|
| 372 |
+
def test_resource() -> str:
|
| 373 |
+
"""Test resource for auth testing."""
|
| 374 |
+
return "test_resource_result"
|
| 375 |
+
|
| 376 |
+
@self.mcp.prompt
|
| 377 |
+
def test_prompt() -> str:
|
| 378 |
+
"""Test prompt for auth testing."""
|
| 379 |
+
return "test_prompt_result"
|
| 380 |
+
|
| 381 |
+
# Create test client
|
| 382 |
+
self.client = TestClient(self.mcp.http_app())
|
| 383 |
+
|
| 384 |
+
async def test_unauthorized_enable_tool(self):
|
| 385 |
+
"""Test that unauthenticated requests to enable a tool are rejected."""
|
| 386 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 387 |
+
tool.enabled = False
|
| 388 |
+
|
| 389 |
+
response = self.client.post("/tools/test_tool/enable")
|
| 390 |
+
assert response.status_code == 401
|
| 391 |
+
assert tool.enabled is False
|
| 392 |
+
|
| 393 |
+
async def test_authorized_enable_tool(self):
|
| 394 |
+
"""Test that authenticated requests to enable a tool are allowed."""
|
| 395 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 396 |
+
tool.enabled = False
|
| 397 |
+
|
| 398 |
+
response = self.client.post(
|
| 399 |
+
"/tools/test_tool/enable", headers={"Authorization": "Bearer " + self.token}
|
| 400 |
+
)
|
| 401 |
+
assert response.status_code == 200
|
| 402 |
+
assert response.json() == {"message": "Enabled tool: test_tool"}
|
| 403 |
+
assert tool.enabled is True
|
| 404 |
+
|
| 405 |
+
async def test_unauthorized_disable_tool(self):
|
| 406 |
+
"""Test that unauthenticated requests to disable a tool are rejected."""
|
| 407 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 408 |
+
tool.enabled = True
|
| 409 |
+
|
| 410 |
+
response = self.client.post("/tools/test_tool/disable")
|
| 411 |
+
assert response.status_code == 401
|
| 412 |
+
assert tool.enabled is True
|
| 413 |
+
|
| 414 |
+
async def test_authorized_disable_tool(self):
|
| 415 |
+
"""Test that authenticated requests to disable a tool are allowed."""
|
| 416 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 417 |
+
tool.enabled = True
|
| 418 |
+
|
| 419 |
+
response = self.client.post(
|
| 420 |
+
"/tools/test_tool/disable",
|
| 421 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 422 |
+
)
|
| 423 |
+
assert response.status_code == 200
|
| 424 |
+
assert response.json() == {"message": "Disabled tool: test_tool"}
|
| 425 |
+
assert tool.enabled is False
|
| 426 |
+
|
| 427 |
+
async def test_forbidden_enable_tool(self):
|
| 428 |
+
"""Test that unauthenticated requests to enable a resource are rejected."""
|
| 429 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 430 |
+
tool.enabled = False
|
| 431 |
+
|
| 432 |
+
response = self.client.post(
|
| 433 |
+
"/tools/test_tool/enable",
|
| 434 |
+
headers={"Authorization": "Bearer " + self.token_without_scopes},
|
| 435 |
+
)
|
| 436 |
+
assert response.status_code == 403
|
| 437 |
+
assert tool.enabled is False
|
| 438 |
+
|
| 439 |
+
async def test_authorized_enable_resource(self):
|
| 440 |
+
"""Test that authenticated requests to enable a resource are allowed."""
|
| 441 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 442 |
+
resource.enabled = False
|
| 443 |
+
|
| 444 |
+
response = self.client.post(
|
| 445 |
+
"/resources/data://test_resource/enable",
|
| 446 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 447 |
+
)
|
| 448 |
+
assert response.status_code == 200
|
| 449 |
+
assert response.json() == {"message": "Enabled resource: data://test_resource"}
|
| 450 |
+
assert resource.enabled is True
|
| 451 |
+
|
| 452 |
+
async def test_unauthorized_disable_resource(self):
|
| 453 |
+
"""Test that unauthenticated requests to disable a resource are rejected."""
|
| 454 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 455 |
+
resource.enabled = True
|
| 456 |
+
|
| 457 |
+
response = self.client.post("/resources/data://test_resource/disable")
|
| 458 |
+
assert response.status_code == 401
|
| 459 |
+
assert resource.enabled is True
|
| 460 |
+
|
| 461 |
+
async def test_forbidden_enable_resource(self):
|
| 462 |
+
"""Test that unauthenticated requests to enable a resource are rejected."""
|
| 463 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 464 |
+
resource.enabled = False
|
| 465 |
+
|
| 466 |
+
response = self.client.post(
|
| 467 |
+
"/resources/data://test_resource/disable",
|
| 468 |
+
headers={"Authorization": "Bearer " + self.token_without_scopes},
|
| 469 |
+
)
|
| 470 |
+
assert response.status_code == 403
|
| 471 |
+
assert resource.enabled is False
|
| 472 |
+
|
| 473 |
+
async def test_authorized_disable_resource(self):
|
| 474 |
+
"""Test that authenticated requests to disable a resource are allowed."""
|
| 475 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 476 |
+
resource.enabled = True
|
| 477 |
+
|
| 478 |
+
response = self.client.post(
|
| 479 |
+
"/resources/data://test_resource/disable",
|
| 480 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 481 |
+
)
|
| 482 |
+
assert response.status_code == 200
|
| 483 |
+
assert response.json() == {"message": "Disabled resource: data://test_resource"}
|
| 484 |
+
assert resource.enabled is False
|
| 485 |
+
|
| 486 |
+
async def test_unauthorized_enable_prompt(self):
|
| 487 |
+
"""Test that unauthenticated requests to enable a prompt are rejected."""
|
| 488 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 489 |
+
prompt.enabled = False
|
| 490 |
+
|
| 491 |
+
response = self.client.post("/prompts/test_prompt/enable")
|
| 492 |
+
assert response.status_code == 401
|
| 493 |
+
assert prompt.enabled is False
|
| 494 |
+
|
| 495 |
+
async def test_authorized_enable_prompt(self):
|
| 496 |
+
"""Test that authenticated requests to enable a prompt are allowed."""
|
| 497 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 498 |
+
prompt.enabled = False
|
| 499 |
+
|
| 500 |
+
response = self.client.post(
|
| 501 |
+
"/prompts/test_prompt/enable",
|
| 502 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 503 |
+
)
|
| 504 |
+
assert response.status_code == 200
|
| 505 |
+
assert response.json() == {"message": "Enabled prompt: test_prompt"}
|
| 506 |
+
assert prompt.enabled is True
|
| 507 |
+
|
| 508 |
+
async def test_unauthorized_disable_prompt(self):
|
| 509 |
+
"""Test that unauthenticated requests to disable a prompt are rejected."""
|
| 510 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 511 |
+
prompt.enabled = True
|
| 512 |
+
|
| 513 |
+
response = self.client.post("/prompts/test_prompt/disable")
|
| 514 |
+
assert response.status_code == 401
|
| 515 |
+
assert prompt.enabled is True
|
| 516 |
+
|
| 517 |
+
async def test_forbidden_disable_prompt(self):
|
| 518 |
+
"""Test that unauthenticated requests to enable a resource are rejected."""
|
| 519 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 520 |
+
prompt.enabled = True
|
| 521 |
+
|
| 522 |
+
response = self.client.post(
|
| 523 |
+
"/prompts/test_prompt/disable",
|
| 524 |
+
headers={"Authorization": "Bearer " + self.token_without_scopes},
|
| 525 |
+
)
|
| 526 |
+
assert response.status_code == 403
|
| 527 |
+
assert prompt.enabled is True
|
| 528 |
+
|
| 529 |
+
async def test_authorized_disable_prompt(self):
|
| 530 |
+
"""Test that authenticated requests to disable a prompt are allowed."""
|
| 531 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 532 |
+
prompt.enabled = True
|
| 533 |
+
|
| 534 |
+
response = self.client.post(
|
| 535 |
+
"/prompts/test_prompt/disable",
|
| 536 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 537 |
+
)
|
| 538 |
+
assert response.status_code == 200
|
| 539 |
+
assert response.json() == {"message": "Disabled prompt: test_prompt"}
|
| 540 |
+
assert prompt.enabled is False
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
class TestComponentManagerWithPath:
|
| 544 |
+
"""Test component manager routes when mounted at a custom path."""
|
| 545 |
+
|
| 546 |
+
@pytest.fixture
|
| 547 |
+
def mcp_with_path(self):
|
| 548 |
+
mcp = FastMCP("TestServerWithPath")
|
| 549 |
+
set_up_component_manager(server=mcp, path="/test")
|
| 550 |
+
|
| 551 |
+
@mcp.tool
|
| 552 |
+
def test_tool() -> str:
|
| 553 |
+
return "test_tool_result"
|
| 554 |
+
|
| 555 |
+
@mcp.resource("data://test_resource")
|
| 556 |
+
def test_resource() -> str:
|
| 557 |
+
return "test_resource_result"
|
| 558 |
+
|
| 559 |
+
@mcp.prompt
|
| 560 |
+
def test_prompt() -> str:
|
| 561 |
+
return "test_prompt_result"
|
| 562 |
+
|
| 563 |
+
return mcp
|
| 564 |
+
|
| 565 |
+
@pytest.fixture
|
| 566 |
+
def client_with_path(self, mcp_with_path):
|
| 567 |
+
return TestClient(mcp_with_path.http_app())
|
| 568 |
+
|
| 569 |
+
@pytest.mark.asyncio
|
| 570 |
+
async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path):
|
| 571 |
+
tool = await mcp_with_path._tool_manager.get_tool("test_tool")
|
| 572 |
+
tool.enabled = False
|
| 573 |
+
response = client_with_path.post("/test/tools/test_tool/enable")
|
| 574 |
+
assert response.status_code == status.HTTP_200_OK
|
| 575 |
+
assert response.json() == {"message": "Enabled tool: test_tool"}
|
| 576 |
+
tool = await mcp_with_path._tool_manager.get_tool("test_tool")
|
| 577 |
+
assert tool.enabled is True
|
| 578 |
+
|
| 579 |
+
@pytest.mark.asyncio
|
| 580 |
+
async def test_disable_resource_route_with_path(
|
| 581 |
+
self, client_with_path, mcp_with_path
|
| 582 |
+
):
|
| 583 |
+
resource = await mcp_with_path._resource_manager.get_resource(
|
| 584 |
+
"data://test_resource"
|
| 585 |
+
)
|
| 586 |
+
resource.enabled = True
|
| 587 |
+
response = client_with_path.post("/test/resources/data://test_resource/disable")
|
| 588 |
+
assert response.status_code == status.HTTP_200_OK
|
| 589 |
+
assert response.json() == {"message": "Disabled resource: data://test_resource"}
|
| 590 |
+
resource = await mcp_with_path._resource_manager.get_resource(
|
| 591 |
+
"data://test_resource"
|
| 592 |
+
)
|
| 593 |
+
assert resource.enabled is False
|
| 594 |
+
|
| 595 |
+
@pytest.mark.asyncio
|
| 596 |
+
async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path):
|
| 597 |
+
prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
|
| 598 |
+
prompt.enabled = False
|
| 599 |
+
response = client_with_path.post("/test/prompts/test_prompt/enable")
|
| 600 |
+
assert response.status_code == status.HTTP_200_OK
|
| 601 |
+
assert response.json() == {"message": "Enabled prompt: test_prompt"}
|
| 602 |
+
prompt = await mcp_with_path._prompt_manager.get_prompt("test_prompt")
|
| 603 |
+
assert prompt.enabled is True
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
class TestComponentManagerWithPathAuth:
|
| 607 |
+
"""Test component manager routes with auth when mounted at a custom path."""
|
| 608 |
+
|
| 609 |
+
def setup_method(self):
|
| 610 |
+
# Generate a key pair and create an auth provider
|
| 611 |
+
key_pair = RSAKeyPair.generate()
|
| 612 |
+
self.auth = BearerAuthProvider(
|
| 613 |
+
public_key=key_pair.public_key,
|
| 614 |
+
issuer="https://dev.example.com",
|
| 615 |
+
audience="my-dev-server",
|
| 616 |
+
required_scopes=["tool:write", "tool:read"],
|
| 617 |
+
)
|
| 618 |
+
self.mcp = FastMCP("TestServerWithPathAuth", auth=self.auth)
|
| 619 |
+
set_up_component_manager(
|
| 620 |
+
server=self.mcp, path="/test", required_scopes=["tool:write", "tool:read"]
|
| 621 |
+
)
|
| 622 |
+
self.token = key_pair.create_token(
|
| 623 |
+
subject="dev-user",
|
| 624 |
+
issuer="https://dev.example.com",
|
| 625 |
+
audience="my-dev-server",
|
| 626 |
+
scopes=["tool:read", "tool:write"],
|
| 627 |
+
)
|
| 628 |
+
self.token_without_scopes = key_pair.create_token(
|
| 629 |
+
subject="dev-user",
|
| 630 |
+
issuer="https://dev.example.com",
|
| 631 |
+
audience="my-dev-server",
|
| 632 |
+
scopes=[],
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
@self.mcp.tool
|
| 636 |
+
def test_tool() -> str:
|
| 637 |
+
return "test_tool_result"
|
| 638 |
+
|
| 639 |
+
@self.mcp.resource("data://test_resource")
|
| 640 |
+
def test_resource() -> str:
|
| 641 |
+
return "test_resource_result"
|
| 642 |
+
|
| 643 |
+
@self.mcp.prompt
|
| 644 |
+
def test_prompt() -> str:
|
| 645 |
+
return "test_prompt_result"
|
| 646 |
+
|
| 647 |
+
self.client = TestClient(self.mcp.http_app())
|
| 648 |
+
|
| 649 |
+
@pytest.mark.asyncio
|
| 650 |
+
async def test_unauthorized_enable_tool(self):
|
| 651 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 652 |
+
tool.enabled = False
|
| 653 |
+
response = self.client.post("/test/tools/test_tool/enable")
|
| 654 |
+
assert response.status_code == 401
|
| 655 |
+
assert tool.enabled is False
|
| 656 |
+
|
| 657 |
+
@pytest.mark.asyncio
|
| 658 |
+
async def test_forbidden_enable_tool(self):
|
| 659 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 660 |
+
tool.enabled = False
|
| 661 |
+
response = self.client.post(
|
| 662 |
+
"/test/tools/test_tool/enable",
|
| 663 |
+
headers={"Authorization": "Bearer " + self.token_without_scopes},
|
| 664 |
+
)
|
| 665 |
+
assert response.status_code == 403
|
| 666 |
+
assert tool.enabled is False
|
| 667 |
+
|
| 668 |
+
@pytest.mark.asyncio
|
| 669 |
+
async def test_authorized_enable_tool(self):
|
| 670 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 671 |
+
tool.enabled = False
|
| 672 |
+
response = self.client.post(
|
| 673 |
+
"/test/tools/test_tool/enable",
|
| 674 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 675 |
+
)
|
| 676 |
+
assert response.status_code == 200
|
| 677 |
+
assert response.json() == {"message": "Enabled tool: test_tool"}
|
| 678 |
+
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 679 |
+
assert tool.enabled is True
|
| 680 |
+
|
| 681 |
+
@pytest.mark.asyncio
|
| 682 |
+
async def test_unauthorized_disable_resource(self):
|
| 683 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 684 |
+
resource.enabled = True
|
| 685 |
+
response = self.client.post("/test/resources/data://test_resource/disable")
|
| 686 |
+
assert response.status_code == 401
|
| 687 |
+
assert resource.enabled is True
|
| 688 |
+
|
| 689 |
+
@pytest.mark.asyncio
|
| 690 |
+
async def test_forbidden_disable_resource(self):
|
| 691 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 692 |
+
resource.enabled = True
|
| 693 |
+
response = self.client.post(
|
| 694 |
+
"/test/resources/data://test_resource/disable",
|
| 695 |
+
headers={"Authorization": "Bearer " + self.token_without_scopes},
|
| 696 |
+
)
|
| 697 |
+
assert response.status_code == 403
|
| 698 |
+
assert resource.enabled is True
|
| 699 |
+
|
| 700 |
+
@pytest.mark.asyncio
|
| 701 |
+
async def test_authorized_disable_resource(self):
|
| 702 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 703 |
+
resource.enabled = True
|
| 704 |
+
response = self.client.post(
|
| 705 |
+
"/test/resources/data://test_resource/disable",
|
| 706 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 707 |
+
)
|
| 708 |
+
assert response.status_code == 200
|
| 709 |
+
assert response.json() == {"message": "Disabled resource: data://test_resource"}
|
| 710 |
+
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 711 |
+
assert resource.enabled is False
|
| 712 |
+
|
| 713 |
+
@pytest.mark.asyncio
|
| 714 |
+
async def test_unauthorized_enable_prompt(self):
|
| 715 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 716 |
+
prompt.enabled = False
|
| 717 |
+
response = self.client.post("/test/prompts/test_prompt/enable")
|
| 718 |
+
assert response.status_code == 401
|
| 719 |
+
assert prompt.enabled is False
|
| 720 |
+
|
| 721 |
+
@pytest.mark.asyncio
|
| 722 |
+
async def test_forbidden_enable_prompt(self):
|
| 723 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 724 |
+
prompt.enabled = False
|
| 725 |
+
response = self.client.post(
|
| 726 |
+
"/test/prompts/test_prompt/enable",
|
| 727 |
+
headers={"Authorization": "Bearer " + self.token_without_scopes},
|
| 728 |
+
)
|
| 729 |
+
assert response.status_code == 403
|
| 730 |
+
assert prompt.enabled is False
|
| 731 |
+
|
| 732 |
+
@pytest.mark.asyncio
|
| 733 |
+
async def test_authorized_enable_prompt(self):
|
| 734 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 735 |
+
prompt.enabled = False
|
| 736 |
+
response = self.client.post(
|
| 737 |
+
"/test/prompts/test_prompt/enable",
|
| 738 |
+
headers={"Authorization": "Bearer " + self.token},
|
| 739 |
+
)
|
| 740 |
+
assert response.status_code == 200
|
| 741 |
+
assert response.json() == {"message": "Enabled prompt: test_prompt"}
|
| 742 |
+
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 743 |
+
assert prompt.enabled is True
|
tests/deprecated/test_mount_import_arg_order.py
CHANGED
|
@@ -36,7 +36,7 @@ class TestDeprecatedMountArgOrder:
|
|
| 36 |
# Test functionality
|
| 37 |
async with Client(main_app) as client:
|
| 38 |
result = await client.call_tool("sub_sub_tool", {})
|
| 39 |
-
assert result
|
| 40 |
|
| 41 |
async def test_mount_new_arg_order_no_warning(self):
|
| 42 |
"""Test that mount(server, prefix) works without deprecation warning."""
|
|
@@ -122,7 +122,7 @@ class TestDeprecatedImportArgOrder:
|
|
| 122 |
# Test functionality
|
| 123 |
async with Client(main_app) as client:
|
| 124 |
result = await client.call_tool("sub_sub_tool", {})
|
| 125 |
-
assert result
|
| 126 |
|
| 127 |
async def test_import_new_arg_order_no_warning(self):
|
| 128 |
"""Test that import_server(server, prefix) works without deprecation warning."""
|
|
|
|
| 36 |
# Test functionality
|
| 37 |
async with Client(main_app) as client:
|
| 38 |
result = await client.call_tool("sub_sub_tool", {})
|
| 39 |
+
assert result.data == "Sub tool result"
|
| 40 |
|
| 41 |
async def test_mount_new_arg_order_no_warning(self):
|
| 42 |
"""Test that mount(server, prefix) works without deprecation warning."""
|
|
|
|
| 122 |
# Test functionality
|
| 123 |
async with Client(main_app) as client:
|
| 124 |
result = await client.call_tool("sub_sub_tool", {})
|
| 125 |
+
assert result.data == "Sub tool result"
|
| 126 |
|
| 127 |
async def test_import_new_arg_order_no_warning(self):
|
| 128 |
"""Test that import_server(server, prefix) works without deprecation warning."""
|
tests/server/http/test_http_dependencies.py
CHANGED
|
@@ -86,9 +86,8 @@ async def test_http_headers_tool_shttp(shttp_server: str):
|
|
| 86 |
)
|
| 87 |
) as client:
|
| 88 |
result = await client.call_tool("get_headers_tool")
|
| 89 |
-
|
| 90 |
-
assert "x-demo-header"
|
| 91 |
-
assert json_result["x-demo-header"] == "ABC"
|
| 92 |
|
| 93 |
|
| 94 |
async def test_http_headers_tool_sse(sse_server: str):
|
|
@@ -96,9 +95,8 @@ async def test_http_headers_tool_sse(sse_server: str):
|
|
| 96 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 97 |
) as client:
|
| 98 |
result = await client.call_tool("get_headers_tool")
|
| 99 |
-
|
| 100 |
-
assert "x-demo-header"
|
| 101 |
-
assert json_result["x-demo-header"] == "ABC"
|
| 102 |
|
| 103 |
|
| 104 |
async def test_http_headers_prompt_shttp(shttp_server: str):
|
|
|
|
| 86 |
)
|
| 87 |
) as client:
|
| 88 |
result = await client.call_tool("get_headers_tool")
|
| 89 |
+
assert "x-demo-header" in result.data
|
| 90 |
+
assert result.data["x-demo-header"] == "ABC"
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
async def test_http_headers_tool_sse(sse_server: str):
|
|
|
|
| 95 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 96 |
) as client:
|
| 97 |
result = await client.call_tool("get_headers_tool")
|
| 98 |
+
assert "x-demo-header" in result.data
|
| 99 |
+
assert result.data["x-demo-header"] == "ABC"
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
async def test_http_headers_prompt_shttp(shttp_server: str):
|
tests/server/openapi/test_openapi.py
CHANGED
|
@@ -269,9 +269,8 @@ class TestTools:
|
|
| 269 |
"create_user_users_post", {"name": "David", "active": False}
|
| 270 |
)
|
| 271 |
|
| 272 |
-
response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
|
| 273 |
expected_user = User(id=4, name="David", active=False).model_dump()
|
| 274 |
-
assert
|
| 275 |
|
| 276 |
# Check that the user was created via API
|
| 277 |
response = await api_client.get("/users")
|
|
@@ -298,9 +297,8 @@ class TestTools:
|
|
| 298 |
{"user_id": 1, "name": "XYZ"},
|
| 299 |
)
|
| 300 |
|
| 301 |
-
response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
|
| 302 |
expected_data = dict(id=1, name="XYZ", active=True)
|
| 303 |
-
assert
|
| 304 |
|
| 305 |
# Check that the user was updated via API
|
| 306 |
response = await api_client.get("/users")
|
|
@@ -332,10 +330,12 @@ class TestTools:
|
|
| 332 |
)
|
| 333 |
async with Client(mcp_server) as client:
|
| 334 |
tool_response = await client.call_tool("get_users_users_get", {})
|
| 335 |
-
assert
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
| 339 |
|
| 340 |
|
| 341 |
class TestResources:
|
|
@@ -729,12 +729,22 @@ class TestOpenAPI30Compatibility:
|
|
| 729 |
"createProduct", {"name": "New Product", "price": 39.99}
|
| 730 |
)
|
| 731 |
# Result should be a text content
|
| 732 |
-
assert len(result) == 1
|
| 733 |
-
product = json.loads(result[0].text) # type: ignore[attr-defined]
|
| 734 |
assert product["id"] == "p3"
|
| 735 |
assert product["name"] == "New Product"
|
| 736 |
assert product["price"] == 39.99
|
| 737 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
|
| 739 |
class TestOpenAPI31Compatibility:
|
| 740 |
"""Tests for compatibility with OpenAPI 3.1 specifications."""
|
|
@@ -905,12 +915,22 @@ class TestOpenAPI31Compatibility:
|
|
| 905 |
"createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
|
| 906 |
)
|
| 907 |
# Result should be a text content
|
| 908 |
-
assert len(result) == 1
|
| 909 |
-
order = json.loads(result[0].text) # type: ignore[attr-dict]
|
| 910 |
assert order["id"] == "o3"
|
| 911 |
assert order["customer"] == "Charlie"
|
| 912 |
assert order["items"] == ["item4", "item5"]
|
| 913 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
|
| 915 |
async def test_empty_query_parameters_not_sent(
|
| 916 |
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
|
|
|
| 269 |
"create_user_users_post", {"name": "David", "active": False}
|
| 270 |
)
|
| 271 |
|
|
|
|
| 272 |
expected_user = User(id=4, name="David", active=False).model_dump()
|
| 273 |
+
assert tool_response.data == expected_user
|
| 274 |
|
| 275 |
# Check that the user was created via API
|
| 276 |
response = await api_client.get("/users")
|
|
|
|
| 297 |
{"user_id": 1, "name": "XYZ"},
|
| 298 |
)
|
| 299 |
|
|
|
|
| 300 |
expected_data = dict(id=1, name="XYZ", active=True)
|
| 301 |
+
assert tool_response.data == expected_data
|
| 302 |
|
| 303 |
# Check that the user was updated via API
|
| 304 |
response = await api_client.get("/users")
|
|
|
|
| 330 |
)
|
| 331 |
async with Client(mcp_server) as client:
|
| 332 |
tool_response = await client.call_tool("get_users_users_get", {})
|
| 333 |
+
assert tool_response.data == {
|
| 334 |
+
"result": [
|
| 335 |
+
user.model_dump()
|
| 336 |
+
for user in sorted(users_db.values(), key=lambda x: x.id)
|
| 337 |
+
]
|
| 338 |
+
}
|
| 339 |
|
| 340 |
|
| 341 |
class TestResources:
|
|
|
|
| 729 |
"createProduct", {"name": "New Product", "price": 39.99}
|
| 730 |
)
|
| 731 |
# Result should be a text content
|
| 732 |
+
assert len(result.content) == 1
|
| 733 |
+
product = json.loads(result.content[0].text) # type: ignore[attr-defined]
|
| 734 |
assert product["id"] == "p3"
|
| 735 |
assert product["name"] == "New Product"
|
| 736 |
assert product["price"] == 39.99
|
| 737 |
|
| 738 |
+
assert result.structured_content is not None
|
| 739 |
+
assert result.structured_content["id"] == "p3"
|
| 740 |
+
assert result.structured_content["name"] == "New Product"
|
| 741 |
+
assert result.structured_content["price"] == 39.99
|
| 742 |
+
|
| 743 |
+
assert result.data is not None
|
| 744 |
+
assert result.data["id"] == "p3"
|
| 745 |
+
assert result.data["name"] == "New Product"
|
| 746 |
+
assert result.data["price"] == 39.99
|
| 747 |
+
|
| 748 |
|
| 749 |
class TestOpenAPI31Compatibility:
|
| 750 |
"""Tests for compatibility with OpenAPI 3.1 specifications."""
|
|
|
|
| 915 |
"createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
|
| 916 |
)
|
| 917 |
# Result should be a text content
|
| 918 |
+
assert len(result.content) == 1
|
| 919 |
+
order = json.loads(result.content[0].text) # type: ignore[attr-dict]
|
| 920 |
assert order["id"] == "o3"
|
| 921 |
assert order["customer"] == "Charlie"
|
| 922 |
assert order["items"] == ["item4", "item5"]
|
| 923 |
|
| 924 |
+
assert result.structured_content is not None
|
| 925 |
+
assert result.structured_content["id"] == "o3"
|
| 926 |
+
assert result.structured_content["customer"] == "Charlie"
|
| 927 |
+
assert result.structured_content["items"] == ["item4", "item5"]
|
| 928 |
+
|
| 929 |
+
assert result.data is not None
|
| 930 |
+
assert result.data["id"] == "o3"
|
| 931 |
+
assert result.data["customer"] == "Charlie"
|
| 932 |
+
assert result.data["items"] == ["item4", "item5"]
|
| 933 |
+
|
| 934 |
|
| 935 |
async def test_empty_query_parameters_not_sent(
|
| 936 |
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
tests/server/openapi/test_openapi_path_parameters.py
CHANGED
|
@@ -301,20 +301,11 @@ async def test_array_query_param_with_fastapi():
|
|
| 301 |
|
| 302 |
# Single day
|
| 303 |
result = await client.call_tool(tool_name, {"days": ["monday"]})
|
| 304 |
-
|
| 305 |
-
assert len(result) == 1
|
| 306 |
-
assert result[0].type == "text"
|
| 307 |
-
import json
|
| 308 |
-
|
| 309 |
-
result_data = json.loads(result[0].text)
|
| 310 |
-
assert result_data == {"selected": ["monday"]}
|
| 311 |
|
| 312 |
# Multiple days
|
| 313 |
result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
|
| 314 |
-
assert
|
| 315 |
-
assert result[0].type == "text"
|
| 316 |
-
result_data = json.loads(result[0].text)
|
| 317 |
-
assert result_data == {"selected": ["monday", "tuesday"]}
|
| 318 |
|
| 319 |
|
| 320 |
async def test_array_query_parameter_format(mock_client):
|
|
|
|
| 301 |
|
| 302 |
# Single day
|
| 303 |
result = await client.call_tool(tool_name, {"days": ["monday"]})
|
| 304 |
+
assert result.data == {"selected": ["monday"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
# Multiple days
|
| 307 |
result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
|
| 308 |
+
assert result.data == {"selected": ["monday", "tuesday"]}
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
|
| 311 |
async def test_array_query_parameter_format(mock_client):
|
tests/server/test_import_server.py
CHANGED
|
@@ -224,7 +224,7 @@ async def test_call_imported_custom_named_tool():
|
|
| 224 |
|
| 225 |
async with Client(main_app) as client:
|
| 226 |
result = await client.call_tool("api_get_data", {"query": "test"})
|
| 227 |
-
assert result
|
| 228 |
|
| 229 |
|
| 230 |
async def test_first_level_importing_with_custom_name():
|
|
@@ -278,7 +278,7 @@ async def test_call_nested_imported_tool():
|
|
| 278 |
|
| 279 |
async with Client(main_app) as client:
|
| 280 |
result = await client.call_tool("service_provider_compute", {"input": 21})
|
| 281 |
-
assert result
|
| 282 |
|
| 283 |
|
| 284 |
async def test_import_with_proxy_tools():
|
|
@@ -302,7 +302,7 @@ async def test_import_with_proxy_tools():
|
|
| 302 |
|
| 303 |
async with Client(main_app) as client:
|
| 304 |
result = await client.call_tool("api_get_data", {"query": "test"})
|
| 305 |
-
assert result
|
| 306 |
|
| 307 |
|
| 308 |
async def test_import_with_proxy_prompts():
|
|
@@ -443,7 +443,7 @@ async def test_import_with_no_prefix():
|
|
| 443 |
async with Client(main_app) as client:
|
| 444 |
# Test tool
|
| 445 |
tool_result = await client.call_tool("sub_tool", {})
|
| 446 |
-
assert tool_result
|
| 447 |
|
| 448 |
# Test resource
|
| 449 |
resource_result = await client.read_resource("data://config")
|
|
@@ -485,7 +485,7 @@ async def test_import_conflict_resolution_tools():
|
|
| 485 |
assert tool_names.count("shared_tool") == 1 # Should only appear once
|
| 486 |
|
| 487 |
result = await client.call_tool("shared_tool", {})
|
| 488 |
-
assert result
|
| 489 |
|
| 490 |
|
| 491 |
async def test_import_conflict_resolution_resources():
|
|
@@ -604,4 +604,4 @@ async def test_import_conflict_resolution_with_prefix():
|
|
| 604 |
assert tool_names.count("api_shared_tool") == 1 # Should only appear once
|
| 605 |
|
| 606 |
result = await client.call_tool("api_shared_tool", {})
|
| 607 |
-
assert result
|
|
|
|
| 224 |
|
| 225 |
async with Client(main_app) as client:
|
| 226 |
result = await client.call_tool("api_get_data", {"query": "test"})
|
| 227 |
+
assert result.data == "Data for query: test"
|
| 228 |
|
| 229 |
|
| 230 |
async def test_first_level_importing_with_custom_name():
|
|
|
|
| 278 |
|
| 279 |
async with Client(main_app) as client:
|
| 280 |
result = await client.call_tool("service_provider_compute", {"input": 21})
|
| 281 |
+
assert result.data == 42
|
| 282 |
|
| 283 |
|
| 284 |
async def test_import_with_proxy_tools():
|
|
|
|
| 302 |
|
| 303 |
async with Client(main_app) as client:
|
| 304 |
result = await client.call_tool("api_get_data", {"query": "test"})
|
| 305 |
+
assert result.data == "Data for query: test"
|
| 306 |
|
| 307 |
|
| 308 |
async def test_import_with_proxy_prompts():
|
|
|
|
| 443 |
async with Client(main_app) as client:
|
| 444 |
# Test tool
|
| 445 |
tool_result = await client.call_tool("sub_tool", {})
|
| 446 |
+
assert tool_result.data == "Sub tool result"
|
| 447 |
|
| 448 |
# Test resource
|
| 449 |
resource_result = await client.read_resource("data://config")
|
|
|
|
| 485 |
assert tool_names.count("shared_tool") == 1 # Should only appear once
|
| 486 |
|
| 487 |
result = await client.call_tool("shared_tool", {})
|
| 488 |
+
assert result.data == "Second app tool"
|
| 489 |
|
| 490 |
|
| 491 |
async def test_import_conflict_resolution_resources():
|
|
|
|
| 604 |
assert tool_names.count("api_shared_tool") == 1 # Should only appear once
|
| 605 |
|
| 606 |
result = await client.call_tool("api_shared_tool", {})
|
| 607 |
+
assert result.data == "Second app tool"
|
tests/server/test_mount.py
CHANGED
|
@@ -33,7 +33,7 @@ class TestBasicMount:
|
|
| 33 |
|
| 34 |
async with Client(main_app) as client:
|
| 35 |
result = await client.call_tool("sub_sub_tool", {})
|
| 36 |
-
assert result
|
| 37 |
|
| 38 |
async def test_mount_with_custom_separator(self):
|
| 39 |
"""Test mounting with a custom tool separator (deprecated but still supported)."""
|
|
@@ -52,8 +52,9 @@ class TestBasicMount:
|
|
| 52 |
assert "sub_greet" in tools
|
| 53 |
|
| 54 |
# Call the tool
|
| 55 |
-
|
| 56 |
-
|
|
|
|
| 57 |
|
| 58 |
async def test_mount_invalid_resource_prefix(self):
|
| 59 |
main_app = FastMCP("MainApp")
|
|
@@ -104,8 +105,9 @@ class TestBasicMount:
|
|
| 104 |
assert "sub_tool" in tools
|
| 105 |
|
| 106 |
# Call the tool to verify it works
|
| 107 |
-
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
async def test_mount_tools_no_prefix(self):
|
| 111 |
"""Test mounting a server with tools without prefix."""
|
|
@@ -124,8 +126,9 @@ class TestBasicMount:
|
|
| 124 |
assert "sub_tool" in tools
|
| 125 |
|
| 126 |
# Test actual functionality
|
| 127 |
-
|
| 128 |
-
|
|
|
|
| 129 |
|
| 130 |
async def test_mount_resources_no_prefix(self):
|
| 131 |
"""Test mounting a server with resources without prefix."""
|
|
@@ -144,8 +147,9 @@ class TestBasicMount:
|
|
| 144 |
assert "data://config" in resources
|
| 145 |
|
| 146 |
# Test actual functionality
|
| 147 |
-
|
| 148 |
-
|
|
|
|
| 149 |
|
| 150 |
async def test_mount_resource_templates_no_prefix(self):
|
| 151 |
"""Test mounting a server with resource templates without prefix."""
|
|
@@ -164,8 +168,9 @@ class TestBasicMount:
|
|
| 164 |
assert "users://{user_id}/info" in templates
|
| 165 |
|
| 166 |
# Test actual functionality
|
| 167 |
-
|
| 168 |
-
|
|
|
|
| 169 |
|
| 170 |
async def test_mount_prompts_no_prefix(self):
|
| 171 |
"""Test mounting a server with prompts without prefix."""
|
|
@@ -184,8 +189,9 @@ class TestBasicMount:
|
|
| 184 |
assert "sub_prompt" in prompts
|
| 185 |
|
| 186 |
# Test actual functionality
|
| 187 |
-
|
| 188 |
-
|
|
|
|
| 189 |
|
| 190 |
|
| 191 |
class TestMultipleServerMount:
|
|
@@ -215,11 +221,11 @@ class TestMultipleServerMount:
|
|
| 215 |
assert "news_get_headlines" in tools
|
| 216 |
|
| 217 |
# Call tools from both mounted servers
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
|
| 224 |
async def test_mount_same_prefix(self):
|
| 225 |
"""Test that mounting with the same prefix replaces the previous mount."""
|
|
@@ -292,7 +298,7 @@ class TestMultipleServerMount:
|
|
| 292 |
|
| 293 |
# Test calling a tool
|
| 294 |
result = await client.call_tool("working_working_tool", {})
|
| 295 |
-
assert result
|
| 296 |
|
| 297 |
# Test resources
|
| 298 |
resources = await client.list_resources()
|
|
@@ -352,7 +358,7 @@ class TestPrefixConflictResolution:
|
|
| 352 |
|
| 353 |
# Test that calling the tool uses the later server's implementation
|
| 354 |
result = await client.call_tool("shared_tool", {})
|
| 355 |
-
assert result
|
| 356 |
|
| 357 |
async def test_later_server_wins_tools_same_prefix(self):
|
| 358 |
"""Test that later mounted server wins for tools when same prefix is used."""
|
|
@@ -381,7 +387,7 @@ class TestPrefixConflictResolution:
|
|
| 381 |
|
| 382 |
# Test that calling the tool uses the later server's implementation
|
| 383 |
result = await client.call_tool("api_shared_tool", {})
|
| 384 |
-
assert result
|
| 385 |
|
| 386 |
async def test_later_server_wins_resources_no_prefix(self):
|
| 387 |
"""Test that later mounted server wins for resources when no prefix is used."""
|
|
@@ -593,8 +599,9 @@ class TestDynamicChanges:
|
|
| 593 |
assert "sub_dynamic_tool" in tools
|
| 594 |
|
| 595 |
# Call the dynamically added tool
|
| 596 |
-
|
| 597 |
-
|
|
|
|
| 598 |
|
| 599 |
async def test_removing_tool_after_mounting(self):
|
| 600 |
"""Test that tools removed from mounted servers are no longer accessible."""
|
|
@@ -726,8 +733,9 @@ class TestPrompts:
|
|
| 726 |
assert "assistant_greeting" in prompts
|
| 727 |
|
| 728 |
# Render the prompt
|
| 729 |
-
|
| 730 |
-
|
|
|
|
| 731 |
# The message should contain our greeting text
|
| 732 |
|
| 733 |
async def test_adding_prompt_after_mounting(self):
|
|
@@ -748,8 +756,9 @@ class TestPrompts:
|
|
| 748 |
assert "assistant_farewell" in prompts
|
| 749 |
|
| 750 |
# Render the prompt
|
| 751 |
-
|
| 752 |
-
|
|
|
|
| 753 |
# The message should contain our farewell text
|
| 754 |
|
| 755 |
|
|
@@ -779,8 +788,9 @@ class TestProxyServer:
|
|
| 779 |
assert "proxy_get_data" in tools
|
| 780 |
|
| 781 |
# Call the tool
|
| 782 |
-
|
| 783 |
-
|
|
|
|
| 784 |
|
| 785 |
async def test_dynamically_adding_to_proxied_server(self):
|
| 786 |
"""Test that changes to the original server are reflected in the mounted proxy."""
|
|
@@ -806,8 +816,9 @@ class TestProxyServer:
|
|
| 806 |
assert "proxy_dynamic_data" in tools
|
| 807 |
|
| 808 |
# Call the tool
|
| 809 |
-
|
| 810 |
-
|
|
|
|
| 811 |
|
| 812 |
async def test_proxy_server_with_resources(self):
|
| 813 |
"""Test mounting a proxy server with resources."""
|
|
@@ -828,9 +839,10 @@ class TestProxyServer:
|
|
| 828 |
main_app.mount(proxy_server, "proxy")
|
| 829 |
|
| 830 |
# Resource should be accessible through main app
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
|
|
|
| 834 |
|
| 835 |
async def test_proxy_server_with_prompts(self):
|
| 836 |
"""Test mounting a proxy server with prompts."""
|
|
@@ -851,8 +863,9 @@ class TestProxyServer:
|
|
| 851 |
main_app.mount(proxy_server, "proxy")
|
| 852 |
|
| 853 |
# Prompt should be accessible through main app
|
| 854 |
-
|
| 855 |
-
|
|
|
|
| 856 |
# The message should contain our welcome text
|
| 857 |
|
| 858 |
|
|
|
|
| 33 |
|
| 34 |
async with Client(main_app) as client:
|
| 35 |
result = await client.call_tool("sub_sub_tool", {})
|
| 36 |
+
assert result.data == "This is from the sub app"
|
| 37 |
|
| 38 |
async def test_mount_with_custom_separator(self):
|
| 39 |
"""Test mounting with a custom tool separator (deprecated but still supported)."""
|
|
|
|
| 52 |
assert "sub_greet" in tools
|
| 53 |
|
| 54 |
# Call the tool
|
| 55 |
+
async with Client(main_app) as client:
|
| 56 |
+
result = await client.call_tool("sub_greet", {"name": "World"})
|
| 57 |
+
assert result.data == "Hello, World!"
|
| 58 |
|
| 59 |
async def test_mount_invalid_resource_prefix(self):
|
| 60 |
main_app = FastMCP("MainApp")
|
|
|
|
| 105 |
assert "sub_tool" in tools
|
| 106 |
|
| 107 |
# Call the tool to verify it works
|
| 108 |
+
async with Client(main_app) as client:
|
| 109 |
+
result = await client.call_tool("sub_tool", {})
|
| 110 |
+
assert result.data == "This is from the sub app"
|
| 111 |
|
| 112 |
async def test_mount_tools_no_prefix(self):
|
| 113 |
"""Test mounting a server with tools without prefix."""
|
|
|
|
| 126 |
assert "sub_tool" in tools
|
| 127 |
|
| 128 |
# Test actual functionality
|
| 129 |
+
async with Client(main_app) as client:
|
| 130 |
+
tool_result = await client.call_tool("sub_tool", {})
|
| 131 |
+
assert tool_result.data == "Sub tool result"
|
| 132 |
|
| 133 |
async def test_mount_resources_no_prefix(self):
|
| 134 |
"""Test mounting a server with resources without prefix."""
|
|
|
|
| 147 |
assert "data://config" in resources
|
| 148 |
|
| 149 |
# Test actual functionality
|
| 150 |
+
async with Client(main_app) as client:
|
| 151 |
+
resource_result = await client.read_resource("data://config")
|
| 152 |
+
assert resource_result[0].text == "Sub resource data" # type: ignore[attr-defined]
|
| 153 |
|
| 154 |
async def test_mount_resource_templates_no_prefix(self):
|
| 155 |
"""Test mounting a server with resource templates without prefix."""
|
|
|
|
| 168 |
assert "users://{user_id}/info" in templates
|
| 169 |
|
| 170 |
# Test actual functionality
|
| 171 |
+
async with Client(main_app) as client:
|
| 172 |
+
template_result = await client.read_resource("users://123/info")
|
| 173 |
+
assert template_result[0].text == "Sub template for user 123" # type: ignore[attr-defined]
|
| 174 |
|
| 175 |
async def test_mount_prompts_no_prefix(self):
|
| 176 |
"""Test mounting a server with prompts without prefix."""
|
|
|
|
| 189 |
assert "sub_prompt" in prompts
|
| 190 |
|
| 191 |
# Test actual functionality
|
| 192 |
+
async with Client(main_app) as client:
|
| 193 |
+
prompt_result = await client.get_prompt("sub_prompt", {})
|
| 194 |
+
assert prompt_result.messages is not None
|
| 195 |
|
| 196 |
|
| 197 |
class TestMultipleServerMount:
|
|
|
|
| 221 |
assert "news_get_headlines" in tools
|
| 222 |
|
| 223 |
# Call tools from both mounted servers
|
| 224 |
+
async with Client(main_app) as client:
|
| 225 |
+
result1 = await client.call_tool("weather_get_forecast", {})
|
| 226 |
+
assert result1.data == "Weather forecast"
|
| 227 |
+
result2 = await client.call_tool("news_get_headlines", {})
|
| 228 |
+
assert result2.data == "News headlines"
|
| 229 |
|
| 230 |
async def test_mount_same_prefix(self):
|
| 231 |
"""Test that mounting with the same prefix replaces the previous mount."""
|
|
|
|
| 298 |
|
| 299 |
# Test calling a tool
|
| 300 |
result = await client.call_tool("working_working_tool", {})
|
| 301 |
+
assert result.data == "Working tool"
|
| 302 |
|
| 303 |
# Test resources
|
| 304 |
resources = await client.list_resources()
|
|
|
|
| 358 |
|
| 359 |
# Test that calling the tool uses the later server's implementation
|
| 360 |
result = await client.call_tool("shared_tool", {})
|
| 361 |
+
assert result.data == "Second app tool"
|
| 362 |
|
| 363 |
async def test_later_server_wins_tools_same_prefix(self):
|
| 364 |
"""Test that later mounted server wins for tools when same prefix is used."""
|
|
|
|
| 387 |
|
| 388 |
# Test that calling the tool uses the later server's implementation
|
| 389 |
result = await client.call_tool("api_shared_tool", {})
|
| 390 |
+
assert result.data == "Second app tool"
|
| 391 |
|
| 392 |
async def test_later_server_wins_resources_no_prefix(self):
|
| 393 |
"""Test that later mounted server wins for resources when no prefix is used."""
|
|
|
|
| 599 |
assert "sub_dynamic_tool" in tools
|
| 600 |
|
| 601 |
# Call the dynamically added tool
|
| 602 |
+
async with Client(main_app) as client:
|
| 603 |
+
result = await client.call_tool("sub_dynamic_tool", {})
|
| 604 |
+
assert result.data == "Added after mounting"
|
| 605 |
|
| 606 |
async def test_removing_tool_after_mounting(self):
|
| 607 |
"""Test that tools removed from mounted servers are no longer accessible."""
|
|
|
|
| 733 |
assert "assistant_greeting" in prompts
|
| 734 |
|
| 735 |
# Render the prompt
|
| 736 |
+
async with Client(main_app) as client:
|
| 737 |
+
result = await client.get_prompt("assistant_greeting", {"name": "World"})
|
| 738 |
+
assert result.messages is not None
|
| 739 |
# The message should contain our greeting text
|
| 740 |
|
| 741 |
async def test_adding_prompt_after_mounting(self):
|
|
|
|
| 756 |
assert "assistant_farewell" in prompts
|
| 757 |
|
| 758 |
# Render the prompt
|
| 759 |
+
async with Client(main_app) as client:
|
| 760 |
+
result = await client.get_prompt("assistant_farewell", {"name": "World"})
|
| 761 |
+
assert result.messages is not None
|
| 762 |
# The message should contain our farewell text
|
| 763 |
|
| 764 |
|
|
|
|
| 788 |
assert "proxy_get_data" in tools
|
| 789 |
|
| 790 |
# Call the tool
|
| 791 |
+
async with Client(main_app) as client:
|
| 792 |
+
result = await client.call_tool("proxy_get_data", {"query": "test"})
|
| 793 |
+
assert result.data == "Data for test"
|
| 794 |
|
| 795 |
async def test_dynamically_adding_to_proxied_server(self):
|
| 796 |
"""Test that changes to the original server are reflected in the mounted proxy."""
|
|
|
|
| 816 |
assert "proxy_dynamic_data" in tools
|
| 817 |
|
| 818 |
# Call the tool
|
| 819 |
+
async with Client(main_app) as client:
|
| 820 |
+
result = await client.call_tool("proxy_dynamic_data", {})
|
| 821 |
+
assert result.data == "Dynamic data"
|
| 822 |
|
| 823 |
async def test_proxy_server_with_resources(self):
|
| 824 |
"""Test mounting a proxy server with resources."""
|
|
|
|
| 839 |
main_app.mount(proxy_server, "proxy")
|
| 840 |
|
| 841 |
# Resource should be accessible through main app
|
| 842 |
+
async with Client(main_app) as client:
|
| 843 |
+
result = await client.read_resource("config://proxy/settings")
|
| 844 |
+
config = json.loads(result[0].text) # type: ignore[attr-defined]
|
| 845 |
+
assert config["api_key"] == "12345"
|
| 846 |
|
| 847 |
async def test_proxy_server_with_prompts(self):
|
| 848 |
"""Test mounting a proxy server with prompts."""
|
|
|
|
| 863 |
main_app.mount(proxy_server, "proxy")
|
| 864 |
|
| 865 |
# Prompt should be accessible through main app
|
| 866 |
+
async with Client(main_app) as client:
|
| 867 |
+
result = await client.get_prompt("proxy_welcome", {"name": "World"})
|
| 868 |
+
assert result.messages is not None
|
| 869 |
# The message should contain our welcome text
|
| 870 |
|
| 871 |
|
tests/server/test_proxy.py
CHANGED
|
@@ -89,15 +89,17 @@ async def test_create_proxy(fastmcp_server):
|
|
| 89 |
async def test_as_proxy_with_server(fastmcp_server):
|
| 90 |
"""FastMCP.as_proxy should accept a FastMCP instance."""
|
| 91 |
proxy = FastMCP.as_proxy(fastmcp_server)
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
async def test_as_proxy_with_transport(fastmcp_server):
|
| 97 |
"""FastMCP.as_proxy should accept a ClientTransport."""
|
| 98 |
proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
|
| 99 |
-
|
| 100 |
-
|
|
|
|
| 101 |
|
| 102 |
|
| 103 |
def test_as_proxy_with_url():
|
|
@@ -137,7 +139,7 @@ class TestTools:
|
|
| 137 |
async def test_call_tool_calls_tool(self, proxy_server):
|
| 138 |
async with Client(proxy_server) as client:
|
| 139 |
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 140 |
-
assert proxy_result
|
| 141 |
|
| 142 |
async def test_error_tool_raises_error(self, proxy_server):
|
| 143 |
with pytest.raises(ToolError, match="This is a test error"):
|
|
@@ -155,7 +157,7 @@ class TestTools:
|
|
| 155 |
|
| 156 |
async with Client(proxy_server) as client:
|
| 157 |
result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
|
| 158 |
-
assert result
|
| 159 |
|
| 160 |
async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server):
|
| 161 |
"""
|
|
|
|
| 89 |
async def test_as_proxy_with_server(fastmcp_server):
|
| 90 |
"""FastMCP.as_proxy should accept a FastMCP instance."""
|
| 91 |
proxy = FastMCP.as_proxy(fastmcp_server)
|
| 92 |
+
async with Client(proxy) as client:
|
| 93 |
+
result = await client.call_tool("greet", {"name": "Test"})
|
| 94 |
+
assert result.data == "Hello, Test!"
|
| 95 |
|
| 96 |
|
| 97 |
async def test_as_proxy_with_transport(fastmcp_server):
|
| 98 |
"""FastMCP.as_proxy should accept a ClientTransport."""
|
| 99 |
proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
|
| 100 |
+
async with Client(proxy) as client:
|
| 101 |
+
result = await client.call_tool("greet", {"name": "Test"})
|
| 102 |
+
assert result.data == "Hello, Test!"
|
| 103 |
|
| 104 |
|
| 105 |
def test_as_proxy_with_url():
|
|
|
|
| 139 |
async def test_call_tool_calls_tool(self, proxy_server):
|
| 140 |
async with Client(proxy_server) as client:
|
| 141 |
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 142 |
+
assert proxy_result.data == 3
|
| 143 |
|
| 144 |
async def test_error_tool_raises_error(self, proxy_server):
|
| 145 |
with pytest.raises(ToolError, match="This is a test error"):
|
|
|
|
| 157 |
|
| 158 |
async with Client(proxy_server) as client:
|
| 159 |
result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
|
| 160 |
+
assert result.data == "Overwritten, Marvin! abc"
|
| 161 |
|
| 162 |
async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server):
|
| 163 |
"""
|
tests/server/test_server.py
CHANGED
|
@@ -45,9 +45,7 @@ class TestCreateServer:
|
|
| 45 |
assert "🎉" in tool.description
|
| 46 |
|
| 47 |
result = await client.call_tool("hello_world", {})
|
| 48 |
-
assert
|
| 49 |
-
content = result[0]
|
| 50 |
-
assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined]
|
| 51 |
|
| 52 |
|
| 53 |
class TestTools:
|
|
@@ -129,8 +127,9 @@ class TestToolDecorator:
|
|
| 129 |
def add(x: int, y: int) -> int:
|
| 130 |
return x + y
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
|
|
|
| 134 |
|
| 135 |
async def test_tool_decorator_without_parentheses(self):
|
| 136 |
"""Test that @tool decorator works without parentheses."""
|
|
@@ -146,8 +145,9 @@ class TestToolDecorator:
|
|
| 146 |
assert "add" in tools
|
| 147 |
|
| 148 |
# Verify it can be called
|
| 149 |
-
|
| 150 |
-
|
|
|
|
| 151 |
|
| 152 |
async def test_tool_decorator_with_name(self):
|
| 153 |
mcp = FastMCP()
|
|
@@ -156,8 +156,9 @@ class TestToolDecorator:
|
|
| 156 |
def add(x: int, y: int) -> int:
|
| 157 |
return x + y
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
|
|
|
| 161 |
|
| 162 |
async def test_tool_decorator_with_description(self):
|
| 163 |
mcp = FastMCP()
|
|
@@ -183,8 +184,9 @@ class TestToolDecorator:
|
|
| 183 |
|
| 184 |
obj = MyClass(10)
|
| 185 |
mcp.add_tool(Tool.from_function(obj.add))
|
| 186 |
-
|
| 187 |
-
|
|
|
|
| 188 |
|
| 189 |
async def test_tool_decorator_classmethod(self):
|
| 190 |
mcp = FastMCP()
|
|
@@ -197,8 +199,9 @@ class TestToolDecorator:
|
|
| 197 |
return cls.x + y
|
| 198 |
|
| 199 |
mcp.add_tool(Tool.from_function(MyClass.add))
|
| 200 |
-
|
| 201 |
-
|
|
|
|
| 202 |
|
| 203 |
async def test_tool_decorator_staticmethod(self):
|
| 204 |
mcp = FastMCP()
|
|
@@ -209,8 +212,9 @@ class TestToolDecorator:
|
|
| 209 |
def add(x: int, y: int) -> int:
|
| 210 |
return x + y
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
|
|
|
| 214 |
|
| 215 |
async def test_tool_decorator_async_function(self):
|
| 216 |
mcp = FastMCP()
|
|
@@ -219,8 +223,9 @@ class TestToolDecorator:
|
|
| 219 |
async def add(x: int, y: int) -> int:
|
| 220 |
return x + y
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
|
|
|
| 224 |
|
| 225 |
async def test_tool_decorator_classmethod_error(self):
|
| 226 |
mcp = FastMCP()
|
|
@@ -244,8 +249,9 @@ class TestToolDecorator:
|
|
| 244 |
return cls.x + y
|
| 245 |
|
| 246 |
mcp.add_tool(Tool.from_function(MyClass.add))
|
| 247 |
-
|
| 248 |
-
|
|
|
|
| 249 |
|
| 250 |
async def test_tool_decorator_staticmethod_async_function(self):
|
| 251 |
mcp = FastMCP()
|
|
@@ -256,8 +262,9 @@ class TestToolDecorator:
|
|
| 256 |
return x + y
|
| 257 |
|
| 258 |
mcp.add_tool(Tool.from_function(MyClass.add))
|
| 259 |
-
|
| 260 |
-
|
|
|
|
| 261 |
|
| 262 |
async def test_tool_decorator_staticmethod_order(self):
|
| 263 |
"""Test that the recommended decorator order works for static methods"""
|
|
@@ -270,8 +277,9 @@ class TestToolDecorator:
|
|
| 270 |
return x + y
|
| 271 |
|
| 272 |
# Test that the recommended order works
|
| 273 |
-
|
| 274 |
-
|
|
|
|
| 275 |
|
| 276 |
async def test_tool_decorator_with_tags(self):
|
| 277 |
"""Test that the tool decorator properly sets tags."""
|
|
@@ -301,8 +309,9 @@ class TestToolDecorator:
|
|
| 301 |
assert "custom_multiply" in tools
|
| 302 |
|
| 303 |
# Call the tool by its custom name
|
| 304 |
-
|
| 305 |
-
|
|
|
|
| 306 |
|
| 307 |
# Original name should not be registered
|
| 308 |
assert "multiply" not in tools
|
|
@@ -356,8 +365,9 @@ class TestToolDecorator:
|
|
| 356 |
assert tools["direct_call_tool"] is result_fn
|
| 357 |
|
| 358 |
# Verify it can be called
|
| 359 |
-
|
| 360 |
-
|
|
|
|
| 361 |
|
| 362 |
async def test_tool_decorator_with_string_name(self):
|
| 363 |
"""Test that @tool("custom_name") syntax works correctly."""
|
|
@@ -374,8 +384,9 @@ class TestToolDecorator:
|
|
| 374 |
assert "my_function" not in tools # Original name should not be registered
|
| 375 |
|
| 376 |
# Verify it can be called
|
| 377 |
-
|
| 378 |
-
|
|
|
|
| 379 |
|
| 380 |
async def test_tool_decorator_conflicting_names_error(self):
|
| 381 |
"""Test that providing both positional and keyword name raises an error."""
|
|
@@ -390,6 +401,17 @@ class TestToolDecorator:
|
|
| 390 |
def my_function(x: int) -> str:
|
| 391 |
return f"Result: {x}"
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
class TestResourceDecorator:
|
| 395 |
async def test_no_resources_before_decorator(self):
|
|
|
|
| 45 |
assert "🎉" in tool.description
|
| 46 |
|
| 47 |
result = await client.call_tool("hello_world", {})
|
| 48 |
+
assert result.data == "¡Hola, 世界! 👋"
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class TestTools:
|
|
|
|
| 127 |
def add(x: int, y: int) -> int:
|
| 128 |
return x + y
|
| 129 |
|
| 130 |
+
async with Client(mcp) as client:
|
| 131 |
+
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 132 |
+
assert result.data == 3
|
| 133 |
|
| 134 |
async def test_tool_decorator_without_parentheses(self):
|
| 135 |
"""Test that @tool decorator works without parentheses."""
|
|
|
|
| 145 |
assert "add" in tools
|
| 146 |
|
| 147 |
# Verify it can be called
|
| 148 |
+
async with Client(mcp) as client:
|
| 149 |
+
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 150 |
+
assert result.data == 3
|
| 151 |
|
| 152 |
async def test_tool_decorator_with_name(self):
|
| 153 |
mcp = FastMCP()
|
|
|
|
| 156 |
def add(x: int, y: int) -> int:
|
| 157 |
return x + y
|
| 158 |
|
| 159 |
+
async with Client(mcp) as client:
|
| 160 |
+
result = await client.call_tool("custom-add", {"x": 1, "y": 2})
|
| 161 |
+
assert result.data == 3
|
| 162 |
|
| 163 |
async def test_tool_decorator_with_description(self):
|
| 164 |
mcp = FastMCP()
|
|
|
|
| 184 |
|
| 185 |
obj = MyClass(10)
|
| 186 |
mcp.add_tool(Tool.from_function(obj.add))
|
| 187 |
+
async with Client(mcp) as client:
|
| 188 |
+
result = await client.call_tool("add", {"y": 2})
|
| 189 |
+
assert result.data == 12
|
| 190 |
|
| 191 |
async def test_tool_decorator_classmethod(self):
|
| 192 |
mcp = FastMCP()
|
|
|
|
| 199 |
return cls.x + y
|
| 200 |
|
| 201 |
mcp.add_tool(Tool.from_function(MyClass.add))
|
| 202 |
+
async with Client(mcp) as client:
|
| 203 |
+
result = await client.call_tool("add", {"y": 2})
|
| 204 |
+
assert result.data == 12
|
| 205 |
|
| 206 |
async def test_tool_decorator_staticmethod(self):
|
| 207 |
mcp = FastMCP()
|
|
|
|
| 212 |
def add(x: int, y: int) -> int:
|
| 213 |
return x + y
|
| 214 |
|
| 215 |
+
async with Client(mcp) as client:
|
| 216 |
+
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 217 |
+
assert result.data == 3
|
| 218 |
|
| 219 |
async def test_tool_decorator_async_function(self):
|
| 220 |
mcp = FastMCP()
|
|
|
|
| 223 |
async def add(x: int, y: int) -> int:
|
| 224 |
return x + y
|
| 225 |
|
| 226 |
+
async with Client(mcp) as client:
|
| 227 |
+
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 228 |
+
assert result.data == 3
|
| 229 |
|
| 230 |
async def test_tool_decorator_classmethod_error(self):
|
| 231 |
mcp = FastMCP()
|
|
|
|
| 249 |
return cls.x + y
|
| 250 |
|
| 251 |
mcp.add_tool(Tool.from_function(MyClass.add))
|
| 252 |
+
async with Client(mcp) as client:
|
| 253 |
+
result = await client.call_tool("add", {"y": 2})
|
| 254 |
+
assert result.data == 12
|
| 255 |
|
| 256 |
async def test_tool_decorator_staticmethod_async_function(self):
|
| 257 |
mcp = FastMCP()
|
|
|
|
| 262 |
return x + y
|
| 263 |
|
| 264 |
mcp.add_tool(Tool.from_function(MyClass.add))
|
| 265 |
+
async with Client(mcp) as client:
|
| 266 |
+
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 267 |
+
assert result.data == 3
|
| 268 |
|
| 269 |
async def test_tool_decorator_staticmethod_order(self):
|
| 270 |
"""Test that the recommended decorator order works for static methods"""
|
|
|
|
| 277 |
return x + y
|
| 278 |
|
| 279 |
# Test that the recommended order works
|
| 280 |
+
async with Client(mcp) as client:
|
| 281 |
+
result = await client.call_tool("add_v1", {"x": 1, "y": 2})
|
| 282 |
+
assert result.data == 3
|
| 283 |
|
| 284 |
async def test_tool_decorator_with_tags(self):
|
| 285 |
"""Test that the tool decorator properly sets tags."""
|
|
|
|
| 309 |
assert "custom_multiply" in tools
|
| 310 |
|
| 311 |
# Call the tool by its custom name
|
| 312 |
+
async with Client(mcp) as client:
|
| 313 |
+
result = await client.call_tool("custom_multiply", {"a": 5, "b": 3})
|
| 314 |
+
assert result.data == 15
|
| 315 |
|
| 316 |
# Original name should not be registered
|
| 317 |
assert "multiply" not in tools
|
|
|
|
| 365 |
assert tools["direct_call_tool"] is result_fn
|
| 366 |
|
| 367 |
# Verify it can be called
|
| 368 |
+
async with Client(mcp) as client:
|
| 369 |
+
result = await client.call_tool("direct_call_tool", {"x": 5, "y": 3})
|
| 370 |
+
assert result.data == 8
|
| 371 |
|
| 372 |
async def test_tool_decorator_with_string_name(self):
|
| 373 |
"""Test that @tool("custom_name") syntax works correctly."""
|
|
|
|
| 384 |
assert "my_function" not in tools # Original name should not be registered
|
| 385 |
|
| 386 |
# Verify it can be called
|
| 387 |
+
async with Client(mcp) as client:
|
| 388 |
+
result = await client.call_tool("string_named_tool", {"x": 42})
|
| 389 |
+
assert result.data == "Result: 42"
|
| 390 |
|
| 391 |
async def test_tool_decorator_conflicting_names_error(self):
|
| 392 |
"""Test that providing both positional and keyword name raises an error."""
|
|
|
|
| 401 |
def my_function(x: int) -> str:
|
| 402 |
return f"Result: {x}"
|
| 403 |
|
| 404 |
+
async def test_tool_decorator_with_output_schema(self):
|
| 405 |
+
mcp = FastMCP()
|
| 406 |
+
|
| 407 |
+
with pytest.raises(
|
| 408 |
+
ValueError, match='Output schemas must have "type" set to "object"'
|
| 409 |
+
):
|
| 410 |
+
|
| 411 |
+
@mcp.tool(output_schema={"type": "integer"})
|
| 412 |
+
def my_function(x: int) -> str:
|
| 413 |
+
return f"Result: {x}"
|
| 414 |
+
|
| 415 |
|
| 416 |
class TestResourceDecorator:
|
| 417 |
async def test_no_resources_before_decorator(self):
|
tests/server/test_server_interactions.py
CHANGED
|
@@ -2,11 +2,11 @@ import base64
|
|
| 2 |
import datetime
|
| 3 |
import json
|
| 4 |
import uuid
|
|
|
|
| 5 |
from enum import Enum
|
| 6 |
from pathlib import Path
|
| 7 |
-
from typing import Annotated, Literal
|
| 8 |
|
| 9 |
-
import pydantic_core
|
| 10 |
import pytest
|
| 11 |
from mcp import McpError
|
| 12 |
from mcp.types import (
|
|
@@ -17,7 +17,8 @@ from mcp.types import (
|
|
| 17 |
TextContent,
|
| 18 |
TextResourceContents,
|
| 19 |
)
|
| 20 |
-
from pydantic import AnyUrl, Field
|
|
|
|
| 21 |
|
| 22 |
from fastmcp import Client, Context, FastMCP
|
| 23 |
from fastmcp.client.transports import FastMCPTransport
|
|
@@ -25,10 +26,26 @@ from fastmcp.exceptions import ToolError
|
|
| 25 |
from fastmcp.prompts.prompt import Prompt, PromptMessage
|
| 26 |
from fastmcp.resources import FileResource, ResourceTemplate
|
| 27 |
from fastmcp.resources.resource import FunctionResource
|
| 28 |
-
from fastmcp.tools.tool import Tool
|
| 29 |
from fastmcp.utilities.types import Audio, File, Image
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
@pytest.fixture
|
| 33 |
def tool_server():
|
| 34 |
mcp = FastMCP()
|
|
@@ -72,7 +89,7 @@ def tool_server():
|
|
| 72 |
),
|
| 73 |
]
|
| 74 |
|
| 75 |
-
@mcp.tool
|
| 76 |
def mixed_list_fn(image_path: str) -> list:
|
| 77 |
return [
|
| 78 |
"text message",
|
|
@@ -81,7 +98,7 @@ def tool_server():
|
|
| 81 |
TextContent(type="text", text="direct content"),
|
| 82 |
]
|
| 83 |
|
| 84 |
-
@mcp.tool
|
| 85 |
def mixed_audio_list_fn(audio_path: str) -> list:
|
| 86 |
return [
|
| 87 |
"text message",
|
|
@@ -90,7 +107,7 @@ def tool_server():
|
|
| 90 |
TextContent(type="text", text="direct content"),
|
| 91 |
]
|
| 92 |
|
| 93 |
-
@mcp.tool
|
| 94 |
def mixed_file_list_fn(file_path: str) -> list:
|
| 95 |
return [
|
| 96 |
"text message",
|
|
@@ -117,26 +134,24 @@ class TestTools:
|
|
| 117 |
async with Client(tool_server) as client:
|
| 118 |
assert len(await client.list_tools()) == 11
|
| 119 |
|
| 120 |
-
async def
|
| 121 |
async with Client(tool_server) as client:
|
| 122 |
-
result = await client.
|
| 123 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 124 |
|
| 125 |
-
async def
|
| 126 |
async with Client(tool_server) as client:
|
| 127 |
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 128 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 129 |
|
| 130 |
async def test_call_tool_error(self, tool_server: FastMCP):
|
| 131 |
async with Client(tool_server) as client:
|
| 132 |
with pytest.raises(Exception):
|
| 133 |
await client.call_tool("error_tool", {})
|
| 134 |
|
| 135 |
-
async def test_call_tool_error_as_client(self, tool_server: FastMCP):
|
| 136 |
-
async with Client(tool_server) as client:
|
| 137 |
-
with pytest.raises(Exception):
|
| 138 |
-
await client.call_tool("error_tool", {})
|
| 139 |
-
|
| 140 |
async def test_call_tool_error_as_client_raw(self):
|
| 141 |
"""Test raising and catching errors from a tool."""
|
| 142 |
mcp = FastMCP()
|
|
@@ -154,13 +169,14 @@ class TestTools:
|
|
| 154 |
async def test_tool_returns_list(self, tool_server: FastMCP):
|
| 155 |
async with Client(tool_server) as client:
|
| 156 |
result = await client.call_tool("list_tool", {})
|
| 157 |
-
assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
|
|
|
|
| 158 |
|
| 159 |
async def test_file_text_tool(self, tool_server: FastMCP):
|
| 160 |
async with Client(tool_server) as client:
|
| 161 |
result = await client.call_tool("file_text_tool", {})
|
| 162 |
-
assert len(result) == 1
|
| 163 |
-
embedded = result[0]
|
| 164 |
assert isinstance(embedded, EmbeddedResource)
|
| 165 |
resource = embedded.resource
|
| 166 |
assert isinstance(resource, TextResourceContents)
|
|
@@ -222,7 +238,7 @@ class TestToolTags:
|
|
| 222 |
|
| 223 |
async with Client(mcp) as client:
|
| 224 |
result_1 = await client.call_tool("tool_1", {})
|
| 225 |
-
assert result_1
|
| 226 |
|
| 227 |
with pytest.raises(ToolError, match="Unknown tool"):
|
| 228 |
await client.call_tool("tool_2", {})
|
|
@@ -235,7 +251,7 @@ class TestToolTags:
|
|
| 235 |
await client.call_tool("tool_1", {})
|
| 236 |
|
| 237 |
result_2 = await client.call_tool("tool_2", {})
|
| 238 |
-
assert result_2
|
| 239 |
|
| 240 |
|
| 241 |
class TestToolReturnTypes:
|
|
@@ -248,7 +264,7 @@ class TestToolReturnTypes:
|
|
| 248 |
|
| 249 |
async with Client(mcp) as client:
|
| 250 |
result = await client.call_tool("string_tool", {})
|
| 251 |
-
assert result
|
| 252 |
|
| 253 |
async def test_bytes(self, tmp_path: Path):
|
| 254 |
mcp = FastMCP()
|
|
@@ -259,7 +275,7 @@ class TestToolReturnTypes:
|
|
| 259 |
|
| 260 |
async with Client(mcp) as client:
|
| 261 |
result = await client.call_tool("bytes_tool", {})
|
| 262 |
-
assert result
|
| 263 |
|
| 264 |
async def test_uuid(self):
|
| 265 |
mcp = FastMCP()
|
|
@@ -272,7 +288,7 @@ class TestToolReturnTypes:
|
|
| 272 |
|
| 273 |
async with Client(mcp) as client:
|
| 274 |
result = await client.call_tool("uuid_tool", {})
|
| 275 |
-
assert result
|
| 276 |
|
| 277 |
async def test_path(self):
|
| 278 |
mcp = FastMCP()
|
|
@@ -285,7 +301,7 @@ class TestToolReturnTypes:
|
|
| 285 |
|
| 286 |
async with Client(mcp) as client:
|
| 287 |
result = await client.call_tool("path_tool", {})
|
| 288 |
-
assert result
|
| 289 |
|
| 290 |
async def test_datetime(self):
|
| 291 |
mcp = FastMCP()
|
|
@@ -298,7 +314,7 @@ class TestToolReturnTypes:
|
|
| 298 |
|
| 299 |
async with Client(mcp) as client:
|
| 300 |
result = await client.call_tool("datetime_tool", {})
|
| 301 |
-
assert result
|
| 302 |
|
| 303 |
async def test_image(self, tmp_path: Path):
|
| 304 |
mcp = FastMCP()
|
|
@@ -313,7 +329,8 @@ class TestToolReturnTypes:
|
|
| 313 |
|
| 314 |
async with Client(mcp) as client:
|
| 315 |
result = await client.call_tool("image_tool", {"path": str(image_path)})
|
| 316 |
-
|
|
|
|
| 317 |
assert isinstance(content, ImageContent)
|
| 318 |
assert content.type == "image"
|
| 319 |
assert content.mimeType == "image/png"
|
|
@@ -334,7 +351,7 @@ class TestToolReturnTypes:
|
|
| 334 |
|
| 335 |
async with Client(mcp) as client:
|
| 336 |
result = await client.call_tool("audio_tool", {"path": str(audio_path)})
|
| 337 |
-
content = result[0]
|
| 338 |
assert isinstance(content, AudioContent)
|
| 339 |
assert content.type == "audio"
|
| 340 |
assert content.mimeType == "audio/wav"
|
|
@@ -355,7 +372,7 @@ class TestToolReturnTypes:
|
|
| 355 |
|
| 356 |
async with Client(mcp) as client:
|
| 357 |
result = await client.call_tool("file_tool", {"path": str(file_path)})
|
| 358 |
-
content = result[0]
|
| 359 |
assert isinstance(content, EmbeddedResource)
|
| 360 |
assert content.type == "resource"
|
| 361 |
resource = content.resource
|
|
@@ -371,10 +388,10 @@ class TestToolReturnTypes:
|
|
| 371 |
async def test_tool_mixed_content(self, tool_server: FastMCP):
|
| 372 |
async with Client(tool_server) as client:
|
| 373 |
result = await client.call_tool("mixed_content_tool", {})
|
| 374 |
-
assert len(result) == 3
|
| 375 |
-
content1 = result[0]
|
| 376 |
-
content2 = result[1]
|
| 377 |
-
content3 = result[2]
|
| 378 |
assert isinstance(content1, TextContent)
|
| 379 |
assert content1.text == "Hello"
|
| 380 |
assert isinstance(content2, ImageContent)
|
|
@@ -402,18 +419,18 @@ class TestToolReturnTypes:
|
|
| 402 |
result = await client.call_tool(
|
| 403 |
"mixed_list_fn", {"image_path": str(image_path)}
|
| 404 |
)
|
| 405 |
-
assert len(result) == 3
|
| 406 |
# Check text conversion
|
| 407 |
-
content1 = result[0]
|
| 408 |
assert isinstance(content1, TextContent)
|
| 409 |
assert json.loads(content1.text) == ["text message", {"key": "value"}]
|
| 410 |
# Check image conversion
|
| 411 |
-
content2 = result[1]
|
| 412 |
assert isinstance(content2, ImageContent)
|
| 413 |
assert content2.mimeType == "image/png"
|
| 414 |
assert base64.b64decode(content2.data) == b"test image data"
|
| 415 |
# Check direct TextContent
|
| 416 |
-
content3 = result[2]
|
| 417 |
assert isinstance(content3, TextContent)
|
| 418 |
assert content3.text == "direct content"
|
| 419 |
|
|
@@ -430,18 +447,18 @@ class TestToolReturnTypes:
|
|
| 430 |
result = await client.call_tool(
|
| 431 |
"mixed_audio_list_fn", {"audio_path": str(audio_path)}
|
| 432 |
)
|
| 433 |
-
assert len(result) == 3
|
| 434 |
# Check text conversion
|
| 435 |
-
content1 = result[0]
|
| 436 |
assert isinstance(content1, TextContent)
|
| 437 |
assert json.loads(content1.text) == ["text message", {"key": "value"}]
|
| 438 |
# Check audio conversion
|
| 439 |
-
content2 = result[1]
|
| 440 |
assert isinstance(content2, AudioContent)
|
| 441 |
assert content2.mimeType == "audio/wav"
|
| 442 |
assert base64.b64decode(content2.data) == b"test audio data"
|
| 443 |
# Check direct TextContent
|
| 444 |
-
content3 = result[2]
|
| 445 |
assert isinstance(content3, TextContent)
|
| 446 |
assert content3.text == "direct content"
|
| 447 |
|
|
@@ -458,13 +475,13 @@ class TestToolReturnTypes:
|
|
| 458 |
result = await client.call_tool(
|
| 459 |
"mixed_file_list_fn", {"file_path": str(file_path)}
|
| 460 |
)
|
| 461 |
-
assert len(result) == 3
|
| 462 |
# Check text conversion
|
| 463 |
-
content1 = result[0]
|
| 464 |
assert isinstance(content1, TextContent)
|
| 465 |
assert json.loads(content1.text) == ["text message", {"key": "value"}]
|
| 466 |
# Check file conversion
|
| 467 |
-
content2 = result[1]
|
| 468 |
assert isinstance(content2, EmbeddedResource)
|
| 469 |
assert content2.type == "resource"
|
| 470 |
resource = content2.resource
|
|
@@ -473,7 +490,7 @@ class TestToolReturnTypes:
|
|
| 473 |
blob_data = getattr(resource, "blob")
|
| 474 |
assert base64.b64decode(blob_data) == b"test file data"
|
| 475 |
# Check direct TextContent
|
| 476 |
-
content3 = result[2]
|
| 477 |
assert isinstance(content3, TextContent)
|
| 478 |
assert content3.text == "direct content"
|
| 479 |
|
|
@@ -540,9 +557,10 @@ class TestToolParameters:
|
|
| 540 |
result = await client.call_tool(
|
| 541 |
"process_image", {"image": b"fake png data"}
|
| 542 |
)
|
| 543 |
-
assert
|
| 544 |
-
assert result[0]
|
| 545 |
-
assert result[0].
|
|
|
|
| 546 |
|
| 547 |
async def test_tool_with_invalid_input(self):
|
| 548 |
mcp = FastMCP()
|
|
@@ -660,7 +678,7 @@ class TestToolParameters:
|
|
| 660 |
|
| 661 |
async with Client(mcp) as client:
|
| 662 |
result = await client.call_tool("analyze", {"x": "a"})
|
| 663 |
-
assert result
|
| 664 |
|
| 665 |
async def test_enum_type_validation_error(self):
|
| 666 |
mcp = FastMCP()
|
|
@@ -695,7 +713,7 @@ class TestToolParameters:
|
|
| 695 |
|
| 696 |
async with Client(mcp) as client:
|
| 697 |
result = await client.call_tool("analyze", {"x": "red"})
|
| 698 |
-
assert result
|
| 699 |
|
| 700 |
async def test_union_type_validation(self):
|
| 701 |
mcp = FastMCP()
|
|
@@ -706,10 +724,10 @@ class TestToolParameters:
|
|
| 706 |
|
| 707 |
async with Client(mcp) as client:
|
| 708 |
result = await client.call_tool("analyze", {"x": 1})
|
| 709 |
-
assert result
|
| 710 |
|
| 711 |
result = await client.call_tool("analyze", {"x": 1.0})
|
| 712 |
-
assert result
|
| 713 |
|
| 714 |
with pytest.raises(
|
| 715 |
ToolError,
|
|
@@ -730,7 +748,7 @@ class TestToolParameters:
|
|
| 730 |
|
| 731 |
async with Client(mcp) as client:
|
| 732 |
result = await client.call_tool("send_path", {"path": str(test_path)})
|
| 733 |
-
assert result
|
| 734 |
|
| 735 |
async def test_path_type_error(self):
|
| 736 |
mcp = FastMCP()
|
|
@@ -757,7 +775,7 @@ class TestToolParameters:
|
|
| 757 |
|
| 758 |
async with Client(mcp) as client:
|
| 759 |
result = await client.call_tool("send_uuid", {"x": test_uuid})
|
| 760 |
-
assert result
|
| 761 |
|
| 762 |
async def test_uuid_type_error(self):
|
| 763 |
mcp = FastMCP()
|
|
@@ -781,7 +799,7 @@ class TestToolParameters:
|
|
| 781 |
|
| 782 |
async with Client(mcp) as client:
|
| 783 |
result = await client.call_tool("send_datetime", {"x": dt})
|
| 784 |
-
assert result
|
| 785 |
|
| 786 |
async def test_datetime_type_parse_string(self):
|
| 787 |
mcp = FastMCP()
|
|
@@ -794,7 +812,7 @@ class TestToolParameters:
|
|
| 794 |
result = await client.call_tool(
|
| 795 |
"send_datetime", {"x": "2021-01-01T00:00:00"}
|
| 796 |
)
|
| 797 |
-
assert result
|
| 798 |
|
| 799 |
async def test_datetime_type_error(self):
|
| 800 |
mcp = FastMCP()
|
|
@@ -816,7 +834,7 @@ class TestToolParameters:
|
|
| 816 |
|
| 817 |
async with Client(mcp) as client:
|
| 818 |
result = await client.call_tool("send_date", {"x": datetime.date.today()})
|
| 819 |
-
assert result
|
| 820 |
|
| 821 |
async def test_date_type_parse_string(self):
|
| 822 |
mcp = FastMCP()
|
|
@@ -827,7 +845,7 @@ class TestToolParameters:
|
|
| 827 |
|
| 828 |
async with Client(mcp) as client:
|
| 829 |
result = await client.call_tool("send_date", {"x": "2021-01-01"})
|
| 830 |
-
assert result
|
| 831 |
|
| 832 |
async def test_timedelta_type(self):
|
| 833 |
mcp = FastMCP()
|
|
@@ -840,7 +858,7 @@ class TestToolParameters:
|
|
| 840 |
result = await client.call_tool(
|
| 841 |
"send_timedelta", {"x": datetime.timedelta(days=1)}
|
| 842 |
)
|
| 843 |
-
assert result
|
| 844 |
|
| 845 |
async def test_timedelta_type_parse_int(self):
|
| 846 |
"""Test that invalid timedelta input raises validation error."""
|
|
@@ -859,6 +877,270 @@ class TestToolParameters:
|
|
| 859 |
await client.call_tool("send_timedelta", {"x": 1000})
|
| 860 |
|
| 861 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
class TestToolContextInjection:
|
| 863 |
"""Test context injection in tools."""
|
| 864 |
|
|
@@ -887,9 +1169,7 @@ class TestToolContextInjection:
|
|
| 887 |
|
| 888 |
async with Client(mcp) as client:
|
| 889 |
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 890 |
-
assert
|
| 891 |
-
content = result[0]
|
| 892 |
-
assert content.text == "1" # type: ignore[attr-defined]
|
| 893 |
|
| 894 |
async def test_async_context(self):
|
| 895 |
"""Test that context works in async functions."""
|
|
@@ -902,9 +1182,7 @@ class TestToolContextInjection:
|
|
| 902 |
|
| 903 |
async with Client(mcp) as client:
|
| 904 |
result = await client.call_tool("async_tool", {"x": 42})
|
| 905 |
-
assert
|
| 906 |
-
content = result[0]
|
| 907 |
-
assert content.text == "Async request 1: 42" # type: ignore[attr-defined]
|
| 908 |
|
| 909 |
async def test_optional_context(self):
|
| 910 |
"""Test that context is optional."""
|
|
@@ -916,9 +1194,7 @@ class TestToolContextInjection:
|
|
| 916 |
|
| 917 |
async with Client(mcp) as client:
|
| 918 |
result = await client.call_tool("no_context", {"x": 21})
|
| 919 |
-
assert
|
| 920 |
-
content = result[0]
|
| 921 |
-
assert content.text == "42" # type: ignore[attr-defined]
|
| 922 |
|
| 923 |
async def test_context_resource_access(self):
|
| 924 |
"""Test that context can access resources."""
|
|
@@ -938,9 +1214,9 @@ class TestToolContextInjection:
|
|
| 938 |
|
| 939 |
async with Client(mcp) as client:
|
| 940 |
result = await client.call_tool("tool_with_resource", {})
|
| 941 |
-
assert
|
| 942 |
-
|
| 943 |
-
|
| 944 |
|
| 945 |
async def test_tool_decorator_with_tags(self):
|
| 946 |
"""Test that the tool decorator properly sets tags."""
|
|
@@ -968,7 +1244,7 @@ class TestToolContextInjection:
|
|
| 968 |
|
| 969 |
async with Client(mcp) as client:
|
| 970 |
result = await client.call_tool("MyTool", {"x": 2})
|
| 971 |
-
assert result
|
| 972 |
|
| 973 |
|
| 974 |
class TestToolEnabled:
|
|
|
|
| 2 |
import datetime
|
| 3 |
import json
|
| 4 |
import uuid
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
from enum import Enum
|
| 7 |
from pathlib import Path
|
| 8 |
+
from typing import Annotated, Any, Literal
|
| 9 |
|
|
|
|
| 10 |
import pytest
|
| 11 |
from mcp import McpError
|
| 12 |
from mcp.types import (
|
|
|
|
| 17 |
TextContent,
|
| 18 |
TextResourceContents,
|
| 19 |
)
|
| 20 |
+
from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
|
| 21 |
+
from typing_extensions import TypedDict
|
| 22 |
|
| 23 |
from fastmcp import Client, Context, FastMCP
|
| 24 |
from fastmcp.client.transports import FastMCPTransport
|
|
|
|
| 26 |
from fastmcp.prompts.prompt import Prompt, PromptMessage
|
| 27 |
from fastmcp.resources import FileResource, ResourceTemplate
|
| 28 |
from fastmcp.resources.resource import FunctionResource
|
| 29 |
+
from fastmcp.tools.tool import Tool, ToolResult
|
| 30 |
from fastmcp.utilities.types import Audio, File, Image
|
| 31 |
|
| 32 |
|
| 33 |
+
class PersonTypedDict(TypedDict):
|
| 34 |
+
name: str
|
| 35 |
+
age: int
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class PersonModel(BaseModel):
|
| 39 |
+
name: str
|
| 40 |
+
age: int
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class PersonDataclass:
|
| 45 |
+
name: str
|
| 46 |
+
age: int
|
| 47 |
+
|
| 48 |
+
|
| 49 |
@pytest.fixture
|
| 50 |
def tool_server():
|
| 51 |
mcp = FastMCP()
|
|
|
|
| 89 |
),
|
| 90 |
]
|
| 91 |
|
| 92 |
+
@mcp.tool(output_schema=None)
|
| 93 |
def mixed_list_fn(image_path: str) -> list:
|
| 94 |
return [
|
| 95 |
"text message",
|
|
|
|
| 98 |
TextContent(type="text", text="direct content"),
|
| 99 |
]
|
| 100 |
|
| 101 |
+
@mcp.tool(output_schema=None)
|
| 102 |
def mixed_audio_list_fn(audio_path: str) -> list:
|
| 103 |
return [
|
| 104 |
"text message",
|
|
|
|
| 107 |
TextContent(type="text", text="direct content"),
|
| 108 |
]
|
| 109 |
|
| 110 |
+
@mcp.tool(output_schema=None)
|
| 111 |
def mixed_file_list_fn(file_path: str) -> list:
|
| 112 |
return [
|
| 113 |
"text message",
|
|
|
|
| 134 |
async with Client(tool_server) as client:
|
| 135 |
assert len(await client.list_tools()) == 11
|
| 136 |
|
| 137 |
+
async def test_call_tool_mcp(self, tool_server: FastMCP):
|
| 138 |
async with Client(tool_server) as client:
|
| 139 |
+
result = await client.call_tool_mcp("add", {"x": 1, "y": 2})
|
| 140 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 141 |
+
assert result.structuredContent == {"result": 3}
|
| 142 |
|
| 143 |
+
async def test_call_tool(self, tool_server: FastMCP):
|
| 144 |
async with Client(tool_server) as client:
|
| 145 |
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 146 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 147 |
+
assert result.structured_content == {"result": 3}
|
| 148 |
+
assert result.data == 3
|
| 149 |
|
| 150 |
async def test_call_tool_error(self, tool_server: FastMCP):
|
| 151 |
async with Client(tool_server) as client:
|
| 152 |
with pytest.raises(Exception):
|
| 153 |
await client.call_tool("error_tool", {})
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
async def test_call_tool_error_as_client_raw(self):
|
| 156 |
"""Test raising and catching errors from a tool."""
|
| 157 |
mcp = FastMCP()
|
|
|
|
| 169 |
async def test_tool_returns_list(self, tool_server: FastMCP):
|
| 170 |
async with Client(tool_server) as client:
|
| 171 |
result = await client.call_tool("list_tool", {})
|
| 172 |
+
assert result.content[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
|
| 173 |
+
assert result.data == ["x", 2]
|
| 174 |
|
| 175 |
async def test_file_text_tool(self, tool_server: FastMCP):
|
| 176 |
async with Client(tool_server) as client:
|
| 177 |
result = await client.call_tool("file_text_tool", {})
|
| 178 |
+
assert len(result.content) == 1
|
| 179 |
+
embedded = result.content[0]
|
| 180 |
assert isinstance(embedded, EmbeddedResource)
|
| 181 |
resource = embedded.resource
|
| 182 |
assert isinstance(resource, TextResourceContents)
|
|
|
|
| 238 |
|
| 239 |
async with Client(mcp) as client:
|
| 240 |
result_1 = await client.call_tool("tool_1", {})
|
| 241 |
+
assert result_1.data == 1
|
| 242 |
|
| 243 |
with pytest.raises(ToolError, match="Unknown tool"):
|
| 244 |
await client.call_tool("tool_2", {})
|
|
|
|
| 251 |
await client.call_tool("tool_1", {})
|
| 252 |
|
| 253 |
result_2 = await client.call_tool("tool_2", {})
|
| 254 |
+
assert result_2.data == 2
|
| 255 |
|
| 256 |
|
| 257 |
class TestToolReturnTypes:
|
|
|
|
| 264 |
|
| 265 |
async with Client(mcp) as client:
|
| 266 |
result = await client.call_tool("string_tool", {})
|
| 267 |
+
assert result.data == "Hello, world!"
|
| 268 |
|
| 269 |
async def test_bytes(self, tmp_path: Path):
|
| 270 |
mcp = FastMCP()
|
|
|
|
| 275 |
|
| 276 |
async with Client(mcp) as client:
|
| 277 |
result = await client.call_tool("bytes_tool", {})
|
| 278 |
+
assert result.data == "Hello, world!"
|
| 279 |
|
| 280 |
async def test_uuid(self):
|
| 281 |
mcp = FastMCP()
|
|
|
|
| 288 |
|
| 289 |
async with Client(mcp) as client:
|
| 290 |
result = await client.call_tool("uuid_tool", {})
|
| 291 |
+
assert result.data == str(test_uuid)
|
| 292 |
|
| 293 |
async def test_path(self):
|
| 294 |
mcp = FastMCP()
|
|
|
|
| 301 |
|
| 302 |
async with Client(mcp) as client:
|
| 303 |
result = await client.call_tool("path_tool", {})
|
| 304 |
+
assert result.data == str(test_path)
|
| 305 |
|
| 306 |
async def test_datetime(self):
|
| 307 |
mcp = FastMCP()
|
|
|
|
| 314 |
|
| 315 |
async with Client(mcp) as client:
|
| 316 |
result = await client.call_tool("datetime_tool", {})
|
| 317 |
+
assert result.data == dt
|
| 318 |
|
| 319 |
async def test_image(self, tmp_path: Path):
|
| 320 |
mcp = FastMCP()
|
|
|
|
| 329 |
|
| 330 |
async with Client(mcp) as client:
|
| 331 |
result = await client.call_tool("image_tool", {"path": str(image_path)})
|
| 332 |
+
assert result.structured_content is None
|
| 333 |
+
content = result.content[0]
|
| 334 |
assert isinstance(content, ImageContent)
|
| 335 |
assert content.type == "image"
|
| 336 |
assert content.mimeType == "image/png"
|
|
|
|
| 351 |
|
| 352 |
async with Client(mcp) as client:
|
| 353 |
result = await client.call_tool("audio_tool", {"path": str(audio_path)})
|
| 354 |
+
content = result.content[0]
|
| 355 |
assert isinstance(content, AudioContent)
|
| 356 |
assert content.type == "audio"
|
| 357 |
assert content.mimeType == "audio/wav"
|
|
|
|
| 372 |
|
| 373 |
async with Client(mcp) as client:
|
| 374 |
result = await client.call_tool("file_tool", {"path": str(file_path)})
|
| 375 |
+
content = result.content[0]
|
| 376 |
assert isinstance(content, EmbeddedResource)
|
| 377 |
assert content.type == "resource"
|
| 378 |
resource = content.resource
|
|
|
|
| 388 |
async def test_tool_mixed_content(self, tool_server: FastMCP):
|
| 389 |
async with Client(tool_server) as client:
|
| 390 |
result = await client.call_tool("mixed_content_tool", {})
|
| 391 |
+
assert len(result.content) == 3
|
| 392 |
+
content1 = result.content[0]
|
| 393 |
+
content2 = result.content[1]
|
| 394 |
+
content3 = result.content[2]
|
| 395 |
assert isinstance(content1, TextContent)
|
| 396 |
assert content1.text == "Hello"
|
| 397 |
assert isinstance(content2, ImageContent)
|
|
|
|
| 419 |
result = await client.call_tool(
|
| 420 |
"mixed_list_fn", {"image_path": str(image_path)}
|
| 421 |
)
|
| 422 |
+
assert len(result.content) == 3
|
| 423 |
# Check text conversion
|
| 424 |
+
content1 = result.content[0]
|
| 425 |
assert isinstance(content1, TextContent)
|
| 426 |
assert json.loads(content1.text) == ["text message", {"key": "value"}]
|
| 427 |
# Check image conversion
|
| 428 |
+
content2 = result.content[1]
|
| 429 |
assert isinstance(content2, ImageContent)
|
| 430 |
assert content2.mimeType == "image/png"
|
| 431 |
assert base64.b64decode(content2.data) == b"test image data"
|
| 432 |
# Check direct TextContent
|
| 433 |
+
content3 = result.content[2]
|
| 434 |
assert isinstance(content3, TextContent)
|
| 435 |
assert content3.text == "direct content"
|
| 436 |
|
|
|
|
| 447 |
result = await client.call_tool(
|
| 448 |
"mixed_audio_list_fn", {"audio_path": str(audio_path)}
|
| 449 |
)
|
| 450 |
+
assert len(result.content) == 3
|
| 451 |
# Check text conversion
|
| 452 |
+
content1 = result.content[0]
|
| 453 |
assert isinstance(content1, TextContent)
|
| 454 |
assert json.loads(content1.text) == ["text message", {"key": "value"}]
|
| 455 |
# Check audio conversion
|
| 456 |
+
content2 = result.content[1]
|
| 457 |
assert isinstance(content2, AudioContent)
|
| 458 |
assert content2.mimeType == "audio/wav"
|
| 459 |
assert base64.b64decode(content2.data) == b"test audio data"
|
| 460 |
# Check direct TextContent
|
| 461 |
+
content3 = result.content[2]
|
| 462 |
assert isinstance(content3, TextContent)
|
| 463 |
assert content3.text == "direct content"
|
| 464 |
|
|
|
|
| 475 |
result = await client.call_tool(
|
| 476 |
"mixed_file_list_fn", {"file_path": str(file_path)}
|
| 477 |
)
|
| 478 |
+
assert len(result.content) == 3
|
| 479 |
# Check text conversion
|
| 480 |
+
content1 = result.content[0]
|
| 481 |
assert isinstance(content1, TextContent)
|
| 482 |
assert json.loads(content1.text) == ["text message", {"key": "value"}]
|
| 483 |
# Check file conversion
|
| 484 |
+
content2 = result.content[1]
|
| 485 |
assert isinstance(content2, EmbeddedResource)
|
| 486 |
assert content2.type == "resource"
|
| 487 |
resource = content2.resource
|
|
|
|
| 490 |
blob_data = getattr(resource, "blob")
|
| 491 |
assert base64.b64decode(blob_data) == b"test file data"
|
| 492 |
# Check direct TextContent
|
| 493 |
+
content3 = result.content[2]
|
| 494 |
assert isinstance(content3, TextContent)
|
| 495 |
assert content3.text == "direct content"
|
| 496 |
|
|
|
|
| 557 |
result = await client.call_tool(
|
| 558 |
"process_image", {"image": b"fake png data"}
|
| 559 |
)
|
| 560 |
+
assert result.structured_content is None
|
| 561 |
+
assert isinstance(result.content[0], ImageContent)
|
| 562 |
+
assert result.content[0].mimeType == "image/png"
|
| 563 |
+
assert result.content[0].data == base64.b64encode(b"fake png data").decode()
|
| 564 |
|
| 565 |
async def test_tool_with_invalid_input(self):
|
| 566 |
mcp = FastMCP()
|
|
|
|
| 678 |
|
| 679 |
async with Client(mcp) as client:
|
| 680 |
result = await client.call_tool("analyze", {"x": "a"})
|
| 681 |
+
assert result.data == "a"
|
| 682 |
|
| 683 |
async def test_enum_type_validation_error(self):
|
| 684 |
mcp = FastMCP()
|
|
|
|
| 713 |
|
| 714 |
async with Client(mcp) as client:
|
| 715 |
result = await client.call_tool("analyze", {"x": "red"})
|
| 716 |
+
assert result.data == "red"
|
| 717 |
|
| 718 |
async def test_union_type_validation(self):
|
| 719 |
mcp = FastMCP()
|
|
|
|
| 724 |
|
| 725 |
async with Client(mcp) as client:
|
| 726 |
result = await client.call_tool("analyze", {"x": 1})
|
| 727 |
+
assert result.data == "1"
|
| 728 |
|
| 729 |
result = await client.call_tool("analyze", {"x": 1.0})
|
| 730 |
+
assert result.data == "1.0"
|
| 731 |
|
| 732 |
with pytest.raises(
|
| 733 |
ToolError,
|
|
|
|
| 748 |
|
| 749 |
async with Client(mcp) as client:
|
| 750 |
result = await client.call_tool("send_path", {"path": str(test_path)})
|
| 751 |
+
assert result.data == str(test_path)
|
| 752 |
|
| 753 |
async def test_path_type_error(self):
|
| 754 |
mcp = FastMCP()
|
|
|
|
| 775 |
|
| 776 |
async with Client(mcp) as client:
|
| 777 |
result = await client.call_tool("send_uuid", {"x": test_uuid})
|
| 778 |
+
assert result.data == str(test_uuid)
|
| 779 |
|
| 780 |
async def test_uuid_type_error(self):
|
| 781 |
mcp = FastMCP()
|
|
|
|
| 799 |
|
| 800 |
async with Client(mcp) as client:
|
| 801 |
result = await client.call_tool("send_datetime", {"x": dt})
|
| 802 |
+
assert result.data == dt.isoformat()
|
| 803 |
|
| 804 |
async def test_datetime_type_parse_string(self):
|
| 805 |
mcp = FastMCP()
|
|
|
|
| 812 |
result = await client.call_tool(
|
| 813 |
"send_datetime", {"x": "2021-01-01T00:00:00"}
|
| 814 |
)
|
| 815 |
+
assert result.data == "2021-01-01T00:00:00"
|
| 816 |
|
| 817 |
async def test_datetime_type_error(self):
|
| 818 |
mcp = FastMCP()
|
|
|
|
| 834 |
|
| 835 |
async with Client(mcp) as client:
|
| 836 |
result = await client.call_tool("send_date", {"x": datetime.date.today()})
|
| 837 |
+
assert result.data == datetime.date.today().isoformat()
|
| 838 |
|
| 839 |
async def test_date_type_parse_string(self):
|
| 840 |
mcp = FastMCP()
|
|
|
|
| 845 |
|
| 846 |
async with Client(mcp) as client:
|
| 847 |
result = await client.call_tool("send_date", {"x": "2021-01-01"})
|
| 848 |
+
assert result.data == "2021-01-01"
|
| 849 |
|
| 850 |
async def test_timedelta_type(self):
|
| 851 |
mcp = FastMCP()
|
|
|
|
| 858 |
result = await client.call_tool(
|
| 859 |
"send_timedelta", {"x": datetime.timedelta(days=1)}
|
| 860 |
)
|
| 861 |
+
assert result.data == "1 day, 0:00:00"
|
| 862 |
|
| 863 |
async def test_timedelta_type_parse_int(self):
|
| 864 |
"""Test that invalid timedelta input raises validation error."""
|
|
|
|
| 877 |
await client.call_tool("send_timedelta", {"x": 1000})
|
| 878 |
|
| 879 |
|
| 880 |
+
class TestToolOutputSchema:
|
| 881 |
+
@pytest.mark.parametrize("annotation", [str, int, float, bool, list, AnyUrl])
|
| 882 |
+
async def test_simple_output_schema(self, annotation):
|
| 883 |
+
mcp = FastMCP()
|
| 884 |
+
|
| 885 |
+
@mcp.tool
|
| 886 |
+
def f() -> annotation: # type: ignore
|
| 887 |
+
return "hello"
|
| 888 |
+
|
| 889 |
+
async with Client(mcp) as client:
|
| 890 |
+
tools = await client.list_tools()
|
| 891 |
+
assert len(tools) == 1
|
| 892 |
+
|
| 893 |
+
type_schema = TypeAdapter(annotation).json_schema()
|
| 894 |
+
# this line will fail until MCP adds output schemas!!
|
| 895 |
+
assert tools[0].outputSchema == {
|
| 896 |
+
"type": "object",
|
| 897 |
+
"properties": {"result": type_schema},
|
| 898 |
+
"x-fastmcp-wrap-result": True,
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
@pytest.mark.parametrize(
|
| 902 |
+
"annotation",
|
| 903 |
+
[dict[str, int | str], PersonTypedDict, PersonModel, PersonDataclass],
|
| 904 |
+
)
|
| 905 |
+
async def test_structured_output_schema(self, annotation):
|
| 906 |
+
mcp = FastMCP()
|
| 907 |
+
|
| 908 |
+
@mcp.tool
|
| 909 |
+
def f() -> annotation: # type: ignore[valid-type]
|
| 910 |
+
return {"name": "John", "age": 30}
|
| 911 |
+
|
| 912 |
+
async with Client(mcp) as client:
|
| 913 |
+
tools = await client.list_tools()
|
| 914 |
+
|
| 915 |
+
type_schema = TypeAdapter(annotation).json_schema()
|
| 916 |
+
assert len(tools) == 1
|
| 917 |
+
assert tools[0].outputSchema == type_schema
|
| 918 |
+
|
| 919 |
+
async def test_disabled_output_schema_no_structured_content(self):
|
| 920 |
+
mcp = FastMCP()
|
| 921 |
+
|
| 922 |
+
@mcp.tool(output_schema=None)
|
| 923 |
+
def f() -> int:
|
| 924 |
+
return 42
|
| 925 |
+
|
| 926 |
+
async with Client(mcp) as client:
|
| 927 |
+
result = await client.call_tool("f", {})
|
| 928 |
+
assert result.content[0].text == "42" # type: ignore[attr-defined]
|
| 929 |
+
assert result.structured_content is None
|
| 930 |
+
assert result.data is None
|
| 931 |
+
|
| 932 |
+
async def test_manual_structured_content(self):
|
| 933 |
+
mcp = FastMCP()
|
| 934 |
+
|
| 935 |
+
@mcp.tool
|
| 936 |
+
def f() -> ToolResult:
|
| 937 |
+
return ToolResult(
|
| 938 |
+
content="Hello, world!", structured_content={"message": "Hello, world!"}
|
| 939 |
+
)
|
| 940 |
+
|
| 941 |
+
assert f.output_schema is None
|
| 942 |
+
|
| 943 |
+
async with Client(mcp) as client:
|
| 944 |
+
result = await client.call_tool("f", {})
|
| 945 |
+
assert result.content[0].text == "Hello, world!" # type: ignore[attr-defined]
|
| 946 |
+
assert result.structured_content == {"message": "Hello, world!"}
|
| 947 |
+
assert result.data == {"message": "Hello, world!"}
|
| 948 |
+
|
| 949 |
+
async def test_output_schema_false_full_handshake(self):
|
| 950 |
+
"""Test that output_schema=False works through full client/server
|
| 951 |
+
handshake. We test this by returning a scalar, which requires an output
|
| 952 |
+
schema to serialize."""
|
| 953 |
+
mcp = FastMCP()
|
| 954 |
+
|
| 955 |
+
@mcp.tool(output_schema=False) # type: ignore[arg-type]
|
| 956 |
+
def simple_tool() -> int:
|
| 957 |
+
return 42
|
| 958 |
+
|
| 959 |
+
async with Client(mcp) as client:
|
| 960 |
+
# List tools and verify output schema is None
|
| 961 |
+
tools = await client.list_tools()
|
| 962 |
+
tool = next(t for t in tools if t.name == "simple_tool")
|
| 963 |
+
assert tool.outputSchema is None
|
| 964 |
+
|
| 965 |
+
# Call tool and verify no structured content
|
| 966 |
+
result = await client.call_tool("simple_tool", {})
|
| 967 |
+
assert result.structured_content is None
|
| 968 |
+
assert result.data is None
|
| 969 |
+
assert result.content[0].text == "42" # type: ignore[attr-defined]
|
| 970 |
+
|
| 971 |
+
async def test_output_schema_explicit_object_full_handshake(self):
|
| 972 |
+
"""Test explicit object output schema through full client/server handshake."""
|
| 973 |
+
mcp = FastMCP()
|
| 974 |
+
|
| 975 |
+
@mcp.tool(
|
| 976 |
+
output_schema={
|
| 977 |
+
"type": "object",
|
| 978 |
+
"properties": {
|
| 979 |
+
"greeting": {"type": "string"},
|
| 980 |
+
"count": {"type": "integer"},
|
| 981 |
+
},
|
| 982 |
+
"required": ["greeting"],
|
| 983 |
+
}
|
| 984 |
+
)
|
| 985 |
+
def explicit_tool() -> dict[str, Any]:
|
| 986 |
+
return {"greeting": "Hello", "count": 42}
|
| 987 |
+
|
| 988 |
+
async with Client(mcp) as client:
|
| 989 |
+
# List tools and verify exact schema is preserved
|
| 990 |
+
tools = await client.list_tools()
|
| 991 |
+
tool = next(t for t in tools if t.name == "explicit_tool")
|
| 992 |
+
expected_schema = {
|
| 993 |
+
"type": "object",
|
| 994 |
+
"properties": {
|
| 995 |
+
"greeting": {"type": "string"},
|
| 996 |
+
"count": {"type": "integer"},
|
| 997 |
+
},
|
| 998 |
+
"required": ["greeting"],
|
| 999 |
+
}
|
| 1000 |
+
assert tool.outputSchema == expected_schema
|
| 1001 |
+
|
| 1002 |
+
# Call tool and verify structured content matches return value directly
|
| 1003 |
+
result = await client.call_tool("explicit_tool", {})
|
| 1004 |
+
assert result.structured_content == {"greeting": "Hello", "count": 42}
|
| 1005 |
+
# Client deserializes according to schema, so check fields
|
| 1006 |
+
assert result.data.greeting == "Hello" # type: ignore[attr-defined]
|
| 1007 |
+
assert result.data.count == 42 # type: ignore[attr-defined]
|
| 1008 |
+
|
| 1009 |
+
async def test_output_schema_wrapped_primitive_full_handshake(self):
|
| 1010 |
+
"""Test wrapped primitive output schema through full client/server handshake."""
|
| 1011 |
+
mcp = FastMCP()
|
| 1012 |
+
|
| 1013 |
+
@mcp.tool
|
| 1014 |
+
def primitive_tool() -> str:
|
| 1015 |
+
return "Hello, primitives!"
|
| 1016 |
+
|
| 1017 |
+
async with Client(mcp) as client:
|
| 1018 |
+
# List tools and verify schema shows wrapped structure
|
| 1019 |
+
tools = await client.list_tools()
|
| 1020 |
+
tool = next(t for t in tools if t.name == "primitive_tool")
|
| 1021 |
+
expected_schema = {
|
| 1022 |
+
"type": "object",
|
| 1023 |
+
"properties": {"result": {"type": "string"}},
|
| 1024 |
+
"x-fastmcp-wrap-result": True,
|
| 1025 |
+
}
|
| 1026 |
+
assert tool.outputSchema == expected_schema
|
| 1027 |
+
|
| 1028 |
+
# Call tool and verify structured content is wrapped
|
| 1029 |
+
result = await client.call_tool("primitive_tool", {})
|
| 1030 |
+
assert result.structured_content == {"result": "Hello, primitives!"}
|
| 1031 |
+
assert result.data == "Hello, primitives!" # Client unwraps for convenience
|
| 1032 |
+
|
| 1033 |
+
async def test_output_schema_complex_type_full_handshake(self):
|
| 1034 |
+
"""Test complex type output schema through full client/server handshake."""
|
| 1035 |
+
mcp = FastMCP()
|
| 1036 |
+
|
| 1037 |
+
@mcp.tool
|
| 1038 |
+
def complex_tool() -> list[dict[str, int]]:
|
| 1039 |
+
return [{"a": 1, "b": 2}, {"c": 3, "d": 4}]
|
| 1040 |
+
|
| 1041 |
+
async with Client(mcp) as client:
|
| 1042 |
+
# List tools and verify schema shows wrapped array
|
| 1043 |
+
tools = await client.list_tools()
|
| 1044 |
+
tool = next(t for t in tools if t.name == "complex_tool")
|
| 1045 |
+
expected_inner_schema = TypeAdapter(list[dict[str, int]]).json_schema()
|
| 1046 |
+
expected_schema = {
|
| 1047 |
+
"type": "object",
|
| 1048 |
+
"properties": {"result": expected_inner_schema},
|
| 1049 |
+
"x-fastmcp-wrap-result": True,
|
| 1050 |
+
}
|
| 1051 |
+
assert tool.outputSchema == expected_schema
|
| 1052 |
+
|
| 1053 |
+
# Call tool and verify structured content is wrapped
|
| 1054 |
+
result = await client.call_tool("complex_tool", {})
|
| 1055 |
+
expected_data = [{"a": 1, "b": 2}, {"c": 3, "d": 4}]
|
| 1056 |
+
assert result.structured_content == {"result": expected_data}
|
| 1057 |
+
# Client deserializes - just verify we got data back
|
| 1058 |
+
assert result.data is not None
|
| 1059 |
+
|
| 1060 |
+
async def test_output_schema_dataclass_full_handshake(self):
|
| 1061 |
+
"""Test dataclass output schema through full client/server handshake."""
|
| 1062 |
+
mcp = FastMCP()
|
| 1063 |
+
|
| 1064 |
+
@dataclass
|
| 1065 |
+
class User:
|
| 1066 |
+
name: str
|
| 1067 |
+
age: int
|
| 1068 |
+
|
| 1069 |
+
@mcp.tool
|
| 1070 |
+
def dataclass_tool() -> User:
|
| 1071 |
+
return User(name="Alice", age=30)
|
| 1072 |
+
|
| 1073 |
+
async with Client(mcp) as client:
|
| 1074 |
+
# List tools and verify schema is object type (not wrapped)
|
| 1075 |
+
tools = await client.list_tools()
|
| 1076 |
+
tool = next(t for t in tools if t.name == "dataclass_tool")
|
| 1077 |
+
expected_schema = TypeAdapter(User).json_schema()
|
| 1078 |
+
assert tool.outputSchema == expected_schema
|
| 1079 |
+
assert (
|
| 1080 |
+
tool.outputSchema and "x-fastmcp-wrap-result" not in tool.outputSchema
|
| 1081 |
+
)
|
| 1082 |
+
|
| 1083 |
+
# Call tool and verify structured content is direct
|
| 1084 |
+
result = await client.call_tool("dataclass_tool", {})
|
| 1085 |
+
assert result.structured_content == {"name": "Alice", "age": 30}
|
| 1086 |
+
# Client deserializes according to schema
|
| 1087 |
+
assert result.data.name == "Alice" # type: ignore[attr-defined]
|
| 1088 |
+
assert result.data.age == 30 # type: ignore[attr-defined]
|
| 1089 |
+
|
| 1090 |
+
async def test_output_schema_mixed_content_types(self):
|
| 1091 |
+
"""Test tools with mixed content and output schemas."""
|
| 1092 |
+
mcp = FastMCP()
|
| 1093 |
+
|
| 1094 |
+
@mcp.tool
|
| 1095 |
+
def mixed_output() -> list[Any]:
|
| 1096 |
+
# Return mixed content that includes MCP types and regular data
|
| 1097 |
+
return [
|
| 1098 |
+
"text message",
|
| 1099 |
+
{"structured": "data"},
|
| 1100 |
+
TextContent(type="text", text="direct MCP content"),
|
| 1101 |
+
]
|
| 1102 |
+
|
| 1103 |
+
async with Client(mcp) as client:
|
| 1104 |
+
result = await client.call_tool("mixed_output", {})
|
| 1105 |
+
|
| 1106 |
+
# Should have multiple content blocks
|
| 1107 |
+
assert len(result.content) >= 2
|
| 1108 |
+
|
| 1109 |
+
# Should have structured output with wrapped result
|
| 1110 |
+
expected_data = [
|
| 1111 |
+
"text message",
|
| 1112 |
+
{"structured": "data"},
|
| 1113 |
+
{
|
| 1114 |
+
"type": "text",
|
| 1115 |
+
"text": "direct MCP content",
|
| 1116 |
+
"annotations": None,
|
| 1117 |
+
"_meta": None,
|
| 1118 |
+
},
|
| 1119 |
+
]
|
| 1120 |
+
assert result.structured_content == {"result": expected_data}
|
| 1121 |
+
|
| 1122 |
+
async def test_output_schema_serialization_edge_cases(self):
|
| 1123 |
+
"""Test edge cases in output schema serialization."""
|
| 1124 |
+
mcp = FastMCP()
|
| 1125 |
+
|
| 1126 |
+
@mcp.tool
|
| 1127 |
+
def edge_case_tool() -> tuple[int, str]:
|
| 1128 |
+
return (42, "hello")
|
| 1129 |
+
|
| 1130 |
+
async with Client(mcp) as client:
|
| 1131 |
+
# Verify tuple gets proper schema
|
| 1132 |
+
tools = await client.list_tools()
|
| 1133 |
+
tool = next(t for t in tools if t.name == "edge_case_tool")
|
| 1134 |
+
|
| 1135 |
+
# Tuples should be wrapped since they're not object type
|
| 1136 |
+
assert tool.outputSchema and "x-fastmcp-wrap-result" in tool.outputSchema
|
| 1137 |
+
|
| 1138 |
+
result = await client.call_tool("edge_case_tool", {})
|
| 1139 |
+
# Should be wrapped with result key
|
| 1140 |
+
assert result.structured_content == {"result": [42, "hello"]}
|
| 1141 |
+
assert result.data == [42, "hello"]
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
class TestToolContextInjection:
|
| 1145 |
"""Test context injection in tools."""
|
| 1146 |
|
|
|
|
| 1169 |
|
| 1170 |
async with Client(mcp) as client:
|
| 1171 |
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 1172 |
+
assert result.data == "1"
|
|
|
|
|
|
|
| 1173 |
|
| 1174 |
async def test_async_context(self):
|
| 1175 |
"""Test that context works in async functions."""
|
|
|
|
| 1182 |
|
| 1183 |
async with Client(mcp) as client:
|
| 1184 |
result = await client.call_tool("async_tool", {"x": 42})
|
| 1185 |
+
assert result.data == "Async request 1: 42"
|
|
|
|
|
|
|
| 1186 |
|
| 1187 |
async def test_optional_context(self):
|
| 1188 |
"""Test that context is optional."""
|
|
|
|
| 1194 |
|
| 1195 |
async with Client(mcp) as client:
|
| 1196 |
result = await client.call_tool("no_context", {"x": 21})
|
| 1197 |
+
assert result.data == 42
|
|
|
|
|
|
|
| 1198 |
|
| 1199 |
async def test_context_resource_access(self):
|
| 1200 |
"""Test that context can access resources."""
|
|
|
|
| 1214 |
|
| 1215 |
async with Client(mcp) as client:
|
| 1216 |
result = await client.call_tool("tool_with_resource", {})
|
| 1217 |
+
assert (
|
| 1218 |
+
result.data == "Read resource: resource data with mime type text/plain"
|
| 1219 |
+
)
|
| 1220 |
|
| 1221 |
async def test_tool_decorator_with_tags(self):
|
| 1222 |
"""Test that the tool decorator properly sets tags."""
|
|
|
|
| 1244 |
|
| 1245 |
async with Client(mcp) as client:
|
| 1246 |
result = await client.call_tool("MyTool", {"x": 2})
|
| 1247 |
+
assert result.data == 3
|
| 1248 |
|
| 1249 |
|
| 1250 |
class TestToolEnabled:
|
tests/server/test_tool_annotations.py
CHANGED
|
@@ -218,8 +218,4 @@ async def test_tool_functionality_with_annotations():
|
|
| 218 |
result = await client.call_tool(
|
| 219 |
"create_item", {"name": "test_item", "value": 42}
|
| 220 |
)
|
| 221 |
-
assert
|
| 222 |
-
|
| 223 |
-
# The result should contain the expected JSON
|
| 224 |
-
assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined]
|
| 225 |
-
assert '"value": 42' in result[0].text # type: ignore[attr-defined]
|
|
|
|
| 218 |
result = await client.call_tool(
|
| 219 |
"create_item", {"name": "test_item", "value": 42}
|
| 220 |
)
|
| 221 |
+
assert result.data == {"name": "test_item", "value": 42}
|
|
|
|
|
|
|
|
|
|
|
|
tests/server/test_tool_exclude_args.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
from typing import Any
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
-
from mcp.types import TextContent
|
| 5 |
|
| 6 |
from fastmcp import Client, FastMCP
|
| 7 |
from fastmcp.tools.tool import Tool
|
|
@@ -92,9 +91,4 @@ async def test_tool_functionality_with_exclude_args():
|
|
| 92 |
result = await client.call_tool(
|
| 93 |
"create_item", {"name": "test_item", "value": 42}
|
| 94 |
)
|
| 95 |
-
assert
|
| 96 |
-
assert isinstance(result[0], TextContent)
|
| 97 |
-
|
| 98 |
-
# The result should contain the expected JSON
|
| 99 |
-
assert '"name": "test_item"' in result[0].text
|
| 100 |
-
assert '"value": 42' in result[0].text
|
|
|
|
| 1 |
from typing import Any
|
| 2 |
|
| 3 |
import pytest
|
|
|
|
| 4 |
|
| 5 |
from fastmcp import Client, FastMCP
|
| 6 |
from fastmcp.tools.tool import Tool
|
|
|
|
| 91 |
result = await client.call_tool(
|
| 92 |
"create_item", {"name": "test_item", "value": 42}
|
| 93 |
)
|
| 94 |
+
assert result.data == {"name": "test_item", "value": 42}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_examples.py
CHANGED
|
@@ -10,9 +10,9 @@ async def test_simple_echo():
|
|
| 10 |
from examples.simple_echo import mcp
|
| 11 |
|
| 12 |
async with Client(mcp) as client:
|
| 13 |
-
result = await client.
|
| 14 |
-
assert len(result) == 1
|
| 15 |
-
assert result[0].text == "hello" # type: ignore[attr-defined]
|
| 16 |
|
| 17 |
|
| 18 |
async def test_complex_inputs():
|
|
@@ -21,11 +21,11 @@ async def test_complex_inputs():
|
|
| 21 |
|
| 22 |
async with Client(mcp) as client:
|
| 23 |
tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
|
| 24 |
-
result = await client.
|
| 25 |
"name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
|
| 26 |
)
|
| 27 |
-
assert len(result) == 1
|
| 28 |
-
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
|
| 29 |
|
| 30 |
|
| 31 |
async def test_desktop(monkeypatch):
|
|
@@ -34,9 +34,9 @@ async def test_desktop(monkeypatch):
|
|
| 34 |
|
| 35 |
async with Client(mcp) as client:
|
| 36 |
# Test the add function
|
| 37 |
-
result = await client.
|
| 38 |
-
assert len(result) == 1
|
| 39 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
| 40 |
|
| 41 |
async with Client(mcp) as client:
|
| 42 |
result = await client.read_resource(AnyUrl("greeting://rooter12"))
|
|
@@ -49,9 +49,9 @@ async def test_echo():
|
|
| 49 |
from examples.echo import mcp
|
| 50 |
|
| 51 |
async with Client(mcp) as client:
|
| 52 |
-
result = await client.
|
| 53 |
-
assert len(result) == 1
|
| 54 |
-
assert result[0].text == "hello" # type: ignore[attr-defined]
|
| 55 |
|
| 56 |
async with Client(mcp) as client:
|
| 57 |
result = await client.read_resource(AnyUrl("echo://static"))
|
|
|
|
| 10 |
from examples.simple_echo import mcp
|
| 11 |
|
| 12 |
async with Client(mcp) as client:
|
| 13 |
+
result = await client.call_tool_mcp("echo", {"text": "hello"})
|
| 14 |
+
assert len(result.content) == 1
|
| 15 |
+
assert result.content[0].text == "hello" # type: ignore[attr-defined]
|
| 16 |
|
| 17 |
|
| 18 |
async def test_complex_inputs():
|
|
|
|
| 21 |
|
| 22 |
async with Client(mcp) as client:
|
| 23 |
tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
|
| 24 |
+
result = await client.call_tool_mcp(
|
| 25 |
"name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
|
| 26 |
)
|
| 27 |
+
assert len(result.content) == 1
|
| 28 |
+
assert result.content[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
|
| 29 |
|
| 30 |
|
| 31 |
async def test_desktop(monkeypatch):
|
|
|
|
| 34 |
|
| 35 |
async with Client(mcp) as client:
|
| 36 |
# Test the add function
|
| 37 |
+
result = await client.call_tool_mcp("add", {"a": 1, "b": 2})
|
| 38 |
+
assert len(result.content) == 1
|
| 39 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 40 |
|
| 41 |
async with Client(mcp) as client:
|
| 42 |
result = await client.read_resource(AnyUrl("greeting://rooter12"))
|
|
|
|
| 49 |
from examples.echo import mcp
|
| 50 |
|
| 51 |
async with Client(mcp) as client:
|
| 52 |
+
result = await client.call_tool_mcp("echo_tool", {"text": "hello"})
|
| 53 |
+
assert len(result.content) == 1
|
| 54 |
+
assert result.content[0].text == "hello" # type: ignore[attr-defined]
|
| 55 |
|
| 56 |
async with Client(mcp) as client:
|
| 57 |
result = await client.read_resource(AnyUrl("echo://static"))
|
tests/tools/test_tool.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import json
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
from mcp.types import (
|
|
@@ -8,7 +10,8 @@ from mcp.types import (
|
|
| 8 |
TextContent,
|
| 9 |
TextResourceContents,
|
| 10 |
)
|
| 11 |
-
from pydantic import AnyUrl, BaseModel
|
|
|
|
| 12 |
|
| 13 |
from fastmcp.tools.tool import Tool, _convert_to_content
|
| 14 |
from fastmcp.utilities.types import Audio, File, Image
|
|
@@ -29,6 +32,13 @@ class TestToolFromFunction:
|
|
| 29 |
assert len(tool.parameters["properties"]) == 2
|
| 30 |
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
| 31 |
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
async def test_async_function(self):
|
| 34 |
"""Test registering and running an async function."""
|
|
@@ -100,7 +110,7 @@ class TestToolFromFunction:
|
|
| 100 |
|
| 101 |
result = await tool.run({"data": "test.png"})
|
| 102 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 103 |
-
assert isinstance(result[0], ImageContent)
|
| 104 |
|
| 105 |
async def test_tool_with_audio_return(self):
|
| 106 |
def audio_tool(data: bytes) -> Audio:
|
|
@@ -110,7 +120,7 @@ class TestToolFromFunction:
|
|
| 110 |
|
| 111 |
result = await tool.run({"data": "test.wav"})
|
| 112 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 113 |
-
assert isinstance(result[0], AudioContent)
|
| 114 |
|
| 115 |
async def test_tool_with_file_return(self):
|
| 116 |
def file_tool(data: bytes) -> File:
|
|
@@ -120,11 +130,11 @@ class TestToolFromFunction:
|
|
| 120 |
|
| 121 |
result = await tool.run({"data": "test.bin"})
|
| 122 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 123 |
-
assert len(result) == 1
|
| 124 |
-
assert isinstance(result[0], EmbeddedResource)
|
| 125 |
-
assert result[0].type == "resource"
|
| 126 |
-
assert hasattr(result[0], "resource")
|
| 127 |
-
resource = result[0].resource
|
| 128 |
assert resource.mimeType == "application/octet-stream"
|
| 129 |
|
| 130 |
def test_non_callable_fn(self):
|
|
@@ -236,8 +246,490 @@ class TestToolFromFunction:
|
|
| 236 |
tool = Tool.from_function(process_list, serializer=custom_serializer)
|
| 237 |
|
| 238 |
result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
|
| 239 |
-
|
| 240 |
-
assert result[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
|
| 243 |
class TestConvertResultToContent:
|
|
@@ -537,3 +1029,199 @@ class TestConvertResultToContent:
|
|
| 537 |
1,
|
| 538 |
{"type": "text", "text": "hello", "annotations": None, "_meta": None},
|
| 539 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Annotated, Any
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
from mcp.types import (
|
|
|
|
| 10 |
TextContent,
|
| 11 |
TextResourceContents,
|
| 12 |
)
|
| 13 |
+
from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
|
| 14 |
+
from typing_extensions import TypedDict
|
| 15 |
|
| 16 |
from fastmcp.tools.tool import Tool, _convert_to_content
|
| 17 |
from fastmcp.utilities.types import Audio, File, Image
|
|
|
|
| 32 |
assert len(tool.parameters["properties"]) == 2
|
| 33 |
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
| 34 |
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
| 35 |
+
# With primitive wrapping, int return type becomes object with result property
|
| 36 |
+
expected_schema = {
|
| 37 |
+
"type": "object",
|
| 38 |
+
"properties": {"result": {"type": "integer"}},
|
| 39 |
+
"x-fastmcp-wrap-result": True,
|
| 40 |
+
}
|
| 41 |
+
assert tool.output_schema == expected_schema
|
| 42 |
|
| 43 |
async def test_async_function(self):
|
| 44 |
"""Test registering and running an async function."""
|
|
|
|
| 110 |
|
| 111 |
result = await tool.run({"data": "test.png"})
|
| 112 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 113 |
+
assert isinstance(result.content[0], ImageContent)
|
| 114 |
|
| 115 |
async def test_tool_with_audio_return(self):
|
| 116 |
def audio_tool(data: bytes) -> Audio:
|
|
|
|
| 120 |
|
| 121 |
result = await tool.run({"data": "test.wav"})
|
| 122 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 123 |
+
assert isinstance(result.content[0], AudioContent)
|
| 124 |
|
| 125 |
async def test_tool_with_file_return(self):
|
| 126 |
def file_tool(data: bytes) -> File:
|
|
|
|
| 130 |
|
| 131 |
result = await tool.run({"data": "test.bin"})
|
| 132 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 133 |
+
assert len(result.content) == 1
|
| 134 |
+
assert isinstance(result.content[0], EmbeddedResource)
|
| 135 |
+
assert result.content[0].type == "resource"
|
| 136 |
+
assert hasattr(result.content[0], "resource")
|
| 137 |
+
resource = result.content[0].resource
|
| 138 |
assert resource.mimeType == "application/octet-stream"
|
| 139 |
|
| 140 |
def test_non_callable_fn(self):
|
|
|
|
| 246 |
tool = Tool.from_function(process_list, serializer=custom_serializer)
|
| 247 |
|
| 248 |
result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
|
| 249 |
+
# Custom serializer affects unstructured content
|
| 250 |
+
assert isinstance(result.content[0], TextContent)
|
| 251 |
+
assert result.content[0].text == "Custom serializer: 15"
|
| 252 |
+
# Structured output should have the raw value
|
| 253 |
+
assert result.structured_content == {"result": 15}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class TestToolFromFunctionOutputSchema:
|
| 257 |
+
async def test_no_return_annotation(self):
|
| 258 |
+
def func():
|
| 259 |
+
pass
|
| 260 |
+
|
| 261 |
+
tool = Tool.from_function(func)
|
| 262 |
+
assert tool.output_schema is None
|
| 263 |
+
|
| 264 |
+
@pytest.mark.parametrize(
|
| 265 |
+
"annotation",
|
| 266 |
+
[
|
| 267 |
+
int,
|
| 268 |
+
float,
|
| 269 |
+
bool,
|
| 270 |
+
str,
|
| 271 |
+
int | float,
|
| 272 |
+
list,
|
| 273 |
+
list[int],
|
| 274 |
+
list[int | float],
|
| 275 |
+
dict,
|
| 276 |
+
dict[str, Any],
|
| 277 |
+
dict[str, int | None],
|
| 278 |
+
tuple[int, str],
|
| 279 |
+
set[int],
|
| 280 |
+
list[tuple[int, str]],
|
| 281 |
+
],
|
| 282 |
+
)
|
| 283 |
+
async def test_simple_return_annotation(self, annotation):
|
| 284 |
+
def func() -> annotation: # type: ignore
|
| 285 |
+
return 1
|
| 286 |
+
|
| 287 |
+
tool = Tool.from_function(func)
|
| 288 |
+
|
| 289 |
+
base_schema = TypeAdapter(annotation).json_schema()
|
| 290 |
+
|
| 291 |
+
# Non-object types get wrapped
|
| 292 |
+
schema_type = base_schema.get("type")
|
| 293 |
+
is_object_type = schema_type == "object"
|
| 294 |
+
|
| 295 |
+
if not is_object_type:
|
| 296 |
+
# Non-object types get wrapped
|
| 297 |
+
expected_schema = {
|
| 298 |
+
"type": "object",
|
| 299 |
+
"properties": {"result": base_schema},
|
| 300 |
+
"x-fastmcp-wrap-result": True,
|
| 301 |
+
}
|
| 302 |
+
assert tool.output_schema == expected_schema
|
| 303 |
+
else:
|
| 304 |
+
# Object types remain unwrapped
|
| 305 |
+
assert tool.output_schema == base_schema
|
| 306 |
+
|
| 307 |
+
@pytest.mark.parametrize(
|
| 308 |
+
"annotation",
|
| 309 |
+
[
|
| 310 |
+
AnyUrl,
|
| 311 |
+
Annotated[int, Field(ge=1)],
|
| 312 |
+
Annotated[int, Field(ge=1)],
|
| 313 |
+
],
|
| 314 |
+
)
|
| 315 |
+
async def test_complex_return_annotation(self, annotation):
|
| 316 |
+
def func() -> annotation: # type: ignore
|
| 317 |
+
return 1
|
| 318 |
+
|
| 319 |
+
tool = Tool.from_function(func)
|
| 320 |
+
base_schema = TypeAdapter(annotation).json_schema()
|
| 321 |
+
|
| 322 |
+
expected_schema = {
|
| 323 |
+
"type": "object",
|
| 324 |
+
"properties": {"result": base_schema},
|
| 325 |
+
"x-fastmcp-wrap-result": True,
|
| 326 |
+
}
|
| 327 |
+
assert tool.output_schema == expected_schema
|
| 328 |
+
|
| 329 |
+
async def test_none_return_annotation(self):
|
| 330 |
+
def func() -> None:
|
| 331 |
+
pass
|
| 332 |
+
|
| 333 |
+
tool = Tool.from_function(func)
|
| 334 |
+
assert tool.output_schema is None
|
| 335 |
+
|
| 336 |
+
async def test_any_return_annotation(self):
|
| 337 |
+
def func() -> Any:
|
| 338 |
+
return 1
|
| 339 |
+
|
| 340 |
+
tool = Tool.from_function(func)
|
| 341 |
+
assert tool.output_schema is None
|
| 342 |
+
|
| 343 |
+
@pytest.mark.parametrize(
|
| 344 |
+
"annotation, expected",
|
| 345 |
+
[
|
| 346 |
+
(Image, ImageContent),
|
| 347 |
+
(Audio, AudioContent),
|
| 348 |
+
(File, EmbeddedResource),
|
| 349 |
+
(Image | int, ImageContent | int),
|
| 350 |
+
(Image | Audio, ImageContent | AudioContent),
|
| 351 |
+
(list[Image | Audio], list[ImageContent | AudioContent]),
|
| 352 |
+
],
|
| 353 |
+
)
|
| 354 |
+
async def test_converted_return_annotation(self, annotation, expected):
|
| 355 |
+
def func() -> annotation: # type: ignore
|
| 356 |
+
return 1
|
| 357 |
+
|
| 358 |
+
tool = Tool.from_function(func)
|
| 359 |
+
# Image, Audio, File types don't generate output schemas since they're converted to content directly
|
| 360 |
+
assert tool.output_schema is None
|
| 361 |
+
|
| 362 |
+
async def test_dataclass_return_annotation(self):
|
| 363 |
+
@dataclass
|
| 364 |
+
class Person:
|
| 365 |
+
name: str
|
| 366 |
+
age: int
|
| 367 |
+
|
| 368 |
+
def func() -> Person:
|
| 369 |
+
return Person(name="John", age=30)
|
| 370 |
+
|
| 371 |
+
tool = Tool.from_function(func)
|
| 372 |
+
assert tool.output_schema == TypeAdapter(Person).json_schema()
|
| 373 |
+
|
| 374 |
+
async def test_base_model_return_annotation(self):
|
| 375 |
+
class Person(BaseModel):
|
| 376 |
+
name: str
|
| 377 |
+
age: int
|
| 378 |
+
|
| 379 |
+
def func() -> Person:
|
| 380 |
+
return Person(name="John", age=30)
|
| 381 |
+
|
| 382 |
+
tool = Tool.from_function(func)
|
| 383 |
+
assert tool.output_schema == TypeAdapter(Person).json_schema()
|
| 384 |
+
|
| 385 |
+
async def test_typeddict_return_annotation(self):
|
| 386 |
+
class Person(TypedDict):
|
| 387 |
+
name: str
|
| 388 |
+
age: int
|
| 389 |
+
|
| 390 |
+
def func() -> Person:
|
| 391 |
+
return Person(name="John", age=30)
|
| 392 |
+
|
| 393 |
+
tool = Tool.from_function(func)
|
| 394 |
+
assert tool.output_schema == TypeAdapter(Person).json_schema()
|
| 395 |
+
|
| 396 |
+
async def test_unserializable_return_annotation(self):
|
| 397 |
+
class Unserializable:
|
| 398 |
+
def __init__(self, data: Any):
|
| 399 |
+
self.data = data
|
| 400 |
+
|
| 401 |
+
def func() -> Unserializable:
|
| 402 |
+
return Unserializable(data="test")
|
| 403 |
+
|
| 404 |
+
tool = Tool.from_function(func)
|
| 405 |
+
assert tool.output_schema is None
|
| 406 |
+
|
| 407 |
+
async def test_mixed_unserializable_return_annotation(self):
|
| 408 |
+
class Unserializable:
|
| 409 |
+
def __init__(self, data: Any):
|
| 410 |
+
self.data = data
|
| 411 |
+
|
| 412 |
+
def func() -> Unserializable | int:
|
| 413 |
+
return Unserializable(data="test")
|
| 414 |
+
|
| 415 |
+
tool = Tool.from_function(func)
|
| 416 |
+
assert tool.output_schema is None
|
| 417 |
+
|
| 418 |
+
async def test_provided_output_schema_takes_precedence_over_json_compatible_annotation(
|
| 419 |
+
self,
|
| 420 |
+
):
|
| 421 |
+
"""Test that provided output_schema takes precedence over inferred schema from JSON-compatible annotation."""
|
| 422 |
+
|
| 423 |
+
def func() -> dict[str, int]:
|
| 424 |
+
return {"a": 1, "b": 2}
|
| 425 |
+
|
| 426 |
+
# Provide a custom output schema that differs from the inferred one
|
| 427 |
+
custom_schema = {"type": "object", "description": "Custom schema"}
|
| 428 |
+
|
| 429 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 430 |
+
assert tool.output_schema == custom_schema
|
| 431 |
+
|
| 432 |
+
async def test_provided_output_schema_takes_precedence_over_complex_annotation(
|
| 433 |
+
self,
|
| 434 |
+
):
|
| 435 |
+
"""Test that provided output_schema takes precedence over inferred schema from complex annotation."""
|
| 436 |
+
|
| 437 |
+
def func() -> list[dict[str, int | float]]:
|
| 438 |
+
return [{"a": 1, "b": 2.5}]
|
| 439 |
+
|
| 440 |
+
# Provide a custom output schema that differs from the inferred one
|
| 441 |
+
custom_schema = {"type": "object", "properties": {"custom": {"type": "string"}}}
|
| 442 |
+
|
| 443 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 444 |
+
assert tool.output_schema == custom_schema
|
| 445 |
+
|
| 446 |
+
async def test_provided_output_schema_takes_precedence_over_unserializable_annotation(
|
| 447 |
+
self,
|
| 448 |
+
):
|
| 449 |
+
"""Test that provided output_schema takes precedence over None schema from unserializable annotation."""
|
| 450 |
+
|
| 451 |
+
class Unserializable:
|
| 452 |
+
def __init__(self, data: Any):
|
| 453 |
+
self.data = data
|
| 454 |
+
|
| 455 |
+
def func() -> Unserializable:
|
| 456 |
+
return Unserializable(data="test")
|
| 457 |
+
|
| 458 |
+
# Provide a custom output schema even though the annotation is unserializable
|
| 459 |
+
custom_schema = {
|
| 460 |
+
"type": "object",
|
| 461 |
+
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 465 |
+
assert tool.output_schema == custom_schema
|
| 466 |
+
|
| 467 |
+
async def test_provided_output_schema_takes_precedence_over_no_annotation(self):
|
| 468 |
+
"""Test that provided output_schema takes precedence over None schema from no annotation."""
|
| 469 |
+
|
| 470 |
+
def func():
|
| 471 |
+
return "hello"
|
| 472 |
+
|
| 473 |
+
# Provide a custom output schema even though there's no return annotation
|
| 474 |
+
custom_schema = {
|
| 475 |
+
"type": "object",
|
| 476 |
+
"properties": {"value": {"type": "number", "minimum": 0}},
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 480 |
+
assert tool.output_schema == custom_schema
|
| 481 |
+
|
| 482 |
+
async def test_provided_output_schema_takes_precedence_over_converted_annotation(
|
| 483 |
+
self,
|
| 484 |
+
):
|
| 485 |
+
"""Test that provided output_schema takes precedence over converted schema from Image/Audio/File annotations."""
|
| 486 |
+
|
| 487 |
+
def func() -> Image:
|
| 488 |
+
return Image(data=b"test")
|
| 489 |
+
|
| 490 |
+
# Provide a custom output schema that differs from the converted ImageContent schema
|
| 491 |
+
custom_schema = {
|
| 492 |
+
"type": "object",
|
| 493 |
+
"properties": {"custom_image": {"type": "string"}},
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 497 |
+
assert tool.output_schema == custom_schema
|
| 498 |
+
|
| 499 |
+
async def test_provided_output_schema_takes_precedence_over_union_annotation(self):
|
| 500 |
+
"""Test that provided output_schema takes precedence over inferred schema from union annotation."""
|
| 501 |
+
|
| 502 |
+
def func() -> str | int | None:
|
| 503 |
+
return "hello"
|
| 504 |
+
|
| 505 |
+
# Provide a custom output schema that differs from the inferred union schema
|
| 506 |
+
custom_schema = {"type": "object", "properties": {"flag": {"type": "boolean"}}}
|
| 507 |
+
|
| 508 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 509 |
+
assert tool.output_schema == custom_schema
|
| 510 |
+
|
| 511 |
+
async def test_provided_output_schema_takes_precedence_over_pydantic_annotation(
|
| 512 |
+
self,
|
| 513 |
+
):
|
| 514 |
+
"""Test that provided output_schema takes precedence over inferred schema from Pydantic model annotation."""
|
| 515 |
+
|
| 516 |
+
class Person(BaseModel):
|
| 517 |
+
name: str
|
| 518 |
+
age: int
|
| 519 |
+
|
| 520 |
+
def func() -> Person:
|
| 521 |
+
return Person(name="John", age=30)
|
| 522 |
+
|
| 523 |
+
# Provide a custom output schema that differs from the inferred Person schema
|
| 524 |
+
custom_schema = {
|
| 525 |
+
"type": "object",
|
| 526 |
+
"properties": {"numbers": {"type": "array", "items": {"type": "number"}}},
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 530 |
+
assert tool.output_schema == custom_schema
|
| 531 |
+
|
| 532 |
+
async def test_output_schema_false_allows_automatic_structured_content(self):
|
| 533 |
+
"""Test that output_schema=False still allows automatic structured content for dict-like objects."""
|
| 534 |
+
|
| 535 |
+
def func() -> dict[str, str]:
|
| 536 |
+
return {"message": "Hello, world!"}
|
| 537 |
+
|
| 538 |
+
tool = Tool.from_function(func, output_schema=False)
|
| 539 |
+
assert tool.output_schema is None
|
| 540 |
+
|
| 541 |
+
result = await tool.run({})
|
| 542 |
+
# Dict objects automatically become structured content even without schema
|
| 543 |
+
assert result.structured_content == {"message": "Hello, world!"}
|
| 544 |
+
assert len(result.content) == 1
|
| 545 |
+
assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined]
|
| 546 |
+
|
| 547 |
+
async def test_output_schema_none_disables_structured_content(self):
|
| 548 |
+
"""Test that output_schema=None explicitly disables structured content."""
|
| 549 |
+
|
| 550 |
+
def func() -> int:
|
| 551 |
+
return 42
|
| 552 |
+
|
| 553 |
+
tool = Tool.from_function(func, output_schema=None)
|
| 554 |
+
assert tool.output_schema is None
|
| 555 |
+
|
| 556 |
+
result = await tool.run({})
|
| 557 |
+
assert result.structured_content is None
|
| 558 |
+
assert len(result.content) == 1
|
| 559 |
+
assert result.content[0].text == "42" # type: ignore[attr-defined]
|
| 560 |
+
|
| 561 |
+
async def test_output_schema_inferred_when_not_specified(self):
|
| 562 |
+
"""Test that output schema is inferred when not explicitly specified."""
|
| 563 |
+
|
| 564 |
+
def func() -> int:
|
| 565 |
+
return 42
|
| 566 |
+
|
| 567 |
+
# Don't specify output_schema - should infer and wrap
|
| 568 |
+
tool = Tool.from_function(func)
|
| 569 |
+
expected_schema = {
|
| 570 |
+
"type": "object",
|
| 571 |
+
"properties": {"result": {"type": "integer"}},
|
| 572 |
+
"x-fastmcp-wrap-result": True,
|
| 573 |
+
}
|
| 574 |
+
assert tool.output_schema == expected_schema
|
| 575 |
+
|
| 576 |
+
result = await tool.run({})
|
| 577 |
+
assert result.structured_content == {"result": 42}
|
| 578 |
+
|
| 579 |
+
async def test_explicit_object_schema_with_dict_return(self):
|
| 580 |
+
"""Test that explicit object schemas work when function returns a dict."""
|
| 581 |
+
|
| 582 |
+
def func() -> dict[str, int]:
|
| 583 |
+
return {"value": 42}
|
| 584 |
+
|
| 585 |
+
# Provide explicit object schema
|
| 586 |
+
explicit_schema = {
|
| 587 |
+
"type": "object",
|
| 588 |
+
"properties": {"value": {"type": "integer", "minimum": 0}},
|
| 589 |
+
}
|
| 590 |
+
tool = Tool.from_function(func, output_schema=explicit_schema)
|
| 591 |
+
assert tool.output_schema == explicit_schema # Schema not wrapped
|
| 592 |
+
assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema
|
| 593 |
+
|
| 594 |
+
result = await tool.run({})
|
| 595 |
+
# Dict result with object schema is used directly
|
| 596 |
+
assert result.structured_content == {"value": 42}
|
| 597 |
+
assert result.content[0].text == '{\n "value": 42\n}' # type: ignore[attr-defined]
|
| 598 |
+
|
| 599 |
+
async def test_explicit_object_schema_with_non_dict_return_fails(self):
|
| 600 |
+
"""Test that explicit object schemas fail when function returns non-dict."""
|
| 601 |
+
|
| 602 |
+
def func() -> int:
|
| 603 |
+
return 42
|
| 604 |
+
|
| 605 |
+
# Provide explicit object schema but return non-dict
|
| 606 |
+
explicit_schema = {
|
| 607 |
+
"type": "object",
|
| 608 |
+
"properties": {"value": {"type": "integer"}},
|
| 609 |
+
}
|
| 610 |
+
tool = Tool.from_function(func, output_schema=explicit_schema)
|
| 611 |
+
|
| 612 |
+
# Should fail because int is not dict-compatible with object schema
|
| 613 |
+
with pytest.raises(ValueError, match="structured_content must be a dict"):
|
| 614 |
+
await tool.run({})
|
| 615 |
+
|
| 616 |
+
async def test_object_output_schema_not_wrapped(self):
|
| 617 |
+
"""Test that object-type output schemas are never wrapped."""
|
| 618 |
+
|
| 619 |
+
def func() -> dict[str, int]:
|
| 620 |
+
return {"value": 42}
|
| 621 |
+
|
| 622 |
+
# Object schemas should never be wrapped, even when inferred
|
| 623 |
+
tool = Tool.from_function(func)
|
| 624 |
+
expected_schema = TypeAdapter(dict[str, int]).json_schema()
|
| 625 |
+
assert tool.output_schema == expected_schema # Not wrapped
|
| 626 |
+
assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema
|
| 627 |
+
|
| 628 |
+
result = await tool.run({})
|
| 629 |
+
assert result.structured_content == {"value": 42} # Direct value
|
| 630 |
+
|
| 631 |
+
async def test_structured_content_interaction_with_wrapping(self):
|
| 632 |
+
"""Test that structured content works correctly with schema wrapping."""
|
| 633 |
+
|
| 634 |
+
def func() -> str:
|
| 635 |
+
return "hello"
|
| 636 |
+
|
| 637 |
+
# Inferred schema should wrap string type
|
| 638 |
+
tool = Tool.from_function(func)
|
| 639 |
+
expected_schema = {
|
| 640 |
+
"type": "object",
|
| 641 |
+
"properties": {"result": {"type": "string"}},
|
| 642 |
+
"x-fastmcp-wrap-result": True,
|
| 643 |
+
}
|
| 644 |
+
assert tool.output_schema == expected_schema
|
| 645 |
+
|
| 646 |
+
result = await tool.run({})
|
| 647 |
+
# Unstructured content
|
| 648 |
+
assert len(result.content) == 1
|
| 649 |
+
assert result.content[0].text == "hello" # type: ignore[attr-defined]
|
| 650 |
+
# Structured content should be wrapped
|
| 651 |
+
assert result.structured_content == {"result": "hello"}
|
| 652 |
+
|
| 653 |
+
async def test_structured_content_with_explicit_object_schema(self):
|
| 654 |
+
"""Test structured content with explicit object schema."""
|
| 655 |
+
|
| 656 |
+
def func() -> dict[str, str]:
|
| 657 |
+
return {"greeting": "hello"}
|
| 658 |
+
|
| 659 |
+
# Provide explicit object schema
|
| 660 |
+
explicit_schema = {
|
| 661 |
+
"type": "object",
|
| 662 |
+
"properties": {"greeting": {"type": "string"}},
|
| 663 |
+
"required": ["greeting"],
|
| 664 |
+
}
|
| 665 |
+
tool = Tool.from_function(func, output_schema=explicit_schema)
|
| 666 |
+
assert tool.output_schema == explicit_schema
|
| 667 |
+
|
| 668 |
+
result = await tool.run({})
|
| 669 |
+
# Should use direct value since explicit schema doesn't have wrap marker
|
| 670 |
+
assert result.structured_content == {"greeting": "hello"}
|
| 671 |
+
|
| 672 |
+
async def test_structured_content_with_custom_wrapper_schema(self):
|
| 673 |
+
"""Test structured content with custom schema that includes wrap marker."""
|
| 674 |
+
|
| 675 |
+
def func() -> str:
|
| 676 |
+
return "world"
|
| 677 |
+
|
| 678 |
+
# Custom schema with wrap marker
|
| 679 |
+
custom_schema = {
|
| 680 |
+
"type": "object",
|
| 681 |
+
"properties": {"message": {"type": "string"}},
|
| 682 |
+
"x-fastmcp-wrap-result": True,
|
| 683 |
+
}
|
| 684 |
+
tool = Tool.from_function(func, output_schema=custom_schema)
|
| 685 |
+
assert tool.output_schema == custom_schema
|
| 686 |
+
|
| 687 |
+
result = await tool.run({})
|
| 688 |
+
# Should wrap with "result" key due to wrap marker
|
| 689 |
+
assert result.structured_content == {"result": "world"}
|
| 690 |
+
|
| 691 |
+
async def test_none_vs_false_output_schema_behavior(self):
|
| 692 |
+
"""Test the difference between None and False for output_schema."""
|
| 693 |
+
|
| 694 |
+
def func() -> int:
|
| 695 |
+
return 123
|
| 696 |
+
|
| 697 |
+
# None should disable
|
| 698 |
+
tool_none = Tool.from_function(func, output_schema=None)
|
| 699 |
+
assert tool_none.output_schema is None
|
| 700 |
+
|
| 701 |
+
# False should also disable
|
| 702 |
+
tool_false = Tool.from_function(func, output_schema=False)
|
| 703 |
+
assert tool_false.output_schema is None
|
| 704 |
+
|
| 705 |
+
# Both should have same behavior
|
| 706 |
+
result_none = await tool_none.run({})
|
| 707 |
+
result_false = await tool_false.run({})
|
| 708 |
+
|
| 709 |
+
assert result_none.structured_content is None
|
| 710 |
+
assert result_false.structured_content is None
|
| 711 |
+
assert result_none.content[0].text == result_false.content[0].text == "123" # type: ignore[attr-defined]
|
| 712 |
+
|
| 713 |
+
async def test_non_object_output_schema_raises_error(self):
|
| 714 |
+
"""Test that providing a non-object output schema raises a ValueError."""
|
| 715 |
+
|
| 716 |
+
def func() -> int:
|
| 717 |
+
return 42
|
| 718 |
+
|
| 719 |
+
# Test various non-object schemas that should raise errors
|
| 720 |
+
non_object_schemas = [
|
| 721 |
+
{"type": "string"},
|
| 722 |
+
{"type": "integer", "minimum": 0},
|
| 723 |
+
{"type": "number"},
|
| 724 |
+
{"type": "boolean"},
|
| 725 |
+
{"type": "array", "items": {"type": "string"}},
|
| 726 |
+
]
|
| 727 |
+
|
| 728 |
+
for schema in non_object_schemas:
|
| 729 |
+
with pytest.raises(
|
| 730 |
+
ValueError, match='Output schemas must have "type" set to "object"'
|
| 731 |
+
):
|
| 732 |
+
Tool.from_function(func, output_schema=schema)
|
| 733 |
|
| 734 |
|
| 735 |
class TestConvertResultToContent:
|
|
|
|
| 1029 |
1,
|
| 1030 |
{"type": "text", "text": "hello", "annotations": None, "_meta": None},
|
| 1031 |
]
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
class TestAutomaticStructuredContent:
|
| 1035 |
+
"""Tests for automatic structured content generation based on return types."""
|
| 1036 |
+
|
| 1037 |
+
async def test_dict_return_creates_structured_content_without_schema(self):
|
| 1038 |
+
"""Test that dict returns automatically create structured content even without output schema."""
|
| 1039 |
+
|
| 1040 |
+
def get_user_data(user_id: str) -> dict:
|
| 1041 |
+
return {"name": "Alice", "age": 30, "active": True}
|
| 1042 |
+
|
| 1043 |
+
# No explicit output schema provided
|
| 1044 |
+
tool = Tool.from_function(get_user_data)
|
| 1045 |
+
|
| 1046 |
+
result = await tool.run({"user_id": "123"})
|
| 1047 |
+
|
| 1048 |
+
# Should have both content and structured content
|
| 1049 |
+
assert len(result.content) == 1
|
| 1050 |
+
assert isinstance(result.content[0], TextContent)
|
| 1051 |
+
assert result.structured_content == {"name": "Alice", "age": 30, "active": True}
|
| 1052 |
+
|
| 1053 |
+
async def test_dataclass_return_creates_structured_content_without_schema(self):
|
| 1054 |
+
"""Test that dataclass returns automatically create structured content even without output schema."""
|
| 1055 |
+
|
| 1056 |
+
@dataclass
|
| 1057 |
+
class UserProfile:
|
| 1058 |
+
name: str
|
| 1059 |
+
age: int
|
| 1060 |
+
email: str
|
| 1061 |
+
|
| 1062 |
+
def get_profile(user_id: str) -> UserProfile:
|
| 1063 |
+
return UserProfile(name="Bob", age=25, email="bob@example.com")
|
| 1064 |
+
|
| 1065 |
+
# No explicit output schema, but dataclass should still create structured content
|
| 1066 |
+
tool = Tool.from_function(get_profile, output_schema=False)
|
| 1067 |
+
|
| 1068 |
+
result = await tool.run({"user_id": "456"})
|
| 1069 |
+
|
| 1070 |
+
# Should have both content and structured content
|
| 1071 |
+
assert len(result.content) == 1
|
| 1072 |
+
assert isinstance(result.content[0], TextContent)
|
| 1073 |
+
# Dataclass should serialize to dict
|
| 1074 |
+
assert result.structured_content == {
|
| 1075 |
+
"name": "Bob",
|
| 1076 |
+
"age": 25,
|
| 1077 |
+
"email": "bob@example.com",
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
async def test_pydantic_model_return_creates_structured_content_without_schema(
|
| 1081 |
+
self,
|
| 1082 |
+
):
|
| 1083 |
+
"""Test that Pydantic model returns automatically create structured content even without output schema."""
|
| 1084 |
+
|
| 1085 |
+
class UserData(BaseModel):
|
| 1086 |
+
username: str
|
| 1087 |
+
score: int
|
| 1088 |
+
verified: bool
|
| 1089 |
+
|
| 1090 |
+
def get_user_stats(user_id: str) -> UserData:
|
| 1091 |
+
return UserData(username="charlie", score=100, verified=True)
|
| 1092 |
+
|
| 1093 |
+
# Explicitly disable output schema to test automatic structured content
|
| 1094 |
+
tool = Tool.from_function(get_user_stats, output_schema=False)
|
| 1095 |
+
|
| 1096 |
+
result = await tool.run({"user_id": "789"})
|
| 1097 |
+
|
| 1098 |
+
# Should have both content and structured content
|
| 1099 |
+
assert len(result.content) == 1
|
| 1100 |
+
assert isinstance(result.content[0], TextContent)
|
| 1101 |
+
# Pydantic model should serialize to dict
|
| 1102 |
+
assert result.structured_content == {
|
| 1103 |
+
"username": "charlie",
|
| 1104 |
+
"score": 100,
|
| 1105 |
+
"verified": True,
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
async def test_int_return_no_structured_content_without_schema(self):
|
| 1109 |
+
"""Test that int returns don't create structured content without output schema."""
|
| 1110 |
+
|
| 1111 |
+
def calculate_sum(a: int, b: int):
|
| 1112 |
+
"""No return annotation."""
|
| 1113 |
+
return a + b
|
| 1114 |
+
|
| 1115 |
+
# No output schema
|
| 1116 |
+
tool = Tool.from_function(calculate_sum)
|
| 1117 |
+
|
| 1118 |
+
result = await tool.run({"a": 5, "b": 3})
|
| 1119 |
+
|
| 1120 |
+
# Should only have content, no structured content
|
| 1121 |
+
assert len(result.content) == 1
|
| 1122 |
+
assert isinstance(result.content[0], TextContent)
|
| 1123 |
+
assert result.content[0].text == "8"
|
| 1124 |
+
assert result.structured_content is None
|
| 1125 |
+
|
| 1126 |
+
async def test_str_return_no_structured_content_without_schema(self):
|
| 1127 |
+
"""Test that str returns don't create structured content without output schema."""
|
| 1128 |
+
|
| 1129 |
+
def get_greeting(name: str):
|
| 1130 |
+
"""No return annotation."""
|
| 1131 |
+
return f"Hello, {name}!"
|
| 1132 |
+
|
| 1133 |
+
# No output schema
|
| 1134 |
+
tool = Tool.from_function(get_greeting)
|
| 1135 |
+
|
| 1136 |
+
result = await tool.run({"name": "World"})
|
| 1137 |
+
|
| 1138 |
+
# Should only have content, no structured content
|
| 1139 |
+
assert len(result.content) == 1
|
| 1140 |
+
assert isinstance(result.content[0], TextContent)
|
| 1141 |
+
assert result.content[0].text == "Hello, World!"
|
| 1142 |
+
assert result.structured_content is None
|
| 1143 |
+
|
| 1144 |
+
async def test_list_return_no_structured_content_without_schema(self):
|
| 1145 |
+
"""Test that list returns don't create structured content without output schema."""
|
| 1146 |
+
|
| 1147 |
+
def get_numbers():
|
| 1148 |
+
"""No return annotation."""
|
| 1149 |
+
return [1, 2, 3, 4, 5]
|
| 1150 |
+
|
| 1151 |
+
# No output schema
|
| 1152 |
+
tool = Tool.from_function(get_numbers)
|
| 1153 |
+
|
| 1154 |
+
result = await tool.run({})
|
| 1155 |
+
|
| 1156 |
+
# Should only have content, no structured content
|
| 1157 |
+
assert len(result.content) == 1
|
| 1158 |
+
assert isinstance(result.content[0], TextContent)
|
| 1159 |
+
assert result.structured_content is None
|
| 1160 |
+
|
| 1161 |
+
async def test_int_return_with_schema_creates_structured_content(self):
|
| 1162 |
+
"""Test that int returns DO create structured content when there's an output schema."""
|
| 1163 |
+
|
| 1164 |
+
def calculate_sum(a: int, b: int) -> int:
|
| 1165 |
+
"""With return annotation."""
|
| 1166 |
+
return a + b
|
| 1167 |
+
|
| 1168 |
+
# Output schema should be auto-generated from annotation
|
| 1169 |
+
tool = Tool.from_function(calculate_sum)
|
| 1170 |
+
assert tool.output_schema is not None
|
| 1171 |
+
|
| 1172 |
+
result = await tool.run({"a": 5, "b": 3})
|
| 1173 |
+
|
| 1174 |
+
# Should have both content and structured content
|
| 1175 |
+
assert len(result.content) == 1
|
| 1176 |
+
assert isinstance(result.content[0], TextContent)
|
| 1177 |
+
assert result.content[0].text == "8"
|
| 1178 |
+
assert result.structured_content == {"result": 8}
|
| 1179 |
+
|
| 1180 |
+
async def test_client_automatic_deserialization_with_dict_result(self):
|
| 1181 |
+
"""Test that clients automatically deserialize dict results from structured content."""
|
| 1182 |
+
from fastmcp import FastMCP
|
| 1183 |
+
from fastmcp.client import Client
|
| 1184 |
+
|
| 1185 |
+
mcp = FastMCP()
|
| 1186 |
+
|
| 1187 |
+
@mcp.tool
|
| 1188 |
+
def get_user_info(user_id: str) -> dict:
|
| 1189 |
+
return {"name": "Alice", "age": 30, "active": True}
|
| 1190 |
+
|
| 1191 |
+
async with Client(mcp) as client:
|
| 1192 |
+
result = await client.call_tool("get_user_info", {"user_id": "123"})
|
| 1193 |
+
|
| 1194 |
+
# Client should provide the deserialized data
|
| 1195 |
+
assert result.data == {"name": "Alice", "age": 30, "active": True}
|
| 1196 |
+
assert result.structured_content == {
|
| 1197 |
+
"name": "Alice",
|
| 1198 |
+
"age": 30,
|
| 1199 |
+
"active": True,
|
| 1200 |
+
}
|
| 1201 |
+
assert len(result.content) == 1
|
| 1202 |
+
|
| 1203 |
+
async def test_client_automatic_deserialization_with_dataclass_result(self):
|
| 1204 |
+
"""Test that clients automatically deserialize dataclass results from structured content."""
|
| 1205 |
+
from fastmcp import FastMCP
|
| 1206 |
+
from fastmcp.client import Client
|
| 1207 |
+
|
| 1208 |
+
mcp = FastMCP()
|
| 1209 |
+
|
| 1210 |
+
@dataclass
|
| 1211 |
+
class UserProfile:
|
| 1212 |
+
name: str
|
| 1213 |
+
age: int
|
| 1214 |
+
verified: bool
|
| 1215 |
+
|
| 1216 |
+
@mcp.tool
|
| 1217 |
+
def get_profile(user_id: str) -> UserProfile:
|
| 1218 |
+
return UserProfile(name="Bob", age=25, verified=True)
|
| 1219 |
+
|
| 1220 |
+
async with Client(mcp) as client:
|
| 1221 |
+
result = await client.call_tool("get_profile", {"user_id": "456"})
|
| 1222 |
+
|
| 1223 |
+
# Client should deserialize back to a dataclass (type name will match)
|
| 1224 |
+
assert result.data.__class__.__name__ == "UserProfile"
|
| 1225 |
+
assert result.data.name == "Bob"
|
| 1226 |
+
assert result.data.age == 25
|
| 1227 |
+
assert result.data.verified is True
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -125,7 +125,8 @@ class TestAddTools:
|
|
| 125 |
tool = await manager.get_tool("image_tool")
|
| 126 |
result = await tool.run({"data": "test.png"})
|
| 127 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 128 |
-
assert isinstance(result[0], ImageContent)
|
|
|
|
| 129 |
|
| 130 |
def test_add_noncallable_tool(self):
|
| 131 |
manager = ToolManager()
|
|
@@ -353,7 +354,8 @@ class TestCallTools:
|
|
| 353 |
manager.add_tool(tool)
|
| 354 |
result = await manager.call_tool("add", {"a": 1, "b": 2})
|
| 355 |
|
| 356 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 357 |
|
| 358 |
async def test_call_async_tool(self):
|
| 359 |
async def double(n: int) -> int:
|
|
@@ -364,7 +366,8 @@ class TestCallTools:
|
|
| 364 |
tool = Tool.from_function(double)
|
| 365 |
manager.add_tool(tool)
|
| 366 |
result = await manager.call_tool("double", {"n": 5})
|
| 367 |
-
assert result[0].text == "10" # type: ignore[attr-defined]
|
|
|
|
| 368 |
|
| 369 |
async def test_call_tool_callable_object(self):
|
| 370 |
class Adder:
|
|
@@ -378,7 +381,8 @@ class TestCallTools:
|
|
| 378 |
tool = Tool.from_function(Adder())
|
| 379 |
manager.add_tool(tool)
|
| 380 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 381 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 382 |
|
| 383 |
async def test_call_tool_callable_object_async(self):
|
| 384 |
class Adder:
|
|
@@ -392,7 +396,8 @@ class TestCallTools:
|
|
| 392 |
tool = Tool.from_function(Adder())
|
| 393 |
manager.add_tool(tool)
|
| 394 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 395 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 396 |
|
| 397 |
async def test_call_tool_with_default_args(self):
|
| 398 |
def add(a: int, b: int = 1) -> int:
|
|
@@ -404,7 +409,8 @@ class TestCallTools:
|
|
| 404 |
manager.add_tool(tool)
|
| 405 |
result = await manager.call_tool("add", {"a": 1})
|
| 406 |
|
| 407 |
-
assert result[0].text == "2" # type: ignore[attr-defined]
|
|
|
|
| 408 |
|
| 409 |
async def test_call_tool_with_missing_args(self):
|
| 410 |
def add(a: int, b: int) -> int:
|
|
@@ -431,7 +437,8 @@ class TestCallTools:
|
|
| 431 |
manager.add_tool(tool)
|
| 432 |
|
| 433 |
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
| 434 |
-
assert result[0].text == "6" # type: ignore[attr-defined]
|
|
|
|
| 435 |
|
| 436 |
async def test_call_tool_with_list_str_or_str_input(self):
|
| 437 |
def concat_strs(vals: list[str] | str) -> str:
|
|
@@ -443,10 +450,12 @@ class TestCallTools:
|
|
| 443 |
|
| 444 |
# Try both with plain python object and with JSON list
|
| 445 |
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
|
| 446 |
-
assert result[0].text == "abc" # type: ignore[attr-defined]
|
|
|
|
| 447 |
|
| 448 |
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
| 449 |
-
assert result[0].text == "a" # type: ignore[attr-defined]
|
|
|
|
| 450 |
|
| 451 |
async def test_call_tool_with_complex_model(self):
|
| 452 |
class MyShrimpTank(BaseModel):
|
|
@@ -477,7 +486,8 @@ class TestCallTools:
|
|
| 477 |
},
|
| 478 |
)
|
| 479 |
|
| 480 |
-
assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
|
|
|
|
| 481 |
|
| 482 |
async def test_call_tool_with_custom_serializer(self):
|
| 483 |
"""Test that a custom serializer provided to FastMCP is used by tools."""
|
|
@@ -496,7 +506,8 @@ class TestCallTools:
|
|
| 496 |
return {"key": "value", "number": 123}
|
| 497 |
|
| 498 |
result = await manager.call_tool("get_data", {})
|
| 499 |
-
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
|
|
|
|
| 500 |
|
| 501 |
async def test_call_tool_with_list_result_custom_serializer(self):
|
| 502 |
"""Test that a custom serializer provided to FastMCP is used by tools that return lists."""
|
|
@@ -518,9 +529,15 @@ class TestCallTools:
|
|
| 518 |
|
| 519 |
result = await manager.call_tool("get_data", {})
|
| 520 |
assert (
|
| 521 |
-
result[0].text # type: ignore[attr-defined]
|
| 522 |
== 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
|
| 523 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
async def test_custom_serializer_fallback_on_error(self):
|
| 526 |
"""Test that a broken custom serializer gracefully falls back."""
|
|
@@ -538,7 +555,11 @@ class TestCallTools:
|
|
| 538 |
return uuid_result
|
| 539 |
|
| 540 |
result = await manager.call_tool("get_data", {})
|
| 541 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
|
| 544 |
class TestToolSchema:
|
|
@@ -608,7 +629,8 @@ class TestContextHandling:
|
|
| 608 |
|
| 609 |
async with context:
|
| 610 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 611 |
-
assert result[0].text == "42" # type: ignore[attr-defined]
|
|
|
|
| 612 |
|
| 613 |
async def test_context_injection_async(self):
|
| 614 |
"""Test that context is properly injected in async tools."""
|
|
@@ -626,7 +648,8 @@ class TestContextHandling:
|
|
| 626 |
|
| 627 |
async with context:
|
| 628 |
result = await manager.call_tool("async_tool", {"x": 42})
|
| 629 |
-
assert result[0].text == "42" # type: ignore[attr-defined]
|
|
|
|
| 630 |
|
| 631 |
async def test_context_optional(self):
|
| 632 |
"""Test that context is optional when calling tools."""
|
|
@@ -644,7 +667,8 @@ class TestContextHandling:
|
|
| 644 |
|
| 645 |
async with context:
|
| 646 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 647 |
-
assert result[0].text == "42" # type: ignore[attr-defined]
|
|
|
|
| 648 |
|
| 649 |
def test_parameterized_context_parameter_detection(self):
|
| 650 |
"""Test that context parameters are properly detected in
|
|
@@ -752,7 +776,8 @@ class TestCustomToolNames:
|
|
| 752 |
|
| 753 |
# Tool should be callable by its custom name
|
| 754 |
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
|
| 755 |
-
assert result[0].text == "15" # type: ignore[attr-defined]
|
|
|
|
| 756 |
|
| 757 |
# Original name should not be registered
|
| 758 |
with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
|
|
|
|
| 125 |
tool = await manager.get_tool("image_tool")
|
| 126 |
result = await tool.run({"data": "test.png"})
|
| 127 |
assert tool.parameters["properties"]["data"]["type"] == "string"
|
| 128 |
+
assert isinstance(result.content[0], ImageContent)
|
| 129 |
+
assert result.structured_content is None
|
| 130 |
|
| 131 |
def test_add_noncallable_tool(self):
|
| 132 |
manager = ToolManager()
|
|
|
|
| 354 |
manager.add_tool(tool)
|
| 355 |
result = await manager.call_tool("add", {"a": 1, "b": 2})
|
| 356 |
|
| 357 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 358 |
+
assert result.structured_content == {"result": 3}
|
| 359 |
|
| 360 |
async def test_call_async_tool(self):
|
| 361 |
async def double(n: int) -> int:
|
|
|
|
| 366 |
tool = Tool.from_function(double)
|
| 367 |
manager.add_tool(tool)
|
| 368 |
result = await manager.call_tool("double", {"n": 5})
|
| 369 |
+
assert result.content[0].text == "10" # type: ignore[attr-defined]
|
| 370 |
+
assert result.structured_content == {"result": 10}
|
| 371 |
|
| 372 |
async def test_call_tool_callable_object(self):
|
| 373 |
class Adder:
|
|
|
|
| 381 |
tool = Tool.from_function(Adder())
|
| 382 |
manager.add_tool(tool)
|
| 383 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 384 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 385 |
+
assert result.structured_content == {"result": 3}
|
| 386 |
|
| 387 |
async def test_call_tool_callable_object_async(self):
|
| 388 |
class Adder:
|
|
|
|
| 396 |
tool = Tool.from_function(Adder())
|
| 397 |
manager.add_tool(tool)
|
| 398 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 399 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 400 |
+
assert result.structured_content == {"result": 3}
|
| 401 |
|
| 402 |
async def test_call_tool_with_default_args(self):
|
| 403 |
def add(a: int, b: int = 1) -> int:
|
|
|
|
| 409 |
manager.add_tool(tool)
|
| 410 |
result = await manager.call_tool("add", {"a": 1})
|
| 411 |
|
| 412 |
+
assert result.content[0].text == "2" # type: ignore[attr-defined]
|
| 413 |
+
assert result.structured_content == {"result": 2}
|
| 414 |
|
| 415 |
async def test_call_tool_with_missing_args(self):
|
| 416 |
def add(a: int, b: int) -> int:
|
|
|
|
| 437 |
manager.add_tool(tool)
|
| 438 |
|
| 439 |
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
| 440 |
+
assert result.content[0].text == "6" # type: ignore[attr-defined]
|
| 441 |
+
assert result.structured_content == {"result": 6}
|
| 442 |
|
| 443 |
async def test_call_tool_with_list_str_or_str_input(self):
|
| 444 |
def concat_strs(vals: list[str] | str) -> str:
|
|
|
|
| 450 |
|
| 451 |
# Try both with plain python object and with JSON list
|
| 452 |
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
|
| 453 |
+
assert result.content[0].text == "abc" # type: ignore[attr-defined]
|
| 454 |
+
assert result.structured_content == {"result": "abc"}
|
| 455 |
|
| 456 |
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
| 457 |
+
assert result.content[0].text == "a" # type: ignore[attr-defined]
|
| 458 |
+
assert result.structured_content == {"result": "a"}
|
| 459 |
|
| 460 |
async def test_call_tool_with_complex_model(self):
|
| 461 |
class MyShrimpTank(BaseModel):
|
|
|
|
| 486 |
},
|
| 487 |
)
|
| 488 |
|
| 489 |
+
assert result.content[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
|
| 490 |
+
assert result.structured_content == {"result": ["rex", "gertrude"]}
|
| 491 |
|
| 492 |
async def test_call_tool_with_custom_serializer(self):
|
| 493 |
"""Test that a custom serializer provided to FastMCP is used by tools."""
|
|
|
|
| 506 |
return {"key": "value", "number": 123}
|
| 507 |
|
| 508 |
result = await manager.call_tool("get_data", {})
|
| 509 |
+
assert result.content[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
|
| 510 |
+
assert result.structured_content == {"key": "value", "number": 123}
|
| 511 |
|
| 512 |
async def test_call_tool_with_list_result_custom_serializer(self):
|
| 513 |
"""Test that a custom serializer provided to FastMCP is used by tools that return lists."""
|
|
|
|
| 529 |
|
| 530 |
result = await manager.call_tool("get_data", {})
|
| 531 |
assert (
|
| 532 |
+
result.content[0].text # type: ignore[attr-defined]
|
| 533 |
== 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
|
| 534 |
)
|
| 535 |
+
assert result.structured_content == {
|
| 536 |
+
"result": [
|
| 537 |
+
{"key": "value", "number": 123},
|
| 538 |
+
{"key": "value2", "number": 456},
|
| 539 |
+
]
|
| 540 |
+
}
|
| 541 |
|
| 542 |
async def test_custom_serializer_fallback_on_error(self):
|
| 543 |
"""Test that a broken custom serializer gracefully falls back."""
|
|
|
|
| 555 |
return uuid_result
|
| 556 |
|
| 557 |
result = await manager.call_tool("get_data", {})
|
| 558 |
+
assert (
|
| 559 |
+
result.content[0].text # type: ignore[attr-defined]
|
| 560 |
+
== pydantic_core.to_json(uuid_result).decode()
|
| 561 |
+
)
|
| 562 |
+
assert result.structured_content == {"result": str(uuid_result)}
|
| 563 |
|
| 564 |
|
| 565 |
class TestToolSchema:
|
|
|
|
| 629 |
|
| 630 |
async with context:
|
| 631 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 632 |
+
assert result.content[0].text == "42" # type: ignore[attr-defined]
|
| 633 |
+
assert result.structured_content == {"result": "42"}
|
| 634 |
|
| 635 |
async def test_context_injection_async(self):
|
| 636 |
"""Test that context is properly injected in async tools."""
|
|
|
|
| 648 |
|
| 649 |
async with context:
|
| 650 |
result = await manager.call_tool("async_tool", {"x": 42})
|
| 651 |
+
assert result.content[0].text == "42" # type: ignore[attr-defined]
|
| 652 |
+
assert result.structured_content == {"result": "42"}
|
| 653 |
|
| 654 |
async def test_context_optional(self):
|
| 655 |
"""Test that context is optional when calling tools."""
|
|
|
|
| 667 |
|
| 668 |
async with context:
|
| 669 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 670 |
+
assert result.content[0].text == "42" # type: ignore[attr-defined]
|
| 671 |
+
assert result.structured_content == {"result": 42}
|
| 672 |
|
| 673 |
def test_parameterized_context_parameter_detection(self):
|
| 674 |
"""Test that context parameters are properly detected in
|
|
|
|
| 776 |
|
| 777 |
# Tool should be callable by its custom name
|
| 778 |
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
|
| 779 |
+
assert result.content[0].text == "15" # type: ignore[attr-defined]
|
| 780 |
+
assert result.structured_content == {"result": 15}
|
| 781 |
|
| 782 |
# Original name should not be registered
|
| 783 |
with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
|
tests/tools/test_tool_transform.py
CHANGED
|
@@ -4,14 +4,15 @@ from typing import Annotated, Any
|
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
from dirty_equals import IsList
|
| 7 |
-
from
|
|
|
|
| 8 |
from typing_extensions import TypedDict
|
| 9 |
|
| 10 |
from fastmcp import FastMCP
|
| 11 |
from fastmcp.client.client import Client
|
| 12 |
from fastmcp.exceptions import ToolError
|
| 13 |
from fastmcp.tools import Tool, forward, forward_raw
|
| 14 |
-
from fastmcp.tools.tool import FunctionTool
|
| 15 |
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
|
| 16 |
|
| 17 |
|
|
@@ -52,7 +53,8 @@ async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool):
|
|
| 52 |
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
|
| 53 |
)
|
| 54 |
result = await new_tool.run(arguments={"new_x": 1})
|
| 55 |
-
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
|
|
@@ -60,7 +62,8 @@ async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
|
|
| 60 |
add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
|
| 61 |
)
|
| 62 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 63 |
-
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
def test_tool_change_arg_name(add_tool):
|
|
@@ -87,7 +90,7 @@ async def test_tool_drop_arg(add_tool):
|
|
| 87 |
)
|
| 88 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 89 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 90 |
-
assert result
|
| 91 |
|
| 92 |
|
| 93 |
async def test_dropped_args_error_if_provided(add_tool):
|
|
@@ -109,7 +112,7 @@ async def test_hidden_arg_with_constant_default(add_tool):
|
|
| 109 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 110 |
# Should pass old_x=5 and old_y=20 to parent
|
| 111 |
result = await new_tool.run(arguments={"old_x": 5})
|
| 112 |
-
assert result
|
| 113 |
|
| 114 |
|
| 115 |
async def test_hidden_arg_without_default_uses_parent_default(add_tool):
|
|
@@ -121,13 +124,14 @@ async def test_hidden_arg_without_default_uses_parent_default(add_tool):
|
|
| 121 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 122 |
# Should pass old_x=3 and let parent use its default old_y=10
|
| 123 |
result = await new_tool.run(arguments={"old_x": 3})
|
| 124 |
-
assert result[0].text == "13" # type: ignore[attr-defined]
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
async def test_mixed_hidden_args_with_custom_function(add_tool):
|
| 128 |
"""Test custom function with both hidden constant and hidden default parameters."""
|
| 129 |
|
| 130 |
-
async def custom_fn(visible_x: int) ->
|
| 131 |
# This custom function should receive the transformed visible parameter
|
| 132 |
# and the hidden parameters should be automatically handled
|
| 133 |
result = await forward(visible_x=visible_x)
|
|
@@ -146,7 +150,8 @@ async def test_mixed_hidden_args_with_custom_function(add_tool):
|
|
| 146 |
assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
|
| 147 |
# Should pass visible_x=7 as old_x=7 and old_y=25 to parent
|
| 148 |
result = await new_tool.run(arguments={"visible_x": 7})
|
| 149 |
-
assert result[0].text == "32" # type: ignore[attr-defined]
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
async def test_hide_required_param_without_default_raises_error():
|
|
@@ -184,13 +189,13 @@ async def test_hide_required_param_with_user_default_works():
|
|
| 184 |
assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
|
| 185 |
# Should pass required_param=5 and optional_param=20 to parent
|
| 186 |
result = await new_tool.run(arguments={"optional_param": 20})
|
| 187 |
-
assert result
|
| 188 |
|
| 189 |
|
| 190 |
async def test_forward_with_argument_mapping(add_tool):
|
| 191 |
"""Test that forward() applies argument mapping correctly."""
|
| 192 |
|
| 193 |
-
async def custom_fn(new_x: int, new_y: int = 5) ->
|
| 194 |
return await forward(new_x=new_x, new_y=new_y)
|
| 195 |
|
| 196 |
new_tool = Tool.from_tool(
|
|
@@ -203,11 +208,12 @@ async def test_forward_with_argument_mapping(add_tool):
|
|
| 203 |
)
|
| 204 |
|
| 205 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 206 |
-
assert result[0].text == "5" # type: ignore[attr-defined]
|
|
|
|
| 207 |
|
| 208 |
|
| 209 |
async def test_forward_with_incorrect_args_raises_error(add_tool):
|
| 210 |
-
async def custom_fn(new_x: int, new_y: int = 5) ->
|
| 211 |
# the forward should use the new args, not the old ones
|
| 212 |
return await forward(old_x=new_x, old_y=new_y)
|
| 213 |
|
|
@@ -228,7 +234,7 @@ async def test_forward_with_incorrect_args_raises_error(add_tool):
|
|
| 228 |
async def test_forward_raw_without_argument_mapping(add_tool):
|
| 229 |
"""Test that forward_raw() calls parent directly without mapping."""
|
| 230 |
|
| 231 |
-
async def custom_fn(new_x: int, new_y: int = 5) ->
|
| 232 |
# Call parent directly with original argument names
|
| 233 |
result = await forward_raw(old_x=new_x, old_y=new_y)
|
| 234 |
return result
|
|
@@ -243,17 +249,19 @@ async def test_forward_raw_without_argument_mapping(add_tool):
|
|
| 243 |
)
|
| 244 |
|
| 245 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 246 |
-
assert result[0].text == "5" # type: ignore[attr-defined]
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
|
| 250 |
async def custom_fn(extra: int, **kwargs) -> int:
|
| 251 |
sum = await forward(**kwargs)
|
| 252 |
-
return int(sum[0].text) + extra # type: ignore[attr-defined]
|
| 253 |
|
| 254 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 255 |
result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
|
| 256 |
-
assert result[0].text == "6" # type: ignore[attr-defined]
|
|
|
|
| 257 |
assert new_tool.parameters["required"] == IsList(
|
| 258 |
"extra", "old_x", check_order=False
|
| 259 |
)
|
|
@@ -263,20 +271,21 @@ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
|
|
| 263 |
|
| 264 |
|
| 265 |
async def test_fn_with_kwargs_passes_through_original_args(add_tool):
|
| 266 |
-
async def custom_fn(new_y: int = 5, **kwargs) ->
|
| 267 |
assert kwargs == {"old_y": 3}
|
| 268 |
result = await forward(old_x=new_y, **kwargs)
|
| 269 |
return result
|
| 270 |
|
| 271 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 272 |
result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
|
| 273 |
-
assert result[0].text == "5" # type: ignore[attr-defined]
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
|
| 277 |
"""Test that **kwargs receives arguments with their transformed names from transform_args."""
|
| 278 |
|
| 279 |
-
async def custom_fn(new_x: int, **kwargs) ->
|
| 280 |
# kwargs should contain 'old_y': 3 (transformed name), not 'old_y': 3 (original name)
|
| 281 |
assert kwargs == {"old_y": 3}
|
| 282 |
result = await forward(new_x=new_x, **kwargs)
|
|
@@ -288,13 +297,16 @@ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
|
|
| 288 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 289 |
)
|
| 290 |
result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
|
| 291 |
-
assert result[0].text == "5" # type: ignore[attr-defined]
|
|
|
|
| 292 |
|
| 293 |
|
| 294 |
async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
|
| 295 |
"""Test that function can explicitly handle some transformed args while others pass through kwargs."""
|
| 296 |
|
| 297 |
-
async def custom_fn(
|
|
|
|
|
|
|
| 298 |
# x is explicitly handled, y should come through kwargs with transformed name
|
| 299 |
assert kwargs == {"old_y": 7}
|
| 300 |
result = await forward(new_x=new_x, **kwargs)
|
|
@@ -308,13 +320,14 @@ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
|
|
| 308 |
result = await new_tool.run(
|
| 309 |
arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
|
| 310 |
)
|
| 311 |
-
assert result[0].text == "10" # type: ignore[attr-defined]
|
|
|
|
| 312 |
|
| 313 |
|
| 314 |
async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
|
| 315 |
"""Test **kwargs behavior with mix of mapped and unmapped arguments."""
|
| 316 |
|
| 317 |
-
async def custom_fn(new_x: int, **kwargs) ->
|
| 318 |
# new_x is explicitly handled, old_y should pass through kwargs with original name (unmapped)
|
| 319 |
assert kwargs == {"old_y": 5}
|
| 320 |
result = await forward(new_x=new_x, **kwargs)
|
|
@@ -326,13 +339,14 @@ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
|
|
| 326 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 327 |
) # only map 'a'
|
| 328 |
result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
|
| 329 |
-
assert result[0].text == "6" # type: ignore[attr-defined]
|
|
|
|
| 330 |
|
| 331 |
|
| 332 |
async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
|
| 333 |
"""Test that dropped arguments don't appear in **kwargs."""
|
| 334 |
|
| 335 |
-
async def custom_fn(new_x: int, **kwargs) ->
|
| 336 |
# 'b' was dropped, so kwargs should be empty
|
| 337 |
assert kwargs == {}
|
| 338 |
# Can't use 'old_y' since it was dropped, so just use 'old_x' mapped to 'new_x'
|
|
@@ -349,7 +363,7 @@ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
|
|
| 349 |
) # drop 'old_y'
|
| 350 |
result = await new_tool.run(arguments={"new_x": 8})
|
| 351 |
# 8 + 10 (default value of b in parent)
|
| 352 |
-
assert result[0].text == "18" # type: ignore[attr-defined]
|
| 353 |
|
| 354 |
|
| 355 |
async def test_forward_outside_context_raises_error():
|
|
@@ -469,18 +483,18 @@ async def test_tool_transform_chaining(add_tool):
|
|
| 469 |
tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
|
| 470 |
|
| 471 |
result = await tool2.run(arguments={"final_x": 5})
|
| 472 |
-
assert result[0].text == "15" # type: ignore[attr-defined]
|
| 473 |
|
| 474 |
# Transform tool1 with custom function that handles all parameters
|
| 475 |
async def custom(final_x: int, **kwargs) -> str:
|
| 476 |
result = await forward(final_x=final_x, **kwargs)
|
| 477 |
-
return f"custom {result[0].text}" # Extract text from content
|
| 478 |
|
| 479 |
tool3 = Tool.from_tool(
|
| 480 |
tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
|
| 481 |
)
|
| 482 |
result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
|
| 483 |
-
assert result[0].text == "custom 8" # type: ignore[attr-defined]
|
| 484 |
|
| 485 |
|
| 486 |
class MyModel(BaseModel):
|
|
@@ -608,7 +622,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs():
|
|
| 608 |
# Function signature has different types/defaults than ArgTransform
|
| 609 |
async def custom_fn(x: str = "function_default", **kwargs) -> str:
|
| 610 |
result = await forward(x=x, **kwargs)
|
| 611 |
-
return f"custom: {result}"
|
| 612 |
|
| 613 |
tool = Tool.from_tool(
|
| 614 |
base,
|
|
@@ -635,7 +649,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs():
|
|
| 635 |
# Test it works at runtime
|
| 636 |
result = await tool.run(arguments={"y": "test"})
|
| 637 |
# Should use ArgTransform default of 42
|
| 638 |
-
assert "42: test" in result[0].text # type: ignore[attr-defined]
|
| 639 |
|
| 640 |
|
| 641 |
def test_arg_transform_combined_attributes():
|
|
@@ -680,7 +694,7 @@ async def test_arg_transform_type_precedence_runtime():
|
|
| 680 |
# Convert string back to int for the original function
|
| 681 |
result = await forward_raw(x=int(x), y=y)
|
| 682 |
# Extract the text from the result
|
| 683 |
-
result_text = result[0].text
|
| 684 |
return f"String input '{x}' converted to result: {result_text}"
|
| 685 |
|
| 686 |
tool = Tool.from_tool(
|
|
@@ -692,8 +706,8 @@ async def test_arg_transform_type_precedence_runtime():
|
|
| 692 |
|
| 693 |
# Test it works with string input
|
| 694 |
result = await tool.run(arguments={"x": "5", "y": 3})
|
| 695 |
-
assert "String input '5'" in result[0].text # type: ignore[attr-defined]
|
| 696 |
-
assert "result: 8" in result[0].text # type: ignore[attr-defined]
|
| 697 |
|
| 698 |
|
| 699 |
class TestProxy:
|
|
@@ -728,7 +742,7 @@ class TestProxy:
|
|
| 728 |
async with Client(proxy_server) as client:
|
| 729 |
# The tool should be registered with its transformed name
|
| 730 |
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
|
| 731 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
| 732 |
|
| 733 |
|
| 734 |
async def test_arg_transform_default_factory():
|
|
@@ -751,7 +765,7 @@ async def test_arg_transform_default_factory():
|
|
| 751 |
|
| 752 |
# Should work without providing timestamp (gets value from factory)
|
| 753 |
result = await new_tool.run(arguments={"x": 42})
|
| 754 |
-
assert result[0].text == "42_12345.0" # type: ignore[attr-defined]
|
| 755 |
|
| 756 |
|
| 757 |
async def test_arg_transform_default_factory_called_each_time():
|
|
@@ -779,11 +793,11 @@ async def test_arg_transform_default_factory_called_each_time():
|
|
| 779 |
|
| 780 |
# First call
|
| 781 |
result1 = await new_tool.run(arguments={"x": 1})
|
| 782 |
-
assert result1[0].text == "1_1" # type: ignore[attr-defined]
|
| 783 |
|
| 784 |
# Second call should get a different value
|
| 785 |
result2 = await new_tool.run(arguments={"x": 2})
|
| 786 |
-
assert result2[0].text == "2_2" # type: ignore[attr-defined]
|
| 787 |
|
| 788 |
|
| 789 |
async def test_arg_transform_hidden_with_default_factory():
|
|
@@ -808,7 +822,7 @@ async def test_arg_transform_hidden_with_default_factory():
|
|
| 808 |
|
| 809 |
# Should pass hidden request_id with factory value
|
| 810 |
result = await new_tool.run(arguments={"x": 42})
|
| 811 |
-
assert result[0].text == "42_req_123" # type: ignore[attr-defined]
|
| 812 |
|
| 813 |
|
| 814 |
async def test_arg_transform_default_and_factory_raises_error():
|
|
@@ -845,7 +859,7 @@ async def test_arg_transform_required_true():
|
|
| 845 |
|
| 846 |
# Should work when parameter is provided
|
| 847 |
result = await new_tool.run(arguments={"optional_param": 100})
|
| 848 |
-
assert result[0].text == "value: 100" # type: ignore
|
| 849 |
|
| 850 |
# Should fail when parameter is not provided
|
| 851 |
with pytest.raises(TypeError, match="Missing required argument"):
|
|
@@ -892,7 +906,7 @@ async def test_arg_transform_required_with_rename():
|
|
| 892 |
|
| 893 |
# Should work with new name
|
| 894 |
result = await new_tool.run(arguments={"new_param": 200})
|
| 895 |
-
assert result[0].text == "value: 200" # type: ignore
|
| 896 |
|
| 897 |
|
| 898 |
async def test_arg_transform_required_true_with_default_raises_error():
|
|
@@ -934,7 +948,7 @@ async def test_arg_transform_required_no_change():
|
|
| 934 |
|
| 935 |
# Should work as expected
|
| 936 |
result = await new_tool.run(arguments={"req": 1})
|
| 937 |
-
assert result[0].text == "values: 1, 42" # type: ignore
|
| 938 |
|
| 939 |
|
| 940 |
async def test_arg_transform_hide_and_required_raises_error():
|
|
@@ -966,7 +980,7 @@ class TestEnableDisable:
|
|
| 966 |
assert {tool.name for tool in tools} == {"new_add"}
|
| 967 |
|
| 968 |
result = await client.call_tool("new_add", {"x": 1, "y": 2})
|
| 969 |
-
assert result[0].text == "3" # type: ignore[attr-defined]
|
| 970 |
|
| 971 |
with pytest.raises(ToolError):
|
| 972 |
await client.call_tool("add", {"x": 1, "y": 2})
|
|
@@ -1019,3 +1033,266 @@ def test_arg_transform_examples_in_schema(add_tool):
|
|
| 1019 |
)
|
| 1020 |
prop3 = get_property(new_tool3, "old_x")
|
| 1021 |
assert "examples" not in prop3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
from dirty_equals import IsList
|
| 7 |
+
from mcp.types import TextContent
|
| 8 |
+
from pydantic import BaseModel, Field, TypeAdapter
|
| 9 |
from typing_extensions import TypedDict
|
| 10 |
|
| 11 |
from fastmcp import FastMCP
|
| 12 |
from fastmcp.client.client import Client
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
from fastmcp.tools import Tool, forward, forward_raw
|
| 15 |
+
from fastmcp.tools.tool import FunctionTool, ToolResult
|
| 16 |
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
|
| 17 |
|
| 18 |
|
|
|
|
| 53 |
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
|
| 54 |
)
|
| 55 |
result = await new_tool.run(arguments={"new_x": 1})
|
| 56 |
+
# The parent tool returns int which gets wrapped as structured output
|
| 57 |
+
assert result.structured_content == {"result": 11}
|
| 58 |
|
| 59 |
|
| 60 |
async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
|
|
|
|
| 62 |
add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
|
| 63 |
)
|
| 64 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 65 |
+
# The parent tool returns int which gets wrapped as structured output
|
| 66 |
+
assert result.structured_content == {"result": 11}
|
| 67 |
|
| 68 |
|
| 69 |
def test_tool_change_arg_name(add_tool):
|
|
|
|
| 90 |
)
|
| 91 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 92 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 93 |
+
assert result.structured_content == {"result": 11}
|
| 94 |
|
| 95 |
|
| 96 |
async def test_dropped_args_error_if_provided(add_tool):
|
|
|
|
| 112 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 113 |
# Should pass old_x=5 and old_y=20 to parent
|
| 114 |
result = await new_tool.run(arguments={"old_x": 5})
|
| 115 |
+
assert result.structured_content == {"result": 25}
|
| 116 |
|
| 117 |
|
| 118 |
async def test_hidden_arg_without_default_uses_parent_default(add_tool):
|
|
|
|
| 124 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 125 |
# Should pass old_x=3 and let parent use its default old_y=10
|
| 126 |
result = await new_tool.run(arguments={"old_x": 3})
|
| 127 |
+
assert result.content[0].text == "13" # type: ignore[attr-defined]
|
| 128 |
+
assert result.structured_content == {"result": 13}
|
| 129 |
|
| 130 |
|
| 131 |
async def test_mixed_hidden_args_with_custom_function(add_tool):
|
| 132 |
"""Test custom function with both hidden constant and hidden default parameters."""
|
| 133 |
|
| 134 |
+
async def custom_fn(visible_x: int) -> ToolResult:
|
| 135 |
# This custom function should receive the transformed visible parameter
|
| 136 |
# and the hidden parameters should be automatically handled
|
| 137 |
result = await forward(visible_x=visible_x)
|
|
|
|
| 150 |
assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
|
| 151 |
# Should pass visible_x=7 as old_x=7 and old_y=25 to parent
|
| 152 |
result = await new_tool.run(arguments={"visible_x": 7})
|
| 153 |
+
assert result.content[0].text == "32" # type: ignore[attr-defined]
|
| 154 |
+
assert result.structured_content == {"result": 32}
|
| 155 |
|
| 156 |
|
| 157 |
async def test_hide_required_param_without_default_raises_error():
|
|
|
|
| 189 |
assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
|
| 190 |
# Should pass required_param=5 and optional_param=20 to parent
|
| 191 |
result = await new_tool.run(arguments={"optional_param": 20})
|
| 192 |
+
assert result.structured_content == {"result": 25}
|
| 193 |
|
| 194 |
|
| 195 |
async def test_forward_with_argument_mapping(add_tool):
|
| 196 |
"""Test that forward() applies argument mapping correctly."""
|
| 197 |
|
| 198 |
+
async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
|
| 199 |
return await forward(new_x=new_x, new_y=new_y)
|
| 200 |
|
| 201 |
new_tool = Tool.from_tool(
|
|
|
|
| 208 |
)
|
| 209 |
|
| 210 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 211 |
+
assert result.content[0].text == "5" # type: ignore[attr-defined]
|
| 212 |
+
assert result.structured_content == {"result": 5}
|
| 213 |
|
| 214 |
|
| 215 |
async def test_forward_with_incorrect_args_raises_error(add_tool):
|
| 216 |
+
async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
|
| 217 |
# the forward should use the new args, not the old ones
|
| 218 |
return await forward(old_x=new_x, old_y=new_y)
|
| 219 |
|
|
|
|
| 234 |
async def test_forward_raw_without_argument_mapping(add_tool):
|
| 235 |
"""Test that forward_raw() calls parent directly without mapping."""
|
| 236 |
|
| 237 |
+
async def custom_fn(new_x: int, new_y: int = 5) -> ToolResult:
|
| 238 |
# Call parent directly with original argument names
|
| 239 |
result = await forward_raw(old_x=new_x, old_y=new_y)
|
| 240 |
return result
|
|
|
|
| 249 |
)
|
| 250 |
|
| 251 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 252 |
+
assert result.content[0].text == "5" # type: ignore[attr-defined]
|
| 253 |
+
assert result.structured_content == {"result": 5}
|
| 254 |
|
| 255 |
|
| 256 |
async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
|
| 257 |
async def custom_fn(extra: int, **kwargs) -> int:
|
| 258 |
sum = await forward(**kwargs)
|
| 259 |
+
return int(sum.content[0].text) + extra # type: ignore[attr-defined]
|
| 260 |
|
| 261 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 262 |
result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
|
| 263 |
+
assert result.content[0].text == "6" # type: ignore[attr-defined]
|
| 264 |
+
assert result.structured_content == {"result": 6}
|
| 265 |
assert new_tool.parameters["required"] == IsList(
|
| 266 |
"extra", "old_x", check_order=False
|
| 267 |
)
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
async def test_fn_with_kwargs_passes_through_original_args(add_tool):
|
| 274 |
+
async def custom_fn(new_y: int = 5, **kwargs) -> ToolResult:
|
| 275 |
assert kwargs == {"old_y": 3}
|
| 276 |
result = await forward(old_x=new_y, **kwargs)
|
| 277 |
return result
|
| 278 |
|
| 279 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 280 |
result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
|
| 281 |
+
assert result.content[0].text == "5" # type: ignore[attr-defined]
|
| 282 |
+
assert result.structured_content == {"result": 5}
|
| 283 |
|
| 284 |
|
| 285 |
async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
|
| 286 |
"""Test that **kwargs receives arguments with their transformed names from transform_args."""
|
| 287 |
|
| 288 |
+
async def custom_fn(new_x: int, **kwargs) -> ToolResult:
|
| 289 |
# kwargs should contain 'old_y': 3 (transformed name), not 'old_y': 3 (original name)
|
| 290 |
assert kwargs == {"old_y": 3}
|
| 291 |
result = await forward(new_x=new_x, **kwargs)
|
|
|
|
| 297 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 298 |
)
|
| 299 |
result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
|
| 300 |
+
assert result.content[0].text == "5" # type: ignore[attr-defined]
|
| 301 |
+
assert result.structured_content == {"result": 5}
|
| 302 |
|
| 303 |
|
| 304 |
async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
|
| 305 |
"""Test that function can explicitly handle some transformed args while others pass through kwargs."""
|
| 306 |
|
| 307 |
+
async def custom_fn(
|
| 308 |
+
new_x: int, some_other_param: str = "default", **kwargs
|
| 309 |
+
) -> ToolResult:
|
| 310 |
# x is explicitly handled, y should come through kwargs with transformed name
|
| 311 |
assert kwargs == {"old_y": 7}
|
| 312 |
result = await forward(new_x=new_x, **kwargs)
|
|
|
|
| 320 |
result = await new_tool.run(
|
| 321 |
arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
|
| 322 |
)
|
| 323 |
+
assert result.content[0].text == "10" # type: ignore[attr-defined]
|
| 324 |
+
assert result.structured_content == {"result": 10}
|
| 325 |
|
| 326 |
|
| 327 |
async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
|
| 328 |
"""Test **kwargs behavior with mix of mapped and unmapped arguments."""
|
| 329 |
|
| 330 |
+
async def custom_fn(new_x: int, **kwargs) -> ToolResult:
|
| 331 |
# new_x is explicitly handled, old_y should pass through kwargs with original name (unmapped)
|
| 332 |
assert kwargs == {"old_y": 5}
|
| 333 |
result = await forward(new_x=new_x, **kwargs)
|
|
|
|
| 339 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 340 |
) # only map 'a'
|
| 341 |
result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
|
| 342 |
+
assert result.content[0].text == "6" # type: ignore[attr-defined]
|
| 343 |
+
assert result.structured_content == {"result": 6}
|
| 344 |
|
| 345 |
|
| 346 |
async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
|
| 347 |
"""Test that dropped arguments don't appear in **kwargs."""
|
| 348 |
|
| 349 |
+
async def custom_fn(new_x: int, **kwargs) -> ToolResult:
|
| 350 |
# 'b' was dropped, so kwargs should be empty
|
| 351 |
assert kwargs == {}
|
| 352 |
# Can't use 'old_y' since it was dropped, so just use 'old_x' mapped to 'new_x'
|
|
|
|
| 363 |
) # drop 'old_y'
|
| 364 |
result = await new_tool.run(arguments={"new_x": 8})
|
| 365 |
# 8 + 10 (default value of b in parent)
|
| 366 |
+
assert result.content[0].text == "18" # type: ignore[attr-defined]
|
| 367 |
|
| 368 |
|
| 369 |
async def test_forward_outside_context_raises_error():
|
|
|
|
| 483 |
tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
|
| 484 |
|
| 485 |
result = await tool2.run(arguments={"final_x": 5})
|
| 486 |
+
assert result.content[0].text == "15" # type: ignore[attr-defined]
|
| 487 |
|
| 488 |
# Transform tool1 with custom function that handles all parameters
|
| 489 |
async def custom(final_x: int, **kwargs) -> str:
|
| 490 |
result = await forward(final_x=final_x, **kwargs)
|
| 491 |
+
return f"custom {result.content[0].text}" # Extract text from content # type: ignore[attr-defined]
|
| 492 |
|
| 493 |
tool3 = Tool.from_tool(
|
| 494 |
tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
|
| 495 |
)
|
| 496 |
result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
|
| 497 |
+
assert result.content[0].text == "custom 8" # type: ignore[attr-defined]
|
| 498 |
|
| 499 |
|
| 500 |
class MyModel(BaseModel):
|
|
|
|
| 622 |
# Function signature has different types/defaults than ArgTransform
|
| 623 |
async def custom_fn(x: str = "function_default", **kwargs) -> str:
|
| 624 |
result = await forward(x=x, **kwargs)
|
| 625 |
+
return f"custom: {result.content[0].text}" # type: ignore[attr-defined]
|
| 626 |
|
| 627 |
tool = Tool.from_tool(
|
| 628 |
base,
|
|
|
|
| 649 |
# Test it works at runtime
|
| 650 |
result = await tool.run(arguments={"y": "test"})
|
| 651 |
# Should use ArgTransform default of 42
|
| 652 |
+
assert "42: test" in result.content[0].text # type: ignore[attr-defined]
|
| 653 |
|
| 654 |
|
| 655 |
def test_arg_transform_combined_attributes():
|
|
|
|
| 694 |
# Convert string back to int for the original function
|
| 695 |
result = await forward_raw(x=int(x), y=y)
|
| 696 |
# Extract the text from the result
|
| 697 |
+
result_text = result.content[0].text # type: ignore[attr-defined]
|
| 698 |
return f"String input '{x}' converted to result: {result_text}"
|
| 699 |
|
| 700 |
tool = Tool.from_tool(
|
|
|
|
| 706 |
|
| 707 |
# Test it works with string input
|
| 708 |
result = await tool.run(arguments={"x": "5", "y": 3})
|
| 709 |
+
assert "String input '5'" in result.content[0].text # type: ignore[attr-defined]
|
| 710 |
+
assert "result: 8" in result.content[0].text # type: ignore[attr-defined]
|
| 711 |
|
| 712 |
|
| 713 |
class TestProxy:
|
|
|
|
| 742 |
async with Client(proxy_server) as client:
|
| 743 |
# The tool should be registered with its transformed name
|
| 744 |
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
|
| 745 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 746 |
|
| 747 |
|
| 748 |
async def test_arg_transform_default_factory():
|
|
|
|
| 765 |
|
| 766 |
# Should work without providing timestamp (gets value from factory)
|
| 767 |
result = await new_tool.run(arguments={"x": 42})
|
| 768 |
+
assert result.content[0].text == "42_12345.0" # type: ignore[attr-defined]
|
| 769 |
|
| 770 |
|
| 771 |
async def test_arg_transform_default_factory_called_each_time():
|
|
|
|
| 793 |
|
| 794 |
# First call
|
| 795 |
result1 = await new_tool.run(arguments={"x": 1})
|
| 796 |
+
assert result1.content[0].text == "1_1" # type: ignore[attr-defined]
|
| 797 |
|
| 798 |
# Second call should get a different value
|
| 799 |
result2 = await new_tool.run(arguments={"x": 2})
|
| 800 |
+
assert result2.content[0].text == "2_2" # type: ignore[attr-defined]
|
| 801 |
|
| 802 |
|
| 803 |
async def test_arg_transform_hidden_with_default_factory():
|
|
|
|
| 822 |
|
| 823 |
# Should pass hidden request_id with factory value
|
| 824 |
result = await new_tool.run(arguments={"x": 42})
|
| 825 |
+
assert result.content[0].text == "42_req_123" # type: ignore[attr-defined]
|
| 826 |
|
| 827 |
|
| 828 |
async def test_arg_transform_default_and_factory_raises_error():
|
|
|
|
| 859 |
|
| 860 |
# Should work when parameter is provided
|
| 861 |
result = await new_tool.run(arguments={"optional_param": 100})
|
| 862 |
+
assert result.content[0].text == "value: 100" # type: ignore
|
| 863 |
|
| 864 |
# Should fail when parameter is not provided
|
| 865 |
with pytest.raises(TypeError, match="Missing required argument"):
|
|
|
|
| 906 |
|
| 907 |
# Should work with new name
|
| 908 |
result = await new_tool.run(arguments={"new_param": 200})
|
| 909 |
+
assert result.content[0].text == "value: 200" # type: ignore
|
| 910 |
|
| 911 |
|
| 912 |
async def test_arg_transform_required_true_with_default_raises_error():
|
|
|
|
| 948 |
|
| 949 |
# Should work as expected
|
| 950 |
result = await new_tool.run(arguments={"req": 1})
|
| 951 |
+
assert result.content[0].text == "values: 1, 42" # type: ignore
|
| 952 |
|
| 953 |
|
| 954 |
async def test_arg_transform_hide_and_required_raises_error():
|
|
|
|
| 980 |
assert {tool.name for tool in tools} == {"new_add"}
|
| 981 |
|
| 982 |
result = await client.call_tool("new_add", {"x": 1, "y": 2})
|
| 983 |
+
assert result.content[0].text == "3" # type: ignore[attr-defined]
|
| 984 |
|
| 985 |
with pytest.raises(ToolError):
|
| 986 |
await client.call_tool("add", {"x": 1, "y": 2})
|
|
|
|
| 1033 |
)
|
| 1034 |
prop3 = get_property(new_tool3, "old_x")
|
| 1035 |
assert "examples" not in prop3
|
| 1036 |
+
|
| 1037 |
+
|
| 1038 |
+
class TestTransformToolOutputSchema:
|
| 1039 |
+
"""Test output schema handling in transformed tools."""
|
| 1040 |
+
|
| 1041 |
+
@pytest.fixture
|
| 1042 |
+
def base_string_tool(self) -> FunctionTool:
|
| 1043 |
+
"""Tool that returns a string (gets wrapped)."""
|
| 1044 |
+
|
| 1045 |
+
def string_tool(x: int) -> str:
|
| 1046 |
+
return f"Result: {x}"
|
| 1047 |
+
|
| 1048 |
+
return Tool.from_function(string_tool)
|
| 1049 |
+
|
| 1050 |
+
@pytest.fixture
|
| 1051 |
+
def base_dict_tool(self) -> FunctionTool:
|
| 1052 |
+
"""Tool that returns a dict (object type, not wrapped)."""
|
| 1053 |
+
|
| 1054 |
+
def dict_tool(x: int) -> dict[str, int]:
|
| 1055 |
+
return {"value": x}
|
| 1056 |
+
|
| 1057 |
+
return Tool.from_function(dict_tool)
|
| 1058 |
+
|
| 1059 |
+
def test_transform_inherits_parent_output_schema(self, base_string_tool):
|
| 1060 |
+
"""Test that transformed tool inherits parent's output schema by default."""
|
| 1061 |
+
new_tool = Tool.from_tool(base_string_tool)
|
| 1062 |
+
|
| 1063 |
+
# Should inherit parent's wrapped string schema
|
| 1064 |
+
expected_schema = {
|
| 1065 |
+
"type": "object",
|
| 1066 |
+
"properties": {"result": {"type": "string"}},
|
| 1067 |
+
"x-fastmcp-wrap-result": True,
|
| 1068 |
+
}
|
| 1069 |
+
assert new_tool.output_schema == expected_schema
|
| 1070 |
+
assert new_tool.output_schema == base_string_tool.output_schema
|
| 1071 |
+
|
| 1072 |
+
def test_transform_with_explicit_output_schema_false(self, base_string_tool):
|
| 1073 |
+
"""Test that output_schema=False disables structured output."""
|
| 1074 |
+
new_tool = Tool.from_tool(base_string_tool, output_schema=False)
|
| 1075 |
+
|
| 1076 |
+
assert new_tool.output_schema is None
|
| 1077 |
+
|
| 1078 |
+
async def test_transform_output_schema_false_runtime(self, base_string_tool):
|
| 1079 |
+
"""Test runtime behavior with output_schema=False."""
|
| 1080 |
+
new_tool = Tool.from_tool(base_string_tool, output_schema=False)
|
| 1081 |
+
|
| 1082 |
+
# Debug: check that output_schema is actually None
|
| 1083 |
+
assert new_tool.output_schema is None, (
|
| 1084 |
+
f"Expected None, got {new_tool.output_schema}"
|
| 1085 |
+
)
|
| 1086 |
+
|
| 1087 |
+
result = await new_tool.run({"x": 5})
|
| 1088 |
+
assert result.structured_content is None
|
| 1089 |
+
assert result.content[0].text == "Result: 5" # type: ignore[attr-defined]
|
| 1090 |
+
|
| 1091 |
+
def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
|
| 1092 |
+
"""Test that explicit output schema overrides parent."""
|
| 1093 |
+
custom_schema = {
|
| 1094 |
+
"type": "object",
|
| 1095 |
+
"properties": {"message": {"type": "string"}},
|
| 1096 |
+
}
|
| 1097 |
+
new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
|
| 1098 |
+
|
| 1099 |
+
assert new_tool.output_schema == custom_schema
|
| 1100 |
+
assert new_tool.output_schema != base_string_tool.output_schema
|
| 1101 |
+
|
| 1102 |
+
async def test_transform_explicit_schema_runtime(self, base_string_tool):
|
| 1103 |
+
"""Test runtime behavior with explicit output schema."""
|
| 1104 |
+
custom_schema = {"type": "string", "minLength": 1}
|
| 1105 |
+
new_tool = Tool.from_tool(base_string_tool, output_schema=custom_schema)
|
| 1106 |
+
|
| 1107 |
+
result = await new_tool.run({"x": 10})
|
| 1108 |
+
# Non-object explicit schemas disable structured content
|
| 1109 |
+
assert result.structured_content is None
|
| 1110 |
+
assert result.content[0].text == "Result: 10" # type: ignore[attr-defined]
|
| 1111 |
+
|
| 1112 |
+
def test_transform_with_custom_function_inferred_schema(self, base_dict_tool):
|
| 1113 |
+
"""Test that custom function's output schema is inferred."""
|
| 1114 |
+
|
| 1115 |
+
async def custom_fn(x: int) -> str:
|
| 1116 |
+
result = await forward(x=x)
|
| 1117 |
+
return f"Custom: {result.content[0].text}" # type: ignore[attr-defined]
|
| 1118 |
+
|
| 1119 |
+
new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
|
| 1120 |
+
|
| 1121 |
+
# Should infer string schema from custom function and wrap it
|
| 1122 |
+
expected_schema = {
|
| 1123 |
+
"type": "object",
|
| 1124 |
+
"properties": {"result": {"type": "string"}},
|
| 1125 |
+
"x-fastmcp-wrap-result": True,
|
| 1126 |
+
}
|
| 1127 |
+
assert new_tool.output_schema == expected_schema
|
| 1128 |
+
|
| 1129 |
+
async def test_transform_custom_function_runtime(self, base_dict_tool):
|
| 1130 |
+
"""Test runtime behavior with custom function that has inferred schema."""
|
| 1131 |
+
|
| 1132 |
+
async def custom_fn(x: int) -> str:
|
| 1133 |
+
result = await forward(x=x)
|
| 1134 |
+
return f"Custom: {result.content[0].text}" # type: ignore[attr-defined]
|
| 1135 |
+
|
| 1136 |
+
new_tool = Tool.from_tool(base_dict_tool, transform_fn=custom_fn)
|
| 1137 |
+
|
| 1138 |
+
result = await new_tool.run({"x": 3})
|
| 1139 |
+
# Should wrap string result
|
| 1140 |
+
assert result.structured_content == {"result": 'Custom: {\n "value": 3\n}'}
|
| 1141 |
+
|
| 1142 |
+
def test_transform_custom_function_fallback_to_parent(self, base_string_tool):
|
| 1143 |
+
"""Test that custom function without output annotation falls back to parent."""
|
| 1144 |
+
|
| 1145 |
+
async def custom_fn(x: int):
|
| 1146 |
+
# No return annotation - should fallback to parent schema
|
| 1147 |
+
result = await forward(x=x)
|
| 1148 |
+
return result
|
| 1149 |
+
|
| 1150 |
+
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
| 1151 |
+
|
| 1152 |
+
# Should use parent's schema since custom function has no annotation
|
| 1153 |
+
assert new_tool.output_schema == base_string_tool.output_schema
|
| 1154 |
+
|
| 1155 |
+
def test_transform_custom_function_explicit_overrides(self, base_string_tool):
|
| 1156 |
+
"""Test that explicit output_schema overrides both custom function and parent."""
|
| 1157 |
+
|
| 1158 |
+
async def custom_fn(x: int) -> dict[str, str]:
|
| 1159 |
+
return {"custom": "value"}
|
| 1160 |
+
|
| 1161 |
+
explicit_schema = {"type": "array", "items": {"type": "number"}}
|
| 1162 |
+
new_tool = Tool.from_tool(
|
| 1163 |
+
base_string_tool, transform_fn=custom_fn, output_schema=explicit_schema
|
| 1164 |
+
)
|
| 1165 |
+
|
| 1166 |
+
# Explicit schema should win
|
| 1167 |
+
assert new_tool.output_schema == explicit_schema
|
| 1168 |
+
|
| 1169 |
+
async def test_transform_custom_function_object_return(self, base_string_tool):
|
| 1170 |
+
"""Test custom function returning object type."""
|
| 1171 |
+
|
| 1172 |
+
async def custom_fn(x: int) -> dict[str, int]:
|
| 1173 |
+
await forward(x=x)
|
| 1174 |
+
return {"original": x, "transformed": x * 2}
|
| 1175 |
+
|
| 1176 |
+
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
| 1177 |
+
|
| 1178 |
+
# Object types should not be wrapped
|
| 1179 |
+
expected_schema = TypeAdapter(dict[str, int]).json_schema()
|
| 1180 |
+
assert new_tool.output_schema == expected_schema
|
| 1181 |
+
assert "x-fastmcp-wrap-result" not in new_tool.output_schema # type: ignore[attr-defined]
|
| 1182 |
+
|
| 1183 |
+
result = await new_tool.run({"x": 4})
|
| 1184 |
+
# Direct value, not wrapped
|
| 1185 |
+
assert result.structured_content == {"original": 4, "transformed": 8}
|
| 1186 |
+
|
| 1187 |
+
async def test_transform_preserves_wrap_marker_behavior(self, base_string_tool):
|
| 1188 |
+
"""Test that wrap marker behavior is preserved through transformation."""
|
| 1189 |
+
new_tool = Tool.from_tool(base_string_tool)
|
| 1190 |
+
|
| 1191 |
+
result = await new_tool.run({"x": 7})
|
| 1192 |
+
# Should wrap because parent schema has wrap marker
|
| 1193 |
+
assert result.structured_content == {"result": "Result: 7"}
|
| 1194 |
+
assert "x-fastmcp-wrap-result" in new_tool.output_schema # type: ignore[attr-defined]
|
| 1195 |
+
|
| 1196 |
+
def test_transform_chained_output_schema_inheritance(self, base_string_tool):
|
| 1197 |
+
"""Test output schema inheritance through multiple transformations."""
|
| 1198 |
+
# First transformation keeps parent schema
|
| 1199 |
+
tool1 = Tool.from_tool(base_string_tool)
|
| 1200 |
+
assert tool1.output_schema == base_string_tool.output_schema
|
| 1201 |
+
|
| 1202 |
+
# Second transformation also inherits
|
| 1203 |
+
tool2 = Tool.from_tool(tool1)
|
| 1204 |
+
assert (
|
| 1205 |
+
tool2.output_schema == tool1.output_schema == base_string_tool.output_schema
|
| 1206 |
+
)
|
| 1207 |
+
|
| 1208 |
+
# Third transformation with explicit override
|
| 1209 |
+
custom_schema = {"type": "number"}
|
| 1210 |
+
tool3 = Tool.from_tool(tool2, output_schema=custom_schema)
|
| 1211 |
+
assert tool3.output_schema == custom_schema
|
| 1212 |
+
assert tool3.output_schema != tool2.output_schema
|
| 1213 |
+
|
| 1214 |
+
async def test_transform_mixed_structured_unstructured_content(
|
| 1215 |
+
self, base_string_tool
|
| 1216 |
+
):
|
| 1217 |
+
"""Test transformation handling of mixed content types."""
|
| 1218 |
+
|
| 1219 |
+
async def custom_fn(x: int):
|
| 1220 |
+
# Return mixed content including ToolResult
|
| 1221 |
+
if x == 1:
|
| 1222 |
+
return ["text", {"data": x}]
|
| 1223 |
+
else:
|
| 1224 |
+
# Return ToolResult directly
|
| 1225 |
+
return ToolResult(
|
| 1226 |
+
content=[TextContent(type="text", text=f"Custom: {x}")],
|
| 1227 |
+
structured_content={"custom_value": x},
|
| 1228 |
+
)
|
| 1229 |
+
|
| 1230 |
+
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
| 1231 |
+
|
| 1232 |
+
# Test mixed content return
|
| 1233 |
+
result1 = await new_tool.run({"x": 1})
|
| 1234 |
+
assert result1.structured_content == {"result": ["text", {"data": 1}]}
|
| 1235 |
+
|
| 1236 |
+
# Test ToolResult return
|
| 1237 |
+
result2 = await new_tool.run({"x": 2})
|
| 1238 |
+
assert result2.structured_content == {"custom_value": 2}
|
| 1239 |
+
assert result2.content[0].text == "Custom: 2" # type: ignore[attr-defined]
|
| 1240 |
+
|
| 1241 |
+
def test_transform_output_schema_with_arg_transforms(self, base_string_tool):
|
| 1242 |
+
"""Test that output schema works correctly with argument transformations."""
|
| 1243 |
+
|
| 1244 |
+
async def custom_fn(new_x: int) -> dict[str, str]:
|
| 1245 |
+
result = await forward(new_x=new_x)
|
| 1246 |
+
return {"transformed": result.content[0].text} # type: ignore[attr-defined]
|
| 1247 |
+
|
| 1248 |
+
new_tool = Tool.from_tool(
|
| 1249 |
+
base_string_tool,
|
| 1250 |
+
transform_fn=custom_fn,
|
| 1251 |
+
transform_args={"x": ArgTransform(name="new_x")},
|
| 1252 |
+
)
|
| 1253 |
+
|
| 1254 |
+
# Should infer object schema from custom function
|
| 1255 |
+
expected_schema = TypeAdapter(dict[str, str]).json_schema()
|
| 1256 |
+
assert new_tool.output_schema == expected_schema
|
| 1257 |
+
|
| 1258 |
+
async def test_transform_output_schema_none_vs_false(self, base_string_tool):
|
| 1259 |
+
"""Test None vs False behavior for output_schema in transforms."""
|
| 1260 |
+
# None (default) should use smart fallback (inherit from parent)
|
| 1261 |
+
tool_none = Tool.from_tool(base_string_tool) # default output_schema=None
|
| 1262 |
+
assert tool_none.output_schema == base_string_tool.output_schema # Inherits
|
| 1263 |
+
|
| 1264 |
+
# False should explicitly disable
|
| 1265 |
+
tool_false = Tool.from_tool(base_string_tool, output_schema=False)
|
| 1266 |
+
assert tool_false.output_schema is None
|
| 1267 |
+
|
| 1268 |
+
# Different behavior at runtime
|
| 1269 |
+
result_none = await tool_none.run({"x": 5})
|
| 1270 |
+
result_false = await tool_false.run({"x": 5})
|
| 1271 |
+
|
| 1272 |
+
assert result_none.structured_content == {
|
| 1273 |
+
"result": "Result: 5"
|
| 1274 |
+
} # Inherits wrapping
|
| 1275 |
+
assert result_false.structured_content is None # Disabled
|
| 1276 |
+
assert result_none.content[0].text == result_false.content[0].text # type: ignore[attr-defined]
|
| 1277 |
+
|
| 1278 |
+
async def test_transform_output_schema_with_tool_result_return(
|
| 1279 |
+
self, base_string_tool
|
| 1280 |
+
):
|
| 1281 |
+
"""Test transform when custom function returns ToolResult directly."""
|
| 1282 |
+
|
| 1283 |
+
async def custom_fn(x: int) -> ToolResult:
|
| 1284 |
+
# Custom function returns ToolResult - should bypass schema handling
|
| 1285 |
+
return ToolResult(
|
| 1286 |
+
content=[TextContent(type="text", text=f"Direct: {x}")],
|
| 1287 |
+
structured_content={"direct_value": x, "doubled": x * 2},
|
| 1288 |
+
)
|
| 1289 |
+
|
| 1290 |
+
new_tool = Tool.from_tool(base_string_tool, transform_fn=custom_fn)
|
| 1291 |
+
|
| 1292 |
+
# ToolResult return type should result in None output schema
|
| 1293 |
+
assert new_tool.output_schema is None
|
| 1294 |
+
|
| 1295 |
+
result = await new_tool.run({"x": 6})
|
| 1296 |
+
# Should use ToolResult content directly
|
| 1297 |
+
assert result.content[0].text == "Direct: 6" # type: ignore[attr-defined]
|
| 1298 |
+
assert result.structured_content == {"direct_value": 6, "doubled": 12}
|
tests/utilities/openapi/test_openapi.py
CHANGED
|
@@ -687,6 +687,29 @@ def test_multiple_tags_preserved(bookstore_schema):
|
|
| 687 |
assert len(get_books.tags) == 3
|
| 688 |
|
| 689 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
# --- Tests for BookStore schema --- #
|
| 691 |
|
| 692 |
|
|
|
|
| 687 |
assert len(get_books.tags) == 3
|
| 688 |
|
| 689 |
|
| 690 |
+
def test_openapi_extensions(petstore_schema):
|
| 691 |
+
"""Test that OpenAPI extensions (x-*) are correctly parsed from operations."""
|
| 692 |
+
# Add extensions to a route
|
| 693 |
+
petstore_schema["paths"]["/pets"]["get"]["x-rate-limit"] = 100
|
| 694 |
+
petstore_schema["paths"]["/pets"]["get"]["x-custom-auth"] = "bearer"
|
| 695 |
+
petstore_schema["paths"]["/pets"]["get"]["x-internal"] = True
|
| 696 |
+
|
| 697 |
+
# Parse the modified schema
|
| 698 |
+
routes = parse_openapi_to_http_routes(petstore_schema)
|
| 699 |
+
|
| 700 |
+
# Find the GET /pets route
|
| 701 |
+
get_pets = next(
|
| 702 |
+
(r for r in routes if r.method == "GET" and r.path == "/pets"), None
|
| 703 |
+
)
|
| 704 |
+
assert get_pets is not None
|
| 705 |
+
|
| 706 |
+
# Should have extensions
|
| 707 |
+
assert get_pets.extensions["x-rate-limit"] == 100
|
| 708 |
+
assert get_pets.extensions["x-custom-auth"] == "bearer"
|
| 709 |
+
assert get_pets.extensions["x-internal"] is True
|
| 710 |
+
assert len(get_pets.extensions) == 3
|
| 711 |
+
|
| 712 |
+
|
| 713 |
# --- Tests for BookStore schema --- #
|
| 714 |
|
| 715 |
|
tests/utilities/test_json_schema_type.py
ADDED
|
@@ -0,0 +1,1441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Any, Union
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
|
| 6 |
+
|
| 7 |
+
from fastmcp.utilities.json_schema_type import (
|
| 8 |
+
_hash_schema,
|
| 9 |
+
_merge_defaults,
|
| 10 |
+
json_schema_to_type,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestSimpleTypes:
|
| 15 |
+
"""Test suite for basic type validation."""
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def simple_string(self):
|
| 19 |
+
return json_schema_to_type({"type": "string"})
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def simple_number(self):
|
| 23 |
+
return json_schema_to_type({"type": "number"})
|
| 24 |
+
|
| 25 |
+
@pytest.fixture
|
| 26 |
+
def simple_integer(self):
|
| 27 |
+
return json_schema_to_type({"type": "integer"})
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def simple_boolean(self):
|
| 31 |
+
return json_schema_to_type({"type": "boolean"})
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def simple_null(self):
|
| 35 |
+
return json_schema_to_type({"type": "null"})
|
| 36 |
+
|
| 37 |
+
def test_string_accepts_string(self, simple_string):
|
| 38 |
+
validator = TypeAdapter(simple_string)
|
| 39 |
+
assert validator.validate_python("test") == "test"
|
| 40 |
+
|
| 41 |
+
def test_string_rejects_number(self, simple_string):
|
| 42 |
+
validator = TypeAdapter(simple_string)
|
| 43 |
+
with pytest.raises(ValidationError):
|
| 44 |
+
validator.validate_python(123)
|
| 45 |
+
|
| 46 |
+
def test_number_accepts_float(self, simple_number):
|
| 47 |
+
validator = TypeAdapter(simple_number)
|
| 48 |
+
assert validator.validate_python(123.45) == 123.45
|
| 49 |
+
|
| 50 |
+
def test_number_accepts_integer(self, simple_number):
|
| 51 |
+
validator = TypeAdapter(simple_number)
|
| 52 |
+
assert validator.validate_python(123) == 123
|
| 53 |
+
|
| 54 |
+
def test_number_accepts_numeric_string(self, simple_number):
|
| 55 |
+
validator = TypeAdapter(simple_number)
|
| 56 |
+
assert validator.validate_python("123.45") == 123.45
|
| 57 |
+
assert validator.validate_python("123") == 123
|
| 58 |
+
|
| 59 |
+
def test_number_rejects_invalid_string(self, simple_number):
|
| 60 |
+
validator = TypeAdapter(simple_number)
|
| 61 |
+
with pytest.raises(ValidationError):
|
| 62 |
+
validator.validate_python("not a number")
|
| 63 |
+
|
| 64 |
+
def test_integer_accepts_integer(self, simple_integer):
|
| 65 |
+
validator = TypeAdapter(simple_integer)
|
| 66 |
+
assert validator.validate_python(123) == 123
|
| 67 |
+
|
| 68 |
+
def test_integer_accepts_integer_string(self, simple_integer):
|
| 69 |
+
validator = TypeAdapter(simple_integer)
|
| 70 |
+
assert validator.validate_python("123") == 123
|
| 71 |
+
|
| 72 |
+
def test_integer_rejects_float(self, simple_integer):
|
| 73 |
+
validator = TypeAdapter(simple_integer)
|
| 74 |
+
with pytest.raises(ValidationError):
|
| 75 |
+
validator.validate_python(123.45)
|
| 76 |
+
|
| 77 |
+
def test_integer_rejects_float_string(self, simple_integer):
|
| 78 |
+
validator = TypeAdapter(simple_integer)
|
| 79 |
+
with pytest.raises(ValidationError):
|
| 80 |
+
validator.validate_python("123.45")
|
| 81 |
+
|
| 82 |
+
def test_boolean_accepts_boolean(self, simple_boolean):
|
| 83 |
+
validator = TypeAdapter(simple_boolean)
|
| 84 |
+
assert validator.validate_python(True) is True
|
| 85 |
+
assert validator.validate_python(False) is False
|
| 86 |
+
|
| 87 |
+
def test_boolean_accepts_boolean_strings(self, simple_boolean):
|
| 88 |
+
validator = TypeAdapter(simple_boolean)
|
| 89 |
+
assert validator.validate_python("true") is True
|
| 90 |
+
assert validator.validate_python("True") is True
|
| 91 |
+
assert validator.validate_python("false") is False
|
| 92 |
+
assert validator.validate_python("False") is False
|
| 93 |
+
|
| 94 |
+
def test_boolean_rejects_invalid_string(self, simple_boolean):
|
| 95 |
+
validator = TypeAdapter(simple_boolean)
|
| 96 |
+
with pytest.raises(ValidationError):
|
| 97 |
+
validator.validate_python("not a boolean")
|
| 98 |
+
|
| 99 |
+
def test_null_accepts_none(self, simple_null):
|
| 100 |
+
validator = TypeAdapter(simple_null)
|
| 101 |
+
assert validator.validate_python(None) is None
|
| 102 |
+
|
| 103 |
+
def test_null_rejects_false(self, simple_null):
|
| 104 |
+
validator = TypeAdapter(simple_null)
|
| 105 |
+
with pytest.raises(ValidationError):
|
| 106 |
+
validator.validate_python(False)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class TestStringConstraints:
|
| 110 |
+
"""Test suite for string constraint validation."""
|
| 111 |
+
|
| 112 |
+
@pytest.fixture
|
| 113 |
+
def min_length_string(self):
|
| 114 |
+
return json_schema_to_type({"type": "string", "minLength": 3})
|
| 115 |
+
|
| 116 |
+
@pytest.fixture
|
| 117 |
+
def max_length_string(self):
|
| 118 |
+
return json_schema_to_type({"type": "string", "maxLength": 5})
|
| 119 |
+
|
| 120 |
+
@pytest.fixture
|
| 121 |
+
def pattern_string(self):
|
| 122 |
+
return json_schema_to_type({"type": "string", "pattern": "^[A-Z][a-z]+$"})
|
| 123 |
+
|
| 124 |
+
@pytest.fixture
|
| 125 |
+
def email_string(self):
|
| 126 |
+
return json_schema_to_type({"type": "string", "format": "email"})
|
| 127 |
+
|
| 128 |
+
def test_min_length_accepts_valid(self, min_length_string):
|
| 129 |
+
validator = TypeAdapter(min_length_string)
|
| 130 |
+
assert validator.validate_python("test") == "test"
|
| 131 |
+
|
| 132 |
+
def test_min_length_rejects_short(self, min_length_string):
|
| 133 |
+
validator = TypeAdapter(min_length_string)
|
| 134 |
+
with pytest.raises(ValidationError):
|
| 135 |
+
validator.validate_python("ab")
|
| 136 |
+
|
| 137 |
+
def test_max_length_accepts_valid(self, max_length_string):
|
| 138 |
+
validator = TypeAdapter(max_length_string)
|
| 139 |
+
assert validator.validate_python("test") == "test"
|
| 140 |
+
|
| 141 |
+
def test_max_length_rejects_long(self, max_length_string):
|
| 142 |
+
validator = TypeAdapter(max_length_string)
|
| 143 |
+
with pytest.raises(ValidationError):
|
| 144 |
+
validator.validate_python("toolong")
|
| 145 |
+
|
| 146 |
+
def test_pattern_accepts_valid(self, pattern_string):
|
| 147 |
+
validator = TypeAdapter(pattern_string)
|
| 148 |
+
assert validator.validate_python("Hello") == "Hello"
|
| 149 |
+
|
| 150 |
+
def test_pattern_rejects_invalid(self, pattern_string):
|
| 151 |
+
validator = TypeAdapter(pattern_string)
|
| 152 |
+
with pytest.raises(ValidationError):
|
| 153 |
+
validator.validate_python("hello")
|
| 154 |
+
|
| 155 |
+
def test_email_accepts_valid(self, email_string):
|
| 156 |
+
validator = TypeAdapter(email_string)
|
| 157 |
+
result = validator.validate_python("test@example.com")
|
| 158 |
+
assert result == "test@example.com"
|
| 159 |
+
|
| 160 |
+
def test_email_rejects_invalid(self, email_string):
|
| 161 |
+
validator = TypeAdapter(email_string)
|
| 162 |
+
with pytest.raises(ValidationError):
|
| 163 |
+
validator.validate_python("not-an-email")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class TestNumberConstraints:
|
| 167 |
+
"""Test suite for numeric constraint validation."""
|
| 168 |
+
|
| 169 |
+
@pytest.fixture
|
| 170 |
+
def multiple_of_number(self):
|
| 171 |
+
return json_schema_to_type({"type": "number", "multipleOf": 0.5})
|
| 172 |
+
|
| 173 |
+
@pytest.fixture
|
| 174 |
+
def min_number(self):
|
| 175 |
+
return json_schema_to_type({"type": "number", "minimum": 0})
|
| 176 |
+
|
| 177 |
+
@pytest.fixture
|
| 178 |
+
def exclusive_min_number(self):
|
| 179 |
+
return json_schema_to_type({"type": "number", "exclusiveMinimum": 0})
|
| 180 |
+
|
| 181 |
+
@pytest.fixture
|
| 182 |
+
def max_number(self):
|
| 183 |
+
return json_schema_to_type({"type": "number", "maximum": 100})
|
| 184 |
+
|
| 185 |
+
@pytest.fixture
|
| 186 |
+
def exclusive_max_number(self):
|
| 187 |
+
return json_schema_to_type({"type": "number", "exclusiveMaximum": 100})
|
| 188 |
+
|
| 189 |
+
def test_multiple_of_accepts_valid(self, multiple_of_number):
|
| 190 |
+
validator = TypeAdapter(multiple_of_number)
|
| 191 |
+
assert validator.validate_python(2.5) == 2.5
|
| 192 |
+
|
| 193 |
+
def test_multiple_of_rejects_invalid(self, multiple_of_number):
|
| 194 |
+
validator = TypeAdapter(multiple_of_number)
|
| 195 |
+
with pytest.raises(ValidationError):
|
| 196 |
+
validator.validate_python(2.7)
|
| 197 |
+
|
| 198 |
+
def test_minimum_accepts_equal(self, min_number):
|
| 199 |
+
validator = TypeAdapter(min_number)
|
| 200 |
+
assert validator.validate_python(0) == 0
|
| 201 |
+
|
| 202 |
+
def test_minimum_rejects_less(self, min_number):
|
| 203 |
+
validator = TypeAdapter(min_number)
|
| 204 |
+
with pytest.raises(ValidationError):
|
| 205 |
+
validator.validate_python(-1)
|
| 206 |
+
|
| 207 |
+
def test_exclusive_minimum_rejects_equal(self, exclusive_min_number):
|
| 208 |
+
validator = TypeAdapter(exclusive_min_number)
|
| 209 |
+
with pytest.raises(ValidationError):
|
| 210 |
+
validator.validate_python(0)
|
| 211 |
+
|
| 212 |
+
def test_maximum_accepts_equal(self, max_number):
|
| 213 |
+
validator = TypeAdapter(max_number)
|
| 214 |
+
assert validator.validate_python(100) == 100
|
| 215 |
+
|
| 216 |
+
def test_maximum_rejects_greater(self, max_number):
|
| 217 |
+
validator = TypeAdapter(max_number)
|
| 218 |
+
with pytest.raises(ValidationError):
|
| 219 |
+
validator.validate_python(101)
|
| 220 |
+
|
| 221 |
+
def test_exclusive_maximum_rejects_equal(self, exclusive_max_number):
|
| 222 |
+
validator = TypeAdapter(exclusive_max_number)
|
| 223 |
+
with pytest.raises(ValidationError):
|
| 224 |
+
validator.validate_python(100)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class TestArrayTypes:
|
| 228 |
+
"""Test suite for array validation."""
|
| 229 |
+
|
| 230 |
+
@pytest.fixture
|
| 231 |
+
def string_array(self):
|
| 232 |
+
return json_schema_to_type({"type": "array", "items": {"type": "string"}})
|
| 233 |
+
|
| 234 |
+
@pytest.fixture
|
| 235 |
+
def min_items_array(self):
|
| 236 |
+
return json_schema_to_type(
|
| 237 |
+
{"type": "array", "items": {"type": "string"}, "minItems": 2}
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
@pytest.fixture
|
| 241 |
+
def max_items_array(self):
|
| 242 |
+
return json_schema_to_type(
|
| 243 |
+
{"type": "array", "items": {"type": "string"}, "maxItems": 3}
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
@pytest.fixture
|
| 247 |
+
def unique_items_array(self):
|
| 248 |
+
return json_schema_to_type(
|
| 249 |
+
{"type": "array", "items": {"type": "string"}, "uniqueItems": True}
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
def test_array_accepts_valid_items(self, string_array):
|
| 253 |
+
validator = TypeAdapter(string_array)
|
| 254 |
+
assert validator.validate_python(["a", "b"]) == ["a", "b"]
|
| 255 |
+
|
| 256 |
+
def test_array_rejects_invalid_items(self, string_array):
|
| 257 |
+
validator = TypeAdapter(string_array)
|
| 258 |
+
with pytest.raises(ValidationError):
|
| 259 |
+
validator.validate_python([1, "b"])
|
| 260 |
+
|
| 261 |
+
def test_min_items_accepts_valid(self, min_items_array):
|
| 262 |
+
validator = TypeAdapter(min_items_array)
|
| 263 |
+
assert validator.validate_python(["a", "b"]) == ["a", "b"]
|
| 264 |
+
|
| 265 |
+
def test_min_items_rejects_too_few(self, min_items_array):
|
| 266 |
+
validator = TypeAdapter(min_items_array)
|
| 267 |
+
with pytest.raises(ValidationError):
|
| 268 |
+
validator.validate_python(["a"])
|
| 269 |
+
|
| 270 |
+
def test_max_items_accepts_valid(self, max_items_array):
|
| 271 |
+
validator = TypeAdapter(max_items_array)
|
| 272 |
+
assert validator.validate_python(["a", "b", "c"]) == ["a", "b", "c"]
|
| 273 |
+
|
| 274 |
+
def test_max_items_rejects_too_many(self, max_items_array):
|
| 275 |
+
validator = TypeAdapter(max_items_array)
|
| 276 |
+
with pytest.raises(ValidationError):
|
| 277 |
+
validator.validate_python(["a", "b", "c", "d"])
|
| 278 |
+
|
| 279 |
+
def test_unique_items_accepts_unique(self, unique_items_array):
|
| 280 |
+
validator = TypeAdapter(unique_items_array)
|
| 281 |
+
assert isinstance(validator.validate_python(["a", "b"]), set)
|
| 282 |
+
|
| 283 |
+
def test_unique_items_converts_duplicates(self, unique_items_array):
|
| 284 |
+
validator = TypeAdapter(unique_items_array)
|
| 285 |
+
result = validator.validate_python(["a", "a", "b"])
|
| 286 |
+
assert result == {"a", "b"}
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
class TestObjectTypes:
|
| 290 |
+
"""Test suite for object validation."""
|
| 291 |
+
|
| 292 |
+
@pytest.fixture
|
| 293 |
+
def simple_object(self):
|
| 294 |
+
return json_schema_to_type(
|
| 295 |
+
{
|
| 296 |
+
"type": "object",
|
| 297 |
+
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
| 298 |
+
}
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
@pytest.fixture
|
| 302 |
+
def required_object(self):
|
| 303 |
+
return json_schema_to_type(
|
| 304 |
+
{
|
| 305 |
+
"type": "object",
|
| 306 |
+
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
| 307 |
+
"required": ["name"],
|
| 308 |
+
}
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
@pytest.fixture
|
| 312 |
+
def nested_object(self):
|
| 313 |
+
return json_schema_to_type(
|
| 314 |
+
{
|
| 315 |
+
"type": "object",
|
| 316 |
+
"properties": {
|
| 317 |
+
"user": {
|
| 318 |
+
"type": "object",
|
| 319 |
+
"properties": {
|
| 320 |
+
"name": {"type": "string"},
|
| 321 |
+
"age": {"type": "integer"},
|
| 322 |
+
},
|
| 323 |
+
"required": ["name"],
|
| 324 |
+
}
|
| 325 |
+
},
|
| 326 |
+
}
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
@pytest.mark.parametrize(
|
| 330 |
+
"input_type, expected_type",
|
| 331 |
+
[
|
| 332 |
+
# Plain dict becomes dict[str, Any] (JSON Schema accurate)
|
| 333 |
+
(dict, dict[str, Any]),
|
| 334 |
+
# dict[str, Any] stays the same
|
| 335 |
+
(dict[str, Any], dict[str, Any]),
|
| 336 |
+
# Simple typed dicts work correctly
|
| 337 |
+
(dict[str, str], dict[str, str]),
|
| 338 |
+
(dict[str, int], dict[str, int]),
|
| 339 |
+
# Union value types work
|
| 340 |
+
(dict[str, str | int], dict[str, str | int]),
|
| 341 |
+
# Key types are constrained to str in JSON Schema
|
| 342 |
+
(dict[int, list[str]], dict[str, list[str]]),
|
| 343 |
+
# Union key types become str (JSON Schema limitation)
|
| 344 |
+
(dict[str | int, str | None], dict[str, str | None]),
|
| 345 |
+
],
|
| 346 |
+
)
|
| 347 |
+
def test_dict_types_are_generated_correctly(self, input_type, expected_type):
|
| 348 |
+
schema = TypeAdapter(input_type).json_schema()
|
| 349 |
+
generated_type = json_schema_to_type(schema)
|
| 350 |
+
assert generated_type == expected_type
|
| 351 |
+
|
| 352 |
+
def test_object_accepts_valid(self, simple_object):
|
| 353 |
+
validator = TypeAdapter(simple_object)
|
| 354 |
+
result = validator.validate_python({"name": "test", "age": 30})
|
| 355 |
+
assert result.name == "test"
|
| 356 |
+
assert result.age == 30
|
| 357 |
+
|
| 358 |
+
def test_object_accepts_extra_properties(self, simple_object):
|
| 359 |
+
validator = TypeAdapter(simple_object)
|
| 360 |
+
result = validator.validate_python(
|
| 361 |
+
{"name": "test", "age": 30, "extra": "field"}
|
| 362 |
+
)
|
| 363 |
+
assert result.name == "test"
|
| 364 |
+
assert result.age == 30
|
| 365 |
+
assert not hasattr(result, "extra")
|
| 366 |
+
|
| 367 |
+
def test_required_accepts_valid(self, required_object):
|
| 368 |
+
validator = TypeAdapter(required_object)
|
| 369 |
+
result = validator.validate_python({"name": "test"})
|
| 370 |
+
assert result.name == "test"
|
| 371 |
+
assert result.age is None
|
| 372 |
+
|
| 373 |
+
def test_required_rejects_missing(self, required_object):
|
| 374 |
+
validator = TypeAdapter(required_object)
|
| 375 |
+
with pytest.raises(ValidationError):
|
| 376 |
+
validator.validate_python({})
|
| 377 |
+
|
| 378 |
+
def test_nested_accepts_valid(self, nested_object):
|
| 379 |
+
validator = TypeAdapter(nested_object)
|
| 380 |
+
result = validator.validate_python({"user": {"name": "test", "age": 30}})
|
| 381 |
+
assert result.user.name == "test"
|
| 382 |
+
assert result.user.age == 30
|
| 383 |
+
|
| 384 |
+
def test_nested_rejects_invalid(self, nested_object):
|
| 385 |
+
validator = TypeAdapter(nested_object)
|
| 386 |
+
with pytest.raises(ValidationError):
|
| 387 |
+
validator.validate_python({"user": {"age": 30}})
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
class TestDefaultValues:
|
| 391 |
+
"""Test suite for default value handling."""
|
| 392 |
+
|
| 393 |
+
@pytest.fixture
|
| 394 |
+
def simple_defaults(self):
|
| 395 |
+
return json_schema_to_type(
|
| 396 |
+
{
|
| 397 |
+
"type": "object",
|
| 398 |
+
"properties": {
|
| 399 |
+
"name": {"type": "string", "default": "anonymous"},
|
| 400 |
+
"age": {"type": "integer", "default": 0},
|
| 401 |
+
},
|
| 402 |
+
}
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
@pytest.fixture
|
| 406 |
+
def nested_defaults(self):
|
| 407 |
+
return json_schema_to_type(
|
| 408 |
+
{
|
| 409 |
+
"type": "object",
|
| 410 |
+
"properties": {
|
| 411 |
+
"user": {
|
| 412 |
+
"type": "object",
|
| 413 |
+
"properties": {
|
| 414 |
+
"name": {"type": "string", "default": "anonymous"},
|
| 415 |
+
"settings": {
|
| 416 |
+
"type": "object",
|
| 417 |
+
"properties": {
|
| 418 |
+
"theme": {"type": "string", "default": "light"}
|
| 419 |
+
},
|
| 420 |
+
"default": {"theme": "dark"},
|
| 421 |
+
},
|
| 422 |
+
},
|
| 423 |
+
"default": {"name": "guest", "settings": {"theme": "system"}},
|
| 424 |
+
}
|
| 425 |
+
},
|
| 426 |
+
}
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
def test_simple_defaults_empty_object(self, simple_defaults):
|
| 430 |
+
validator = TypeAdapter(simple_defaults)
|
| 431 |
+
result = validator.validate_python({})
|
| 432 |
+
assert result.name == "anonymous"
|
| 433 |
+
assert result.age == 0
|
| 434 |
+
|
| 435 |
+
def test_simple_defaults_partial_override(self, simple_defaults):
|
| 436 |
+
validator = TypeAdapter(simple_defaults)
|
| 437 |
+
result = validator.validate_python({"name": "test"})
|
| 438 |
+
assert result.name == "test"
|
| 439 |
+
assert result.age == 0
|
| 440 |
+
|
| 441 |
+
def test_nested_defaults_empty_object(self, nested_defaults):
|
| 442 |
+
validator = TypeAdapter(nested_defaults)
|
| 443 |
+
result = validator.validate_python({})
|
| 444 |
+
assert result.user.name == "guest"
|
| 445 |
+
assert result.user.settings.theme == "system"
|
| 446 |
+
|
| 447 |
+
def test_nested_defaults_partial_override(self, nested_defaults):
|
| 448 |
+
validator = TypeAdapter(nested_defaults)
|
| 449 |
+
result = validator.validate_python({"user": {"name": "test"}})
|
| 450 |
+
assert result.user.name == "test"
|
| 451 |
+
assert result.user.settings.theme == "system"
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
class TestUnionTypes:
|
| 455 |
+
"""Test suite for testing union type behaviors."""
|
| 456 |
+
|
| 457 |
+
@pytest.fixture
|
| 458 |
+
def heterogeneous_union(self):
|
| 459 |
+
return json_schema_to_type({"type": ["string", "number", "boolean", "null"]})
|
| 460 |
+
|
| 461 |
+
@pytest.fixture
|
| 462 |
+
def union_with_constraints(self):
|
| 463 |
+
return json_schema_to_type(
|
| 464 |
+
{"type": ["string", "number"], "minLength": 3, "minimum": 0}
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
@pytest.fixture
|
| 468 |
+
def union_with_formats(self):
|
| 469 |
+
return json_schema_to_type({"type": ["string", "null"], "format": "email"})
|
| 470 |
+
|
| 471 |
+
@pytest.fixture
|
| 472 |
+
def nested_union_array(self):
|
| 473 |
+
return json_schema_to_type(
|
| 474 |
+
{"type": "array", "items": {"type": ["string", "number"]}}
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
@pytest.fixture
|
| 478 |
+
def nested_union_object(self):
|
| 479 |
+
return json_schema_to_type(
|
| 480 |
+
{
|
| 481 |
+
"type": "object",
|
| 482 |
+
"properties": {
|
| 483 |
+
"id": {"type": ["string", "integer"]},
|
| 484 |
+
"data": {
|
| 485 |
+
"type": ["object", "null"],
|
| 486 |
+
"properties": {"value": {"type": "string"}},
|
| 487 |
+
},
|
| 488 |
+
},
|
| 489 |
+
}
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
def test_heterogeneous_accepts_string(self, heterogeneous_union):
|
| 493 |
+
validator = TypeAdapter(heterogeneous_union)
|
| 494 |
+
assert validator.validate_python("test") == "test"
|
| 495 |
+
|
| 496 |
+
def test_heterogeneous_accepts_number(self, heterogeneous_union):
|
| 497 |
+
validator = TypeAdapter(heterogeneous_union)
|
| 498 |
+
assert validator.validate_python(123.45) == 123.45
|
| 499 |
+
|
| 500 |
+
def test_heterogeneous_accepts_boolean(self, heterogeneous_union):
|
| 501 |
+
validator = TypeAdapter(heterogeneous_union)
|
| 502 |
+
assert validator.validate_python(True) is True
|
| 503 |
+
|
| 504 |
+
def test_heterogeneous_accepts_null(self, heterogeneous_union):
|
| 505 |
+
validator = TypeAdapter(heterogeneous_union)
|
| 506 |
+
assert validator.validate_python(None) is None
|
| 507 |
+
|
| 508 |
+
def test_heterogeneous_rejects_array(self, heterogeneous_union):
|
| 509 |
+
validator = TypeAdapter(heterogeneous_union)
|
| 510 |
+
with pytest.raises(ValidationError):
|
| 511 |
+
validator.validate_python([])
|
| 512 |
+
|
| 513 |
+
def test_constrained_string_valid(self, union_with_constraints):
|
| 514 |
+
validator = TypeAdapter(union_with_constraints)
|
| 515 |
+
assert validator.validate_python("test") == "test"
|
| 516 |
+
|
| 517 |
+
def test_constrained_string_invalid(self, union_with_constraints):
|
| 518 |
+
validator = TypeAdapter(union_with_constraints)
|
| 519 |
+
with pytest.raises(ValidationError):
|
| 520 |
+
validator.validate_python("ab")
|
| 521 |
+
|
| 522 |
+
def test_constrained_number_valid(self, union_with_constraints):
|
| 523 |
+
validator = TypeAdapter(union_with_constraints)
|
| 524 |
+
assert validator.validate_python(10) == 10
|
| 525 |
+
|
| 526 |
+
def test_constrained_number_invalid(self, union_with_constraints):
|
| 527 |
+
validator = TypeAdapter(union_with_constraints)
|
| 528 |
+
with pytest.raises(ValidationError):
|
| 529 |
+
validator.validate_python(-1)
|
| 530 |
+
|
| 531 |
+
def test_format_valid_email(self, union_with_formats):
|
| 532 |
+
validator = TypeAdapter(union_with_formats)
|
| 533 |
+
result = validator.validate_python("test@example.com")
|
| 534 |
+
assert isinstance(result, str)
|
| 535 |
+
|
| 536 |
+
def test_format_valid_null(self, union_with_formats):
|
| 537 |
+
validator = TypeAdapter(union_with_formats)
|
| 538 |
+
assert validator.validate_python(None) is None
|
| 539 |
+
|
| 540 |
+
def test_format_invalid_email(self, union_with_formats):
|
| 541 |
+
validator = TypeAdapter(union_with_formats)
|
| 542 |
+
with pytest.raises(ValidationError):
|
| 543 |
+
validator.validate_python("not-an-email")
|
| 544 |
+
|
| 545 |
+
def test_nested_array_mixed_types(self, nested_union_array):
|
| 546 |
+
validator = TypeAdapter(nested_union_array)
|
| 547 |
+
result = validator.validate_python(["test", 123, "abc"])
|
| 548 |
+
assert result == ["test", 123, "abc"]
|
| 549 |
+
|
| 550 |
+
def test_nested_array_rejects_invalid(self, nested_union_array):
|
| 551 |
+
validator = TypeAdapter(nested_union_array)
|
| 552 |
+
with pytest.raises(ValidationError):
|
| 553 |
+
validator.validate_python(["test", ["not", "allowed"], "abc"])
|
| 554 |
+
|
| 555 |
+
def test_nested_object_string_id(self, nested_union_object):
|
| 556 |
+
validator = TypeAdapter(nested_union_object)
|
| 557 |
+
result = validator.validate_python({"id": "abc123", "data": {"value": "test"}})
|
| 558 |
+
assert result.id == "abc123"
|
| 559 |
+
assert result.data.value == "test"
|
| 560 |
+
|
| 561 |
+
def test_nested_object_integer_id(self, nested_union_object):
|
| 562 |
+
validator = TypeAdapter(nested_union_object)
|
| 563 |
+
result = validator.validate_python({"id": 123, "data": None})
|
| 564 |
+
assert result.id == 123
|
| 565 |
+
assert result.data is None
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
class TestFormatTypes:
|
| 569 |
+
"""Test suite for format type validation."""
|
| 570 |
+
|
| 571 |
+
@pytest.fixture
|
| 572 |
+
def datetime_format(self):
|
| 573 |
+
return json_schema_to_type({"type": "string", "format": "date-time"})
|
| 574 |
+
|
| 575 |
+
@pytest.fixture
|
| 576 |
+
def email_format(self):
|
| 577 |
+
return json_schema_to_type({"type": "string", "format": "email"})
|
| 578 |
+
|
| 579 |
+
@pytest.fixture
|
| 580 |
+
def uri_format(self):
|
| 581 |
+
return json_schema_to_type({"type": "string", "format": "uri"})
|
| 582 |
+
|
| 583 |
+
@pytest.fixture
|
| 584 |
+
def uri_reference_format(self):
|
| 585 |
+
return json_schema_to_type({"type": "string", "format": "uri-reference"})
|
| 586 |
+
|
| 587 |
+
@pytest.fixture
|
| 588 |
+
def json_format(self):
|
| 589 |
+
return json_schema_to_type({"type": "string", "format": "json"})
|
| 590 |
+
|
| 591 |
+
@pytest.fixture
|
| 592 |
+
def mixed_formats_object(self):
|
| 593 |
+
return json_schema_to_type(
|
| 594 |
+
{
|
| 595 |
+
"type": "object",
|
| 596 |
+
"properties": {
|
| 597 |
+
"full_uri": {"type": "string", "format": "uri"},
|
| 598 |
+
"ref_uri": {"type": "string", "format": "uri-reference"},
|
| 599 |
+
},
|
| 600 |
+
}
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
def test_datetime_valid(self, datetime_format):
|
| 604 |
+
validator = TypeAdapter(datetime_format)
|
| 605 |
+
result = validator.validate_python("2024-01-17T12:34:56Z")
|
| 606 |
+
assert isinstance(result, datetime)
|
| 607 |
+
|
| 608 |
+
def test_datetime_invalid(self, datetime_format):
|
| 609 |
+
validator = TypeAdapter(datetime_format)
|
| 610 |
+
with pytest.raises(ValidationError):
|
| 611 |
+
validator.validate_python("not-a-date")
|
| 612 |
+
|
| 613 |
+
def test_email_valid(self, email_format):
|
| 614 |
+
validator = TypeAdapter(email_format)
|
| 615 |
+
result = validator.validate_python("test@example.com")
|
| 616 |
+
assert isinstance(result, str)
|
| 617 |
+
|
| 618 |
+
def test_email_invalid(self, email_format):
|
| 619 |
+
validator = TypeAdapter(email_format)
|
| 620 |
+
with pytest.raises(ValidationError):
|
| 621 |
+
validator.validate_python("not-an-email")
|
| 622 |
+
|
| 623 |
+
def test_uri_valid(self, uri_format):
|
| 624 |
+
validator = TypeAdapter(uri_format)
|
| 625 |
+
result = validator.validate_python("https://example.com")
|
| 626 |
+
assert isinstance(result, AnyUrl)
|
| 627 |
+
|
| 628 |
+
def test_uri_invalid(self, uri_format):
|
| 629 |
+
validator = TypeAdapter(uri_format)
|
| 630 |
+
with pytest.raises(ValidationError):
|
| 631 |
+
validator.validate_python("not-a-uri")
|
| 632 |
+
|
| 633 |
+
def test_uri_reference_valid(self, uri_reference_format):
|
| 634 |
+
validator = TypeAdapter(uri_reference_format)
|
| 635 |
+
result = validator.validate_python("https://example.com")
|
| 636 |
+
assert isinstance(result, str)
|
| 637 |
+
|
| 638 |
+
def test_uri_reference_relative_valid(self, uri_reference_format):
|
| 639 |
+
validator = TypeAdapter(uri_reference_format)
|
| 640 |
+
result = validator.validate_python("/path/to/resource")
|
| 641 |
+
assert isinstance(result, str)
|
| 642 |
+
|
| 643 |
+
def test_uri_reference_invalid(self, uri_reference_format):
|
| 644 |
+
validator = TypeAdapter(uri_reference_format)
|
| 645 |
+
result = validator.validate_python("not a uri")
|
| 646 |
+
assert isinstance(result, str)
|
| 647 |
+
|
| 648 |
+
def test_json_valid(self, json_format):
|
| 649 |
+
validator = TypeAdapter(json_format)
|
| 650 |
+
result = validator.validate_python('{"key": "value"}')
|
| 651 |
+
assert isinstance(result, dict)
|
| 652 |
+
|
| 653 |
+
def test_json_invalid(self, json_format):
|
| 654 |
+
validator = TypeAdapter(json_format)
|
| 655 |
+
with pytest.raises(ValidationError):
|
| 656 |
+
validator.validate_python("{invalid json}")
|
| 657 |
+
|
| 658 |
+
def test_mixed_formats_object(self, mixed_formats_object):
|
| 659 |
+
validator = TypeAdapter(mixed_formats_object)
|
| 660 |
+
result = validator.validate_python(
|
| 661 |
+
{"full_uri": "https://example.com", "ref_uri": "/path/to/resource"}
|
| 662 |
+
)
|
| 663 |
+
assert isinstance(result.full_uri, AnyUrl)
|
| 664 |
+
assert isinstance(result.ref_uri, str)
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
class TestCircularReferences:
|
| 668 |
+
"""Test suite for circular reference handling."""
|
| 669 |
+
|
| 670 |
+
@pytest.fixture
|
| 671 |
+
def self_referential(self):
|
| 672 |
+
return json_schema_to_type(
|
| 673 |
+
{
|
| 674 |
+
"type": "object",
|
| 675 |
+
"properties": {"name": {"type": "string"}, "child": {"$ref": "#"}},
|
| 676 |
+
}
|
| 677 |
+
)
|
| 678 |
+
|
| 679 |
+
@pytest.fixture
|
| 680 |
+
def mutually_recursive(self):
|
| 681 |
+
return json_schema_to_type(
|
| 682 |
+
{
|
| 683 |
+
"type": "object",
|
| 684 |
+
"definitions": {
|
| 685 |
+
"Person": {
|
| 686 |
+
"type": "object",
|
| 687 |
+
"properties": {
|
| 688 |
+
"name": {"type": "string"},
|
| 689 |
+
"friend": {"$ref": "#/definitions/Pet"},
|
| 690 |
+
},
|
| 691 |
+
},
|
| 692 |
+
"Pet": {
|
| 693 |
+
"type": "object",
|
| 694 |
+
"properties": {
|
| 695 |
+
"name": {"type": "string"},
|
| 696 |
+
"owner": {"$ref": "#/definitions/Person"},
|
| 697 |
+
},
|
| 698 |
+
},
|
| 699 |
+
},
|
| 700 |
+
"properties": {"person": {"$ref": "#/definitions/Person"}},
|
| 701 |
+
}
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
def test_self_ref_single_level(self, self_referential):
|
| 705 |
+
validator = TypeAdapter(self_referential)
|
| 706 |
+
result = validator.validate_python(
|
| 707 |
+
{"name": "parent", "child": {"name": "child"}}
|
| 708 |
+
)
|
| 709 |
+
assert result.name == "parent"
|
| 710 |
+
assert result.child.name == "child"
|
| 711 |
+
assert result.child.child is None
|
| 712 |
+
|
| 713 |
+
def test_self_ref_multiple_levels(self, self_referential):
|
| 714 |
+
validator = TypeAdapter(self_referential)
|
| 715 |
+
result = validator.validate_python(
|
| 716 |
+
{
|
| 717 |
+
"name": "grandparent",
|
| 718 |
+
"child": {"name": "parent", "child": {"name": "child"}},
|
| 719 |
+
}
|
| 720 |
+
)
|
| 721 |
+
assert result.name == "grandparent"
|
| 722 |
+
assert result.child.name == "parent"
|
| 723 |
+
assert result.child.child.name == "child"
|
| 724 |
+
|
| 725 |
+
def test_mutual_recursion_single_level(self, mutually_recursive):
|
| 726 |
+
validator = TypeAdapter(mutually_recursive)
|
| 727 |
+
result = validator.validate_python(
|
| 728 |
+
{"person": {"name": "Alice", "friend": {"name": "Spot"}}}
|
| 729 |
+
)
|
| 730 |
+
assert result.person.name == "Alice"
|
| 731 |
+
assert result.person.friend.name == "Spot"
|
| 732 |
+
assert result.person.friend.owner is None
|
| 733 |
+
|
| 734 |
+
def test_mutual_recursion_multiple_levels(self, mutually_recursive):
|
| 735 |
+
validator = TypeAdapter(mutually_recursive)
|
| 736 |
+
result = validator.validate_python(
|
| 737 |
+
{
|
| 738 |
+
"person": {
|
| 739 |
+
"name": "Alice",
|
| 740 |
+
"friend": {"name": "Spot", "owner": {"name": "Bob"}},
|
| 741 |
+
}
|
| 742 |
+
}
|
| 743 |
+
)
|
| 744 |
+
assert result.person.name == "Alice"
|
| 745 |
+
assert result.person.friend.name == "Spot"
|
| 746 |
+
assert result.person.friend.owner.name == "Bob"
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
class TestIdentifierNormalization:
|
| 750 |
+
"""Test suite for handling non-standard property names."""
|
| 751 |
+
|
| 752 |
+
@pytest.fixture
|
| 753 |
+
def special_chars(self):
|
| 754 |
+
return json_schema_to_type(
|
| 755 |
+
{
|
| 756 |
+
"type": "object",
|
| 757 |
+
"properties": {
|
| 758 |
+
"@type": {"type": "string"},
|
| 759 |
+
"first-name": {"type": "string"},
|
| 760 |
+
"last.name": {"type": "string"},
|
| 761 |
+
"2nd_address": {"type": "string"},
|
| 762 |
+
"$ref": {"type": "string"},
|
| 763 |
+
},
|
| 764 |
+
}
|
| 765 |
+
)
|
| 766 |
+
|
| 767 |
+
def test_normalizes_special_chars(self, special_chars):
|
| 768 |
+
validator = TypeAdapter(special_chars)
|
| 769 |
+
result = validator.validate_python(
|
| 770 |
+
{
|
| 771 |
+
"@type": "person",
|
| 772 |
+
"first-name": "Alice",
|
| 773 |
+
"last.name": "Smith",
|
| 774 |
+
"2nd_address": "456 Oak St",
|
| 775 |
+
"$ref": "12345",
|
| 776 |
+
}
|
| 777 |
+
)
|
| 778 |
+
assert result.field_type == "person" # @type -> field_type
|
| 779 |
+
assert result.first_name == "Alice" # first-name -> first_name
|
| 780 |
+
assert result.last_name == "Smith" # last.name -> last_name
|
| 781 |
+
assert (
|
| 782 |
+
result.field_2nd_address == "456 Oak St"
|
| 783 |
+
) # 2nd_address -> field_2nd_address
|
| 784 |
+
assert result.field_ref == "12345" # $ref -> field_ref
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
class TestConstantValues:
|
| 788 |
+
"""Test suite for constant value validation."""
|
| 789 |
+
|
| 790 |
+
@pytest.fixture
|
| 791 |
+
def string_const(self):
|
| 792 |
+
return json_schema_to_type({"type": "string", "const": "production"})
|
| 793 |
+
|
| 794 |
+
@pytest.fixture
|
| 795 |
+
def number_const(self):
|
| 796 |
+
return json_schema_to_type({"type": "number", "const": 42.5})
|
| 797 |
+
|
| 798 |
+
@pytest.fixture
|
| 799 |
+
def boolean_const(self):
|
| 800 |
+
return json_schema_to_type({"type": "boolean", "const": True})
|
| 801 |
+
|
| 802 |
+
@pytest.fixture
|
| 803 |
+
def null_const(self):
|
| 804 |
+
return json_schema_to_type({"type": "null", "const": None})
|
| 805 |
+
|
| 806 |
+
@pytest.fixture
|
| 807 |
+
def object_with_consts(self):
|
| 808 |
+
return json_schema_to_type(
|
| 809 |
+
{
|
| 810 |
+
"type": "object",
|
| 811 |
+
"properties": {
|
| 812 |
+
"env": {"const": "production"},
|
| 813 |
+
"version": {"const": 1},
|
| 814 |
+
"enabled": {"const": True},
|
| 815 |
+
},
|
| 816 |
+
}
|
| 817 |
+
)
|
| 818 |
+
|
| 819 |
+
def test_string_const_valid(self, string_const):
|
| 820 |
+
validator = TypeAdapter(string_const)
|
| 821 |
+
assert validator.validate_python("production") == "production"
|
| 822 |
+
|
| 823 |
+
def test_string_const_invalid(self, string_const):
|
| 824 |
+
validator = TypeAdapter(string_const)
|
| 825 |
+
with pytest.raises(ValidationError):
|
| 826 |
+
validator.validate_python("development")
|
| 827 |
+
|
| 828 |
+
def test_number_const_valid(self, number_const):
|
| 829 |
+
validator = TypeAdapter(number_const)
|
| 830 |
+
assert validator.validate_python(42.5) == 42.5
|
| 831 |
+
|
| 832 |
+
def test_number_const_invalid(self, number_const):
|
| 833 |
+
validator = TypeAdapter(number_const)
|
| 834 |
+
with pytest.raises(ValidationError):
|
| 835 |
+
validator.validate_python(42)
|
| 836 |
+
|
| 837 |
+
def test_boolean_const_valid(self, boolean_const):
|
| 838 |
+
validator = TypeAdapter(boolean_const)
|
| 839 |
+
assert validator.validate_python(True) is True
|
| 840 |
+
|
| 841 |
+
def test_boolean_const_invalid(self, boolean_const):
|
| 842 |
+
validator = TypeAdapter(boolean_const)
|
| 843 |
+
with pytest.raises(ValidationError):
|
| 844 |
+
validator.validate_python(False)
|
| 845 |
+
|
| 846 |
+
def test_null_const_valid(self, null_const):
|
| 847 |
+
validator = TypeAdapter(null_const)
|
| 848 |
+
assert validator.validate_python(None) is None
|
| 849 |
+
|
| 850 |
+
def test_null_const_invalid(self, null_const):
|
| 851 |
+
validator = TypeAdapter(null_const)
|
| 852 |
+
with pytest.raises(ValidationError):
|
| 853 |
+
validator.validate_python(False)
|
| 854 |
+
|
| 855 |
+
def test_object_consts_valid(self, object_with_consts):
|
| 856 |
+
validator = TypeAdapter(object_with_consts)
|
| 857 |
+
result = validator.validate_python(
|
| 858 |
+
{"env": "production", "version": 1, "enabled": True}
|
| 859 |
+
)
|
| 860 |
+
assert result.env == "production"
|
| 861 |
+
assert result.version == 1
|
| 862 |
+
assert result.enabled is True
|
| 863 |
+
|
| 864 |
+
def test_object_consts_invalid(self, object_with_consts):
|
| 865 |
+
validator = TypeAdapter(object_with_consts)
|
| 866 |
+
with pytest.raises(ValidationError):
|
| 867 |
+
validator.validate_python(
|
| 868 |
+
{
|
| 869 |
+
"env": "production",
|
| 870 |
+
"version": 2, # Wrong constant
|
| 871 |
+
"enabled": True,
|
| 872 |
+
}
|
| 873 |
+
)
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
class TestSchemaCaching:
|
| 877 |
+
"""Test suite for schema caching behavior."""
|
| 878 |
+
|
| 879 |
+
def test_identical_schemas_reuse_class(self):
|
| 880 |
+
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 881 |
+
|
| 882 |
+
class1 = json_schema_to_type(schema)
|
| 883 |
+
class2 = json_schema_to_type(schema)
|
| 884 |
+
assert class1 is class2
|
| 885 |
+
|
| 886 |
+
def test_different_names_different_classes(self):
|
| 887 |
+
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 888 |
+
|
| 889 |
+
class1 = json_schema_to_type(schema, name="Class1")
|
| 890 |
+
class2 = json_schema_to_type(schema, name="Class2")
|
| 891 |
+
assert class1 is not class2
|
| 892 |
+
assert class1.__name__ == "Class1"
|
| 893 |
+
assert class2.__name__ == "Class2"
|
| 894 |
+
|
| 895 |
+
def test_nested_schema_caching(self):
|
| 896 |
+
schema = {
|
| 897 |
+
"type": "object",
|
| 898 |
+
"properties": {
|
| 899 |
+
"nested": {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 900 |
+
},
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
class1 = json_schema_to_type(schema)
|
| 904 |
+
class2 = json_schema_to_type(schema)
|
| 905 |
+
|
| 906 |
+
# Both main classes and their nested classes should be identical
|
| 907 |
+
assert class1 is class2
|
| 908 |
+
assert (
|
| 909 |
+
class1.__dataclass_fields__["nested"].type
|
| 910 |
+
is class2.__dataclass_fields__["nested"].type
|
| 911 |
+
)
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
class TestSchemaHashing:
|
| 915 |
+
"""Test suite for schema hashing utility."""
|
| 916 |
+
|
| 917 |
+
def test_deterministic_hash(self):
|
| 918 |
+
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 919 |
+
hash1 = _hash_schema(schema)
|
| 920 |
+
hash2 = _hash_schema(schema)
|
| 921 |
+
assert hash1 == hash2
|
| 922 |
+
assert isinstance(hash1, str)
|
| 923 |
+
assert len(hash1) == 64 # SHA-256 hash length
|
| 924 |
+
|
| 925 |
+
def test_different_schemas_different_hashes(self):
|
| 926 |
+
schema1 = {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 927 |
+
schema2 = {"type": "object", "properties": {"age": {"type": "integer"}}}
|
| 928 |
+
assert _hash_schema(schema1) != _hash_schema(schema2)
|
| 929 |
+
|
| 930 |
+
def test_order_independent_hash(self):
|
| 931 |
+
schema1 = {"properties": {"name": {"type": "string"}}, "type": "object"}
|
| 932 |
+
schema2 = {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 933 |
+
assert _hash_schema(schema1) == _hash_schema(schema2)
|
| 934 |
+
|
| 935 |
+
def test_nested_schema_hash(self):
|
| 936 |
+
schema = {
|
| 937 |
+
"type": "object",
|
| 938 |
+
"properties": {
|
| 939 |
+
"nested": {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 940 |
+
},
|
| 941 |
+
}
|
| 942 |
+
hash1 = _hash_schema(schema)
|
| 943 |
+
assert isinstance(hash1, str)
|
| 944 |
+
assert len(hash1) == 64
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
class TestDefaultMerging:
|
| 948 |
+
"""Test suite for default value merging behavior."""
|
| 949 |
+
|
| 950 |
+
def test_simple_merge(self):
|
| 951 |
+
defaults = {"name": "anonymous", "age": 0}
|
| 952 |
+
data = {"name": "test"}
|
| 953 |
+
result = _merge_defaults(data, {"properties": {}}, defaults)
|
| 954 |
+
assert result["name"] == "test"
|
| 955 |
+
assert result["age"] == 0
|
| 956 |
+
|
| 957 |
+
def test_nested_merge(self):
|
| 958 |
+
defaults = {"user": {"name": "anonymous", "settings": {"theme": "light"}}}
|
| 959 |
+
data = {"user": {"name": "test"}}
|
| 960 |
+
result = _merge_defaults(data, {"properties": {}}, defaults)
|
| 961 |
+
assert result["user"]["name"] == "test"
|
| 962 |
+
assert result["user"]["settings"]["theme"] == "light"
|
| 963 |
+
|
| 964 |
+
def test_array_merge(self):
|
| 965 |
+
defaults = {
|
| 966 |
+
"items": [
|
| 967 |
+
{"name": "item1", "done": False},
|
| 968 |
+
{"name": "item2", "done": False},
|
| 969 |
+
]
|
| 970 |
+
}
|
| 971 |
+
data = {"items": [{"name": "custom", "done": True}]}
|
| 972 |
+
result = _merge_defaults(data, {"properties": {}}, defaults)
|
| 973 |
+
assert len(result["items"]) == 1
|
| 974 |
+
assert result["items"][0]["name"] == "custom"
|
| 975 |
+
assert result["items"][0]["done"] is True
|
| 976 |
+
|
| 977 |
+
def test_empty_data_uses_defaults(self):
|
| 978 |
+
schema = {
|
| 979 |
+
"properties": {
|
| 980 |
+
"user": {
|
| 981 |
+
"type": "object",
|
| 982 |
+
"properties": {
|
| 983 |
+
"name": {"type": "string", "default": "anonymous"},
|
| 984 |
+
"settings": {"type": "object", "default": {"theme": "light"}},
|
| 985 |
+
},
|
| 986 |
+
"default": {"name": "guest", "settings": {"theme": "dark"}},
|
| 987 |
+
}
|
| 988 |
+
}
|
| 989 |
+
}
|
| 990 |
+
result = _merge_defaults({}, schema)
|
| 991 |
+
assert result["user"]["name"] == "guest"
|
| 992 |
+
assert result["user"]["settings"]["theme"] == "dark"
|
| 993 |
+
|
| 994 |
+
def test_property_level_defaults(self):
|
| 995 |
+
schema = {
|
| 996 |
+
"properties": {
|
| 997 |
+
"name": {"type": "string", "default": "anonymous"},
|
| 998 |
+
"age": {"type": "integer", "default": 0},
|
| 999 |
+
}
|
| 1000 |
+
}
|
| 1001 |
+
result = _merge_defaults({}, schema)
|
| 1002 |
+
assert result["name"] == "anonymous"
|
| 1003 |
+
assert result["age"] == 0
|
| 1004 |
+
|
| 1005 |
+
def test_nested_property_defaults(self):
|
| 1006 |
+
schema = {
|
| 1007 |
+
"properties": {
|
| 1008 |
+
"user": {
|
| 1009 |
+
"type": "object",
|
| 1010 |
+
"properties": {
|
| 1011 |
+
"name": {"type": "string", "default": "anonymous"},
|
| 1012 |
+
"settings": {
|
| 1013 |
+
"type": "object",
|
| 1014 |
+
"properties": {
|
| 1015 |
+
"theme": {"type": "string", "default": "light"}
|
| 1016 |
+
},
|
| 1017 |
+
},
|
| 1018 |
+
},
|
| 1019 |
+
}
|
| 1020 |
+
}
|
| 1021 |
+
}
|
| 1022 |
+
result = _merge_defaults({"user": {"settings": {}}}, schema)
|
| 1023 |
+
assert result["user"]["name"] == "anonymous"
|
| 1024 |
+
assert result["user"]["settings"]["theme"] == "light"
|
| 1025 |
+
|
| 1026 |
+
def test_default_priority(self):
|
| 1027 |
+
schema = {
|
| 1028 |
+
"properties": {
|
| 1029 |
+
"settings": {
|
| 1030 |
+
"type": "object",
|
| 1031 |
+
"properties": {"theme": {"type": "string", "default": "light"}},
|
| 1032 |
+
"default": {"theme": "dark"},
|
| 1033 |
+
}
|
| 1034 |
+
},
|
| 1035 |
+
"default": {"settings": {"theme": "system"}},
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
# Test priority: data > parent default > object default > property default
|
| 1039 |
+
result1 = _merge_defaults({}, schema) # Uses schema default
|
| 1040 |
+
assert result1["settings"]["theme"] == "system"
|
| 1041 |
+
|
| 1042 |
+
result2 = _merge_defaults({"settings": {}}, schema) # Uses object default
|
| 1043 |
+
assert result2["settings"]["theme"] == "dark"
|
| 1044 |
+
|
| 1045 |
+
result3 = _merge_defaults(
|
| 1046 |
+
{"settings": {"theme": "custom"}}, schema
|
| 1047 |
+
) # Uses provided data
|
| 1048 |
+
assert result3["settings"]["theme"] == "custom"
|
| 1049 |
+
|
| 1050 |
+
|
| 1051 |
+
class TestEdgeCases:
|
| 1052 |
+
"""Test suite for edge cases and corner scenarios."""
|
| 1053 |
+
|
| 1054 |
+
def test_empty_schema(self):
|
| 1055 |
+
schema = {}
|
| 1056 |
+
result = json_schema_to_type(schema)
|
| 1057 |
+
assert result is object
|
| 1058 |
+
|
| 1059 |
+
def test_schema_without_type(self):
|
| 1060 |
+
schema = {"properties": {"name": {"type": "string"}}}
|
| 1061 |
+
Type = json_schema_to_type(schema)
|
| 1062 |
+
validator = TypeAdapter(Type)
|
| 1063 |
+
result = validator.validate_python({"name": "test"})
|
| 1064 |
+
assert result.name == "test"
|
| 1065 |
+
|
| 1066 |
+
def test_recursive_defaults(self):
|
| 1067 |
+
schema = {
|
| 1068 |
+
"type": "object",
|
| 1069 |
+
"properties": {
|
| 1070 |
+
"node": {
|
| 1071 |
+
"type": "object",
|
| 1072 |
+
"properties": {"value": {"type": "string"}, "next": {"$ref": "#"}},
|
| 1073 |
+
"default": {"value": "default", "next": None},
|
| 1074 |
+
}
|
| 1075 |
+
},
|
| 1076 |
+
}
|
| 1077 |
+
Type = json_schema_to_type(schema)
|
| 1078 |
+
validator = TypeAdapter(Type)
|
| 1079 |
+
result = validator.validate_python({})
|
| 1080 |
+
assert result.node.value == "default"
|
| 1081 |
+
assert result.node.next is None
|
| 1082 |
+
|
| 1083 |
+
def test_mixed_type_array(self):
|
| 1084 |
+
schema = {
|
| 1085 |
+
"type": "array",
|
| 1086 |
+
"items": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}],
|
| 1087 |
+
}
|
| 1088 |
+
Type = json_schema_to_type(schema)
|
| 1089 |
+
validator = TypeAdapter(Type)
|
| 1090 |
+
result = validator.validate_python(["test", 123, True])
|
| 1091 |
+
assert result == ["test", 123, True]
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
class TestNameHandling:
|
| 1095 |
+
"""Test suite for schema name handling."""
|
| 1096 |
+
|
| 1097 |
+
def test_name_from_title(self):
|
| 1098 |
+
schema = {
|
| 1099 |
+
"type": "object",
|
| 1100 |
+
"title": "Person",
|
| 1101 |
+
"properties": {"name": {"type": "string"}},
|
| 1102 |
+
}
|
| 1103 |
+
Type = json_schema_to_type(schema)
|
| 1104 |
+
assert Type.__name__ == "Person"
|
| 1105 |
+
|
| 1106 |
+
def test_explicit_name_overrides_title(self):
|
| 1107 |
+
schema = {
|
| 1108 |
+
"type": "object",
|
| 1109 |
+
"title": "Person",
|
| 1110 |
+
"properties": {"name": {"type": "string"}},
|
| 1111 |
+
}
|
| 1112 |
+
Type = json_schema_to_type(schema, name="CustomPerson")
|
| 1113 |
+
assert Type.__name__ == "CustomPerson"
|
| 1114 |
+
|
| 1115 |
+
def test_default_name_without_title(self):
|
| 1116 |
+
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
| 1117 |
+
Type = json_schema_to_type(schema)
|
| 1118 |
+
assert Type.__name__ == "Root"
|
| 1119 |
+
|
| 1120 |
+
def test_name_only_allowed_for_objects(self):
|
| 1121 |
+
schema = {"type": "string"}
|
| 1122 |
+
with pytest.raises(ValueError, match="Can not apply name to non-object schema"):
|
| 1123 |
+
json_schema_to_type(schema, name="StringType")
|
| 1124 |
+
|
| 1125 |
+
def test_nested_object_names(self):
|
| 1126 |
+
schema = {
|
| 1127 |
+
"type": "object",
|
| 1128 |
+
"title": "Parent",
|
| 1129 |
+
"properties": {
|
| 1130 |
+
"child": {
|
| 1131 |
+
"type": "object",
|
| 1132 |
+
"title": "Child",
|
| 1133 |
+
"properties": {"name": {"type": "string"}},
|
| 1134 |
+
}
|
| 1135 |
+
},
|
| 1136 |
+
}
|
| 1137 |
+
Type = json_schema_to_type(schema)
|
| 1138 |
+
assert Type.__name__ == "Parent"
|
| 1139 |
+
assert Type.__dataclass_fields__["child"].type.__origin__ is Union
|
| 1140 |
+
assert Type.__dataclass_fields__["child"].type.__args__[0].__name__ == "Child"
|
| 1141 |
+
assert Type.__dataclass_fields__["child"].type.__args__[1] is type(None)
|
| 1142 |
+
|
| 1143 |
+
def test_recursive_schema_naming(self):
|
| 1144 |
+
schema = {
|
| 1145 |
+
"type": "object",
|
| 1146 |
+
"title": "Node",
|
| 1147 |
+
"properties": {"next": {"$ref": "#"}},
|
| 1148 |
+
}
|
| 1149 |
+
Type = json_schema_to_type(schema)
|
| 1150 |
+
assert Type.__name__ == "Node"
|
| 1151 |
+
assert Type.__dataclass_fields__["next"].type.__origin__ is Union
|
| 1152 |
+
assert (
|
| 1153 |
+
Type.__dataclass_fields__["next"].type.__args__[0].__forward_arg__ == "Node"
|
| 1154 |
+
)
|
| 1155 |
+
assert Type.__dataclass_fields__["next"].type.__args__[1] is type(None)
|
| 1156 |
+
|
| 1157 |
+
def test_name_caching_with_different_titles(self):
|
| 1158 |
+
"""Ensure schemas with different titles create different cached classes"""
|
| 1159 |
+
schema1 = {
|
| 1160 |
+
"type": "object",
|
| 1161 |
+
"title": "Type1",
|
| 1162 |
+
"properties": {"name": {"type": "string"}},
|
| 1163 |
+
}
|
| 1164 |
+
schema2 = {
|
| 1165 |
+
"type": "object",
|
| 1166 |
+
"title": "Type2",
|
| 1167 |
+
"properties": {"name": {"type": "string"}},
|
| 1168 |
+
}
|
| 1169 |
+
Type1 = json_schema_to_type(schema1)
|
| 1170 |
+
Type2 = json_schema_to_type(schema2)
|
| 1171 |
+
assert Type1 is not Type2
|
| 1172 |
+
assert Type1.__name__ == "Type1"
|
| 1173 |
+
assert Type2.__name__ == "Type2"
|
| 1174 |
+
|
| 1175 |
+
def test_recursive_schema_with_invalid_python_name(self):
|
| 1176 |
+
"""Test that recursive schemas work with titles that aren't valid Python identifiers"""
|
| 1177 |
+
schema = {
|
| 1178 |
+
"type": "object",
|
| 1179 |
+
"title": "My Complex Type!",
|
| 1180 |
+
"properties": {"name": {"type": "string"}, "child": {"$ref": "#"}},
|
| 1181 |
+
}
|
| 1182 |
+
Type = json_schema_to_type(schema)
|
| 1183 |
+
# The class should get a sanitized name
|
| 1184 |
+
assert Type.__name__ == "My_Complex_Type"
|
| 1185 |
+
# Create an instance to verify the recursive reference works
|
| 1186 |
+
validator = TypeAdapter(Type)
|
| 1187 |
+
result = validator.validate_python(
|
| 1188 |
+
{"name": "parent", "child": {"name": "child", "child": None}}
|
| 1189 |
+
)
|
| 1190 |
+
assert result.name == "parent"
|
| 1191 |
+
assert result.child.name == "child"
|
| 1192 |
+
assert result.child.child is None
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
class TestAdditionalProperties:
|
| 1196 |
+
"""Test suite for additionalProperties handling."""
|
| 1197 |
+
|
| 1198 |
+
@pytest.fixture
|
| 1199 |
+
def dict_only_schema(self):
|
| 1200 |
+
"""Schema with no properties but additionalProperties=True -> dict[str, Any]"""
|
| 1201 |
+
return json_schema_to_type({"type": "object", "additionalProperties": True})
|
| 1202 |
+
|
| 1203 |
+
@pytest.fixture
|
| 1204 |
+
def properties_with_additional(self):
|
| 1205 |
+
"""Schema with properties AND additionalProperties=True -> BaseModel"""
|
| 1206 |
+
return json_schema_to_type(
|
| 1207 |
+
{
|
| 1208 |
+
"type": "object",
|
| 1209 |
+
"properties": {
|
| 1210 |
+
"name": {"type": "string"},
|
| 1211 |
+
"age": {"type": "integer"},
|
| 1212 |
+
},
|
| 1213 |
+
"additionalProperties": True,
|
| 1214 |
+
}
|
| 1215 |
+
)
|
| 1216 |
+
|
| 1217 |
+
@pytest.fixture
|
| 1218 |
+
def properties_without_additional(self):
|
| 1219 |
+
"""Schema with properties but no additionalProperties -> dataclass"""
|
| 1220 |
+
return json_schema_to_type(
|
| 1221 |
+
{
|
| 1222 |
+
"type": "object",
|
| 1223 |
+
"properties": {
|
| 1224 |
+
"name": {"type": "string"},
|
| 1225 |
+
"age": {"type": "integer"},
|
| 1226 |
+
},
|
| 1227 |
+
}
|
| 1228 |
+
)
|
| 1229 |
+
|
| 1230 |
+
@pytest.fixture
|
| 1231 |
+
def required_properties_with_additional(self):
|
| 1232 |
+
"""Schema with required properties AND additionalProperties=True -> BaseModel"""
|
| 1233 |
+
return json_schema_to_type(
|
| 1234 |
+
{
|
| 1235 |
+
"type": "object",
|
| 1236 |
+
"properties": {
|
| 1237 |
+
"name": {"type": "string"},
|
| 1238 |
+
"age": {"type": "integer"},
|
| 1239 |
+
},
|
| 1240 |
+
"required": ["name"],
|
| 1241 |
+
"additionalProperties": True,
|
| 1242 |
+
}
|
| 1243 |
+
)
|
| 1244 |
+
|
| 1245 |
+
def test_dict_only_returns_dict_type(self, dict_only_schema):
|
| 1246 |
+
"""Test that schema with no properties + additionalProperties=True returns dict[str, Any]"""
|
| 1247 |
+
import typing
|
| 1248 |
+
|
| 1249 |
+
assert dict_only_schema == dict[str, typing.Any]
|
| 1250 |
+
|
| 1251 |
+
def test_dict_only_accepts_any_data(self, dict_only_schema):
|
| 1252 |
+
"""Test that pure dict accepts arbitrary key-value pairs"""
|
| 1253 |
+
validator = TypeAdapter(dict_only_schema)
|
| 1254 |
+
data = {"anything": "works", "numbers": 123, "nested": {"key": "value"}}
|
| 1255 |
+
result = validator.validate_python(data)
|
| 1256 |
+
assert result == data
|
| 1257 |
+
assert isinstance(result, dict)
|
| 1258 |
+
|
| 1259 |
+
def test_properties_with_additional_returns_basemodel(
|
| 1260 |
+
self, properties_with_additional
|
| 1261 |
+
):
|
| 1262 |
+
"""Test that schema with properties + additionalProperties=True returns BaseModel"""
|
| 1263 |
+
assert issubclass(properties_with_additional, BaseModel)
|
| 1264 |
+
|
| 1265 |
+
def test_properties_with_additional_accepts_extra_fields(
|
| 1266 |
+
self, properties_with_additional
|
| 1267 |
+
):
|
| 1268 |
+
"""Test that BaseModel with extra='allow' accepts additional properties"""
|
| 1269 |
+
validator = TypeAdapter(properties_with_additional)
|
| 1270 |
+
data = {
|
| 1271 |
+
"name": "Alice",
|
| 1272 |
+
"age": 30,
|
| 1273 |
+
"extra": "field",
|
| 1274 |
+
"another": {"nested": "data"},
|
| 1275 |
+
}
|
| 1276 |
+
result = validator.validate_python(data)
|
| 1277 |
+
|
| 1278 |
+
# Check standard properties
|
| 1279 |
+
assert result.name == "Alice"
|
| 1280 |
+
assert result.age == 30
|
| 1281 |
+
|
| 1282 |
+
# Check extra properties are preserved with dot access
|
| 1283 |
+
assert hasattr(result, "extra")
|
| 1284 |
+
assert result.extra == "field"
|
| 1285 |
+
assert hasattr(result, "another")
|
| 1286 |
+
assert result.another == {"nested": "data"}
|
| 1287 |
+
|
| 1288 |
+
def test_properties_with_additional_validates_known_fields(
|
| 1289 |
+
self, properties_with_additional
|
| 1290 |
+
):
|
| 1291 |
+
"""Test that BaseModel still validates known fields"""
|
| 1292 |
+
validator = TypeAdapter(properties_with_additional)
|
| 1293 |
+
|
| 1294 |
+
# Should accept valid data
|
| 1295 |
+
result = validator.validate_python({"name": "Alice", "age": 30, "extra": "ok"})
|
| 1296 |
+
assert result.name == "Alice"
|
| 1297 |
+
assert result.age == 30
|
| 1298 |
+
assert result.extra == "ok"
|
| 1299 |
+
|
| 1300 |
+
# Should reject invalid types for known fields
|
| 1301 |
+
with pytest.raises(ValidationError):
|
| 1302 |
+
validator.validate_python({"name": "Alice", "age": "not_a_number"})
|
| 1303 |
+
|
| 1304 |
+
def test_properties_without_additional_is_dataclass(
|
| 1305 |
+
self, properties_without_additional
|
| 1306 |
+
):
|
| 1307 |
+
"""Test that schema with properties but no additionalProperties returns dataclass"""
|
| 1308 |
+
assert not issubclass(properties_without_additional, BaseModel)
|
| 1309 |
+
assert hasattr(properties_without_additional, "__dataclass_fields__")
|
| 1310 |
+
|
| 1311 |
+
def test_properties_without_additional_ignores_extra_fields(
|
| 1312 |
+
self, properties_without_additional
|
| 1313 |
+
):
|
| 1314 |
+
"""Test that dataclass ignores extra properties (current behavior)"""
|
| 1315 |
+
validator = TypeAdapter(properties_without_additional)
|
| 1316 |
+
data = {"name": "Alice", "age": 30, "extra": "ignored"}
|
| 1317 |
+
result = validator.validate_python(data)
|
| 1318 |
+
|
| 1319 |
+
# Check standard properties
|
| 1320 |
+
assert result.name == "Alice"
|
| 1321 |
+
assert result.age == 30
|
| 1322 |
+
|
| 1323 |
+
# Check extra property is ignored
|
| 1324 |
+
assert not hasattr(result, "extra")
|
| 1325 |
+
|
| 1326 |
+
def test_required_properties_with_additional(
|
| 1327 |
+
self, required_properties_with_additional
|
| 1328 |
+
):
|
| 1329 |
+
"""Test BaseModel with required fields and additional properties"""
|
| 1330 |
+
validator = TypeAdapter(required_properties_with_additional)
|
| 1331 |
+
|
| 1332 |
+
# Should accept valid data with required field
|
| 1333 |
+
result = validator.validate_python({"name": "Alice", "extra": "field"})
|
| 1334 |
+
assert result.name == "Alice"
|
| 1335 |
+
assert result.age is None # Optional field
|
| 1336 |
+
assert result.extra == "field"
|
| 1337 |
+
|
| 1338 |
+
# Should reject missing required field
|
| 1339 |
+
with pytest.raises(ValidationError):
|
| 1340 |
+
validator.validate_python({"age": 30, "extra": "field"})
|
| 1341 |
+
|
| 1342 |
+
def test_nested_additional_properties(self):
|
| 1343 |
+
"""Test nested objects with additionalProperties"""
|
| 1344 |
+
schema = {
|
| 1345 |
+
"type": "object",
|
| 1346 |
+
"properties": {
|
| 1347 |
+
"user": {
|
| 1348 |
+
"type": "object",
|
| 1349 |
+
"properties": {"name": {"type": "string"}},
|
| 1350 |
+
"additionalProperties": True,
|
| 1351 |
+
},
|
| 1352 |
+
"settings": {
|
| 1353 |
+
"type": "object",
|
| 1354 |
+
"properties": {"theme": {"type": "string"}},
|
| 1355 |
+
},
|
| 1356 |
+
},
|
| 1357 |
+
"additionalProperties": True,
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
Type = json_schema_to_type(schema)
|
| 1361 |
+
validator = TypeAdapter(Type)
|
| 1362 |
+
|
| 1363 |
+
data = {
|
| 1364 |
+
"user": {"name": "Alice", "extra_user_field": "value"},
|
| 1365 |
+
"settings": {"theme": "dark", "extra_settings_field": "ignored"},
|
| 1366 |
+
"top_level_extra": "preserved",
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
result = validator.validate_python(data)
|
| 1370 |
+
|
| 1371 |
+
# Check top-level extra field (BaseModel)
|
| 1372 |
+
assert result.top_level_extra == "preserved"
|
| 1373 |
+
|
| 1374 |
+
# Check nested user extra field (BaseModel)
|
| 1375 |
+
assert result.user.name == "Alice"
|
| 1376 |
+
assert result.user.extra_user_field == "value"
|
| 1377 |
+
|
| 1378 |
+
# Check nested settings - should be dataclass
|
| 1379 |
+
assert result.settings.theme == "dark"
|
| 1380 |
+
# Note: When nested in BaseModel with extra='allow', Pydantic may preserve extra fields
|
| 1381 |
+
# even on dataclass children. The important thing is that settings is still a dataclass.
|
| 1382 |
+
assert not issubclass(type(result.settings), BaseModel)
|
| 1383 |
+
|
| 1384 |
+
def test_additional_properties_false_vs_missing(self):
|
| 1385 |
+
"""Test difference between additionalProperties: false and missing additionalProperties"""
|
| 1386 |
+
# Schema with explicit additionalProperties: false
|
| 1387 |
+
schema_false = {
|
| 1388 |
+
"type": "object",
|
| 1389 |
+
"properties": {"name": {"type": "string"}},
|
| 1390 |
+
"additionalProperties": False,
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
# Schema with no additionalProperties key
|
| 1394 |
+
schema_missing = {
|
| 1395 |
+
"type": "object",
|
| 1396 |
+
"properties": {"name": {"type": "string"}},
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
Type_false = json_schema_to_type(schema_false)
|
| 1400 |
+
Type_missing = json_schema_to_type(schema_missing)
|
| 1401 |
+
|
| 1402 |
+
# Both should create dataclasses (not BaseModel)
|
| 1403 |
+
assert not issubclass(Type_false, BaseModel)
|
| 1404 |
+
assert not issubclass(Type_missing, BaseModel)
|
| 1405 |
+
assert hasattr(Type_false, "__dataclass_fields__")
|
| 1406 |
+
assert hasattr(Type_missing, "__dataclass_fields__")
|
| 1407 |
+
|
| 1408 |
+
def test_additional_properties_with_defaults(self):
|
| 1409 |
+
"""Test additionalProperties with default values"""
|
| 1410 |
+
schema = {
|
| 1411 |
+
"type": "object",
|
| 1412 |
+
"properties": {
|
| 1413 |
+
"name": {"type": "string", "default": "anonymous"},
|
| 1414 |
+
"age": {"type": "integer", "default": 0},
|
| 1415 |
+
},
|
| 1416 |
+
"additionalProperties": True,
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
Type = json_schema_to_type(schema)
|
| 1420 |
+
validator = TypeAdapter(Type)
|
| 1421 |
+
|
| 1422 |
+
# Test with extra fields and defaults
|
| 1423 |
+
result = validator.validate_python({"extra": "field"})
|
| 1424 |
+
assert result.name == "anonymous"
|
| 1425 |
+
assert result.age == 0
|
| 1426 |
+
assert result.extra == "field"
|
| 1427 |
+
|
| 1428 |
+
def test_additional_properties_type_consistency(self):
|
| 1429 |
+
"""Test that the same schema always returns the same type"""
|
| 1430 |
+
schema = {
|
| 1431 |
+
"type": "object",
|
| 1432 |
+
"properties": {"name": {"type": "string"}},
|
| 1433 |
+
"additionalProperties": True,
|
| 1434 |
+
}
|
| 1435 |
+
|
| 1436 |
+
Type1 = json_schema_to_type(schema)
|
| 1437 |
+
Type2 = json_schema_to_type(schema)
|
| 1438 |
+
|
| 1439 |
+
# Should be the same cached class
|
| 1440 |
+
assert Type1 is Type2
|
| 1441 |
+
assert issubclass(Type1, BaseModel)
|
tests/utilities/test_mcp_config.py
CHANGED
|
@@ -136,8 +136,8 @@ async def test_multi_client(tmp_path: Path):
|
|
| 136 |
|
| 137 |
result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
|
| 138 |
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
|
| 139 |
-
assert result_1
|
| 140 |
-
assert result_2
|
| 141 |
|
| 142 |
|
| 143 |
async def test_remote_config_default_no_auth():
|
|
|
|
| 136 |
|
| 137 |
result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
|
| 138 |
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
|
| 139 |
+
assert result_1.data == 3
|
| 140 |
+
assert result_2.data == 3
|
| 141 |
|
| 142 |
|
| 143 |
async def test_remote_config_default_no_auth():
|
tests/utilities/test_types.py
CHANGED
|
@@ -12,6 +12,7 @@ from fastmcp.utilities.types import (
|
|
| 12 |
find_kwarg_by_type,
|
| 13 |
is_class_member_of_type,
|
| 14 |
issubclass_safe,
|
|
|
|
| 15 |
)
|
| 16 |
|
| 17 |
|
|
@@ -536,3 +537,29 @@ class TestFindKwargByType:
|
|
| 536 |
pass
|
| 537 |
|
| 538 |
assert find_kwarg_by_type(func, str) == "c"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
find_kwarg_by_type,
|
| 13 |
is_class_member_of_type,
|
| 14 |
issubclass_safe,
|
| 15 |
+
replace_type,
|
| 16 |
)
|
| 17 |
|
| 18 |
|
|
|
|
| 537 |
pass
|
| 538 |
|
| 539 |
assert find_kwarg_by_type(func, str) == "c"
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
class TestReplaceType:
|
| 543 |
+
@pytest.mark.parametrize(
|
| 544 |
+
"input,type_map,expected",
|
| 545 |
+
[
|
| 546 |
+
(int, {}, int),
|
| 547 |
+
(int, {int: str}, str),
|
| 548 |
+
(int, {int: int}, int),
|
| 549 |
+
(int, {int: float, bool: str}, float),
|
| 550 |
+
(bool, {int: float, bool: str}, str),
|
| 551 |
+
(int, {int: list[int]}, list[int]),
|
| 552 |
+
(list[int], {int: str}, list[str]),
|
| 553 |
+
(list[int], {int: list[str]}, list[list[str]]),
|
| 554 |
+
(
|
| 555 |
+
list[int],
|
| 556 |
+
{int: float, list[int]: bool},
|
| 557 |
+
bool,
|
| 558 |
+
), # list[int] will match before int
|
| 559 |
+
(list[int | bool], {int: str}, list[str | bool]),
|
| 560 |
+
(list[list[int]], {int: str}, list[list[str]]),
|
| 561 |
+
],
|
| 562 |
+
)
|
| 563 |
+
def test_replace_type(self, input, type_map, expected):
|
| 564 |
+
"""Test replacing a type with another type."""
|
| 565 |
+
assert replace_type(input, type_map) == expected
|