Spaces:
Running
Running
nate nowack Claude commited on
Fix tool output schema generation to respect Pydantic serialization aliases (#1148)
Browse files- src/fastmcp/tools/tool.py +2 -2
- tests/tools/test_tool.py +82 -0
src/fastmcp/tools/tool.py
CHANGED
|
@@ -399,7 +399,7 @@ class ParsedFunction:
|
|
| 399 |
|
| 400 |
try:
|
| 401 |
type_adapter = get_cached_typeadapter(clean_output_type)
|
| 402 |
-
base_schema = type_adapter.json_schema()
|
| 403 |
|
| 404 |
# Generate schema for wrapped type if it's non-object
|
| 405 |
# because MCP requires that output schemas are objects
|
|
@@ -410,7 +410,7 @@ class ParsedFunction:
|
|
| 410 |
# Use the wrapped result schema directly
|
| 411 |
wrapped_type = _WrappedResult[clean_output_type]
|
| 412 |
wrapped_adapter = get_cached_typeadapter(wrapped_type)
|
| 413 |
-
output_schema = wrapped_adapter.json_schema()
|
| 414 |
output_schema["x-fastmcp-wrap-result"] = True
|
| 415 |
else:
|
| 416 |
output_schema = base_schema
|
|
|
|
| 399 |
|
| 400 |
try:
|
| 401 |
type_adapter = get_cached_typeadapter(clean_output_type)
|
| 402 |
+
base_schema = type_adapter.json_schema(mode="serialization")
|
| 403 |
|
| 404 |
# Generate schema for wrapped type if it's non-object
|
| 405 |
# because MCP requires that output schemas are objects
|
|
|
|
| 410 |
# Use the wrapped result schema directly
|
| 411 |
wrapped_type = _WrappedResult[clean_output_type]
|
| 412 |
wrapped_adapter = get_cached_typeadapter(wrapped_type)
|
| 413 |
+
output_schema = wrapped_adapter.json_schema(mode="serialization")
|
| 414 |
output_schema["x-fastmcp-wrap-result"] = True
|
| 415 |
else:
|
| 416 |
output_schema = base_schema
|
tests/tools/test_tool.py
CHANGED
|
@@ -1291,6 +1291,88 @@ class TestUnionReturnTypes:
|
|
| 1291 |
assert result2.structured_content == {"result": "error occurred"}
|
| 1292 |
|
| 1293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1294 |
class TestToolTitle:
|
| 1295 |
"""Tests for tool title functionality."""
|
| 1296 |
|
|
|
|
| 1291 |
assert result2.structured_content == {"result": "error occurred"}
|
| 1292 |
|
| 1293 |
|
| 1294 |
+
class TestSerializationAlias:
|
| 1295 |
+
"""Tests for Pydantic field serialization alias support in tool output schemas."""
|
| 1296 |
+
|
| 1297 |
+
def test_output_schema_respects_serialization_alias(self):
|
| 1298 |
+
"""Test that Tool.from_function generates output schema using serialization alias."""
|
| 1299 |
+
from pydantic import AliasChoices, BaseModel, Field
|
| 1300 |
+
|
| 1301 |
+
class Component(BaseModel):
|
| 1302 |
+
"""Model with multiple validation aliases but specific serialization alias."""
|
| 1303 |
+
|
| 1304 |
+
component_id: str = Field(
|
| 1305 |
+
validation_alias=AliasChoices("id", "componentId"),
|
| 1306 |
+
serialization_alias="componentId",
|
| 1307 |
+
description="The ID of the component",
|
| 1308 |
+
)
|
| 1309 |
+
|
| 1310 |
+
async def get_component(
|
| 1311 |
+
component_id: str,
|
| 1312 |
+
) -> Annotated[Component, Field(description="The component.")]:
|
| 1313 |
+
# API returns data with 'id' field
|
| 1314 |
+
api_data = {"id": component_id}
|
| 1315 |
+
return Component.model_validate(api_data)
|
| 1316 |
+
|
| 1317 |
+
tool = Tool.from_function(get_component, name="get-component")
|
| 1318 |
+
|
| 1319 |
+
# The output schema should use the serialization alias 'componentId'
|
| 1320 |
+
# not the first validation alias 'id'
|
| 1321 |
+
assert tool.output_schema is not None
|
| 1322 |
+
|
| 1323 |
+
# Check the wrapped result schema
|
| 1324 |
+
assert "properties" in tool.output_schema
|
| 1325 |
+
assert "result" in tool.output_schema["properties"]
|
| 1326 |
+
assert "$defs" in tool.output_schema
|
| 1327 |
+
|
| 1328 |
+
# Find the Component definition
|
| 1329 |
+
component_def = list(tool.output_schema["$defs"].values())[0]
|
| 1330 |
+
|
| 1331 |
+
# Should have 'componentId' not 'id' in properties
|
| 1332 |
+
assert "componentId" in component_def["properties"]
|
| 1333 |
+
assert "id" not in component_def["properties"]
|
| 1334 |
+
|
| 1335 |
+
# Should require 'componentId' not 'id'
|
| 1336 |
+
assert "componentId" in component_def["required"]
|
| 1337 |
+
assert "id" not in component_def.get("required", [])
|
| 1338 |
+
|
| 1339 |
+
async def test_tool_execution_with_serialization_alias(self):
|
| 1340 |
+
"""Test that tool execution works correctly with serialization aliases."""
|
| 1341 |
+
from pydantic import AliasChoices, BaseModel, Field
|
| 1342 |
+
|
| 1343 |
+
from fastmcp import Client, FastMCP
|
| 1344 |
+
|
| 1345 |
+
class Component(BaseModel):
|
| 1346 |
+
"""Model with multiple validation aliases but specific serialization alias."""
|
| 1347 |
+
|
| 1348 |
+
component_id: str = Field(
|
| 1349 |
+
validation_alias=AliasChoices("id", "componentId"),
|
| 1350 |
+
serialization_alias="componentId",
|
| 1351 |
+
description="The ID of the component",
|
| 1352 |
+
)
|
| 1353 |
+
|
| 1354 |
+
mcp = FastMCP("TestServer")
|
| 1355 |
+
|
| 1356 |
+
@mcp.tool
|
| 1357 |
+
async def get_component(
|
| 1358 |
+
component_id: str,
|
| 1359 |
+
) -> Annotated[Component, Field(description="The component.")]:
|
| 1360 |
+
# API returns data with 'id' field
|
| 1361 |
+
api_data = {"id": component_id}
|
| 1362 |
+
return Component.model_validate(api_data)
|
| 1363 |
+
|
| 1364 |
+
async with Client(mcp) as client:
|
| 1365 |
+
# Execute the tool - this should work without validation errors
|
| 1366 |
+
result = await client.call_tool(
|
| 1367 |
+
"get_component", {"component_id": "test123"}
|
| 1368 |
+
)
|
| 1369 |
+
|
| 1370 |
+
# The result should contain the serialized form with 'componentId'
|
| 1371 |
+
assert result.structured_content is not None
|
| 1372 |
+
assert result.structured_content["result"]["componentId"] == "test123"
|
| 1373 |
+
assert "id" not in result.structured_content["result"]
|
| 1374 |
+
|
| 1375 |
+
|
| 1376 |
class TestToolTitle:
|
| 1377 |
"""Tests for tool title functionality."""
|
| 1378 |
|