mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 23:44:17 +02:00
Fix: Remove JSON schema title metadata while preserving parameters named 'title' (#1872)
This commit is contained in:
parent
7770e3a6bc
commit
a41600d838
6 changed files with 153 additions and 83 deletions
|
|
@ -413,7 +413,9 @@ class ParsedFunction:
|
|||
|
||||
input_type_adapter = get_cached_typeadapter(fn)
|
||||
input_schema = input_type_adapter.json_schema()
|
||||
input_schema = compress_schema(input_schema, prune_params=prune_params)
|
||||
input_schema = compress_schema(
|
||||
input_schema, prune_params=prune_params, prune_titles=True
|
||||
)
|
||||
|
||||
output_schema = None
|
||||
# Get the return annotation from the signature
|
||||
|
|
@ -473,7 +475,7 @@ class ParsedFunction:
|
|||
else:
|
||||
output_schema = base_schema
|
||||
|
||||
output_schema = compress_schema(output_schema)
|
||||
output_schema = compress_schema(output_schema, prune_titles=True)
|
||||
|
||||
except PydanticSchemaGenerationError as e:
|
||||
if "_UnserializableType" not in str(e):
|
||||
|
|
|
|||
|
|
@ -109,8 +109,25 @@ def _single_pass_optimize(
|
|||
root_refs.add(referenced_def)
|
||||
|
||||
# Apply cleanups
|
||||
# Only remove "title" if it's a schema metadata field
|
||||
# Schema objects have keywords like "type", "properties", "$ref", etc.
|
||||
# If we see these, then "title" is metadata, not a property name
|
||||
if prune_titles and "title" in node:
|
||||
node.pop("title")
|
||||
# Check if this looks like a schema node
|
||||
if any(
|
||||
k in node
|
||||
for k in [
|
||||
"type",
|
||||
"properties",
|
||||
"$ref",
|
||||
"items",
|
||||
"allOf",
|
||||
"oneOf",
|
||||
"anyOf",
|
||||
"required",
|
||||
]
|
||||
):
|
||||
node.pop("title")
|
||||
|
||||
if (
|
||||
prune_additional_properties
|
||||
|
|
|
|||
|
|
@ -935,12 +935,13 @@ class TestToolOutputSchema:
|
|||
assert len(tools) == 1
|
||||
|
||||
type_schema = TypeAdapter(annotation).json_schema()
|
||||
# Remove title fields from the schema for comparison (title pruning is enabled)
|
||||
type_schema = compress_schema(type_schema, prune_titles=True)
|
||||
# this line will fail until MCP adds output schemas!!
|
||||
assert tools[0].outputSchema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {**type_schema, "title": "Result"}},
|
||||
"properties": {"result": type_schema},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
|
||||
|
|
@ -958,7 +959,9 @@ class TestToolOutputSchema:
|
|||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
type_schema = compress_schema(TypeAdapter(annotation).json_schema())
|
||||
type_schema = compress_schema(
|
||||
TypeAdapter(annotation).json_schema(), prune_titles=True
|
||||
)
|
||||
assert len(tools) == 1
|
||||
|
||||
# Normalize anyOf ordering for comparison since union type order
|
||||
|
|
@ -1071,9 +1074,8 @@ class TestToolOutputSchema:
|
|||
tool = next(t for t in tools if t.name == "primitive_tool")
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string", "title": "Result"}},
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.outputSchema == expected_schema
|
||||
|
|
@ -1095,12 +1097,13 @@ class TestToolOutputSchema:
|
|||
# List tools and verify schema shows wrapped array
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools if t.name == "complex_tool")
|
||||
expected_inner_schema = TypeAdapter(list[dict[str, int]]).json_schema()
|
||||
expected_inner_schema = compress_schema(
|
||||
TypeAdapter(list[dict[str, int]]).json_schema(), prune_titles=True
|
||||
)
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {**expected_inner_schema, "title": "Result"}},
|
||||
"properties": {"result": expected_inner_schema},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.outputSchema == expected_schema
|
||||
|
|
@ -1129,7 +1132,9 @@ class TestToolOutputSchema:
|
|||
# List tools and verify schema is object type (not wrapped)
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools if t.name == "dataclass_tool")
|
||||
expected_schema = compress_schema(TypeAdapter(User).json_schema())
|
||||
expected_schema = compress_schema(
|
||||
TypeAdapter(User).json_schema(), prune_titles=True
|
||||
)
|
||||
assert tool.outputSchema == expected_schema
|
||||
assert (
|
||||
tool.outputSchema and "x-fastmcp-wrap-result" not in tool.outputSchema
|
||||
|
|
|
|||
|
|
@ -40,16 +40,15 @@ class TestToolFromFunction:
|
|||
"enabled": True,
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"a": {"title": "A", "type": "integer"},
|
||||
"b": {"title": "B", "type": "integer"},
|
||||
"a": {"type": "integer"},
|
||||
"b": {"type": "integer"},
|
||||
},
|
||||
"required": ["a", "b"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"title": "Result", "type": "integer"}},
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
|
|
@ -90,14 +89,13 @@ class TestToolFromFunction:
|
|||
"tags": set(),
|
||||
"enabled": True,
|
||||
"parameters": {
|
||||
"properties": {"url": {"title": "Url", "type": "string"}},
|
||||
"properties": {"url": {"type": "string"}},
|
||||
"required": ["url"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"title": "Result", "type": "string"}},
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
|
|
@ -123,16 +121,15 @@ class TestToolFromFunction:
|
|||
"enabled": True,
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"x": {"title": "X", "type": "integer"},
|
||||
"y": {"title": "Y", "type": "integer"},
|
||||
"x": {"type": "integer"},
|
||||
"y": {"type": "integer"},
|
||||
},
|
||||
"required": ["x", "y"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"title": "Result", "type": "integer"}},
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
|
|
@ -157,16 +154,15 @@ class TestToolFromFunction:
|
|||
"enabled": True,
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"x": {"title": "X", "type": "integer"},
|
||||
"y": {"title": "Y", "type": "integer"},
|
||||
"x": {"type": "integer"},
|
||||
"y": {"type": "integer"},
|
||||
},
|
||||
"required": ["x", "y"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"title": "Result", "type": "integer"}},
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
|
|
@ -196,17 +192,16 @@ class TestToolFromFunction:
|
|||
"$defs": {
|
||||
"UserInput": {
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"title": "UserInput",
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"user": {"$ref": "#/$defs/UserInput", "title": "User"},
|
||||
"flag": {"title": "Flag", "type": "boolean"},
|
||||
"user": {"$ref": "#/$defs/UserInput"},
|
||||
"flag": {"type": "boolean"},
|
||||
},
|
||||
"required": ["user", "flag"],
|
||||
"type": "object",
|
||||
|
|
@ -300,8 +295,8 @@ class TestToolFromFunction:
|
|||
"enabled": True,
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"_a": {"title": "A", "type": "integer"},
|
||||
"_b": {"title": "B", "type": "integer"},
|
||||
"_a": {"type": "integer"},
|
||||
"_b": {"type": "integer"},
|
||||
},
|
||||
"required": ["_a", "_b"],
|
||||
"type": "object",
|
||||
|
|
@ -348,16 +343,15 @@ class TestToolFromFunction:
|
|||
"enabled": True,
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"x": {"title": "X", "type": "integer"},
|
||||
"y": {"title": "Y", "type": "integer"},
|
||||
"x": {"type": "integer"},
|
||||
"y": {"type": "integer"},
|
||||
},
|
||||
"required": ["x", "y"],
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"properties": {"result": {"title": "Result", "type": "integer"}},
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
},
|
||||
|
|
@ -467,9 +461,8 @@ class TestToolFromFunctionOutputSchema:
|
|||
# Non-object types get wrapped
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {**base_schema, "title": "Result"}},
|
||||
"properties": {"result": base_schema},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
|
@ -495,9 +488,8 @@ class TestToolFromFunctionOutputSchema:
|
|||
base_schema = TypeAdapter(annotation).json_schema()
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {**base_schema, "title": "Result"}},
|
||||
"properties": {"result": base_schema},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
|
@ -545,7 +537,9 @@ class TestToolFromFunctionOutputSchema:
|
|||
return Person(name="John", age=30)
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
expected_schema = compress_schema(TypeAdapter(Person).json_schema())
|
||||
expected_schema = compress_schema(
|
||||
TypeAdapter(Person).json_schema(), prune_titles=True
|
||||
)
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
async def test_base_model_return_annotation(self):
|
||||
|
|
@ -561,11 +555,10 @@ class TestToolFromFunctionOutputSchema:
|
|||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
|
|
@ -582,11 +575,10 @@ class TestToolFromFunctionOutputSchema:
|
|||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
|
|
@ -766,9 +758,8 @@ class TestToolFromFunctionOutputSchema:
|
|||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {"result": {"title": "Result", "type": "integer"}},
|
||||
"properties": {"result": {"type": "integer"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
|
|
@ -839,9 +830,8 @@ class TestToolFromFunctionOutputSchema:
|
|||
tool = Tool.from_function(func)
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {"result": {"title": "Result", "type": "string"}},
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"type": "object",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
|
|
@ -1405,8 +1395,8 @@ class TestAutomaticStructuredContent:
|
|||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("get_profile", {"user_id": "456"})
|
||||
|
||||
# Client should deserialize back to a dataclass (type name preserved with new compression)
|
||||
assert result.data.__class__.__name__ == "UserProfile"
|
||||
# Client should deserialize back to a dataclass (but type name is lost with title pruning)
|
||||
assert result.data.__class__.__name__ == "Root"
|
||||
assert result.data.name == "Bob"
|
||||
assert result.data.age == 25
|
||||
assert result.data.verified is True
|
||||
|
|
|
|||
|
|
@ -1084,9 +1084,8 @@ class TestTransformToolOutputSchema:
|
|||
# Should inherit parent's wrapped string schema
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string", "title": "Result"}},
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert new_tool.output_schema == expected_schema
|
||||
|
|
@ -1145,9 +1144,8 @@ class TestTransformToolOutputSchema:
|
|||
# Should infer string schema from custom function and wrap it
|
||||
expected_schema = {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string", "title": "Result"}},
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
"title": "_WrappedResult",
|
||||
"x-fastmcp-wrap-result": True,
|
||||
}
|
||||
assert new_tool.output_schema == expected_schema
|
||||
|
|
@ -1574,15 +1572,12 @@ class TestInputSchema:
|
|||
assert transformed_tool.parameters == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"used_param": {"$ref": "#/$defs/UsedType", "title": "Used Param"}
|
||||
},
|
||||
"properties": {"used_param": {"$ref": "#/$defs/UsedType"}},
|
||||
"required": ["used_param"],
|
||||
"$defs": {
|
||||
"UsedType": {
|
||||
"properties": {"value": {"title": "Value", "type": "string"}},
|
||||
"properties": {"value": {"type": "string"}},
|
||||
"required": ["value"],
|
||||
"title": "UsedType",
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
|
|
@ -1615,18 +1610,12 @@ class TestInputSchema:
|
|||
assert transformed.parameters == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"renamed_input": {
|
||||
"$ref": "#/$defs/InputType",
|
||||
"title": "Input Data",
|
||||
}
|
||||
},
|
||||
"properties": {"renamed_input": {"$ref": "#/$defs/InputType"}},
|
||||
"required": ["renamed_input"],
|
||||
"$defs": {
|
||||
"InputType": {
|
||||
"properties": {"data": {"title": "Data", "type": "string"}},
|
||||
"properties": {"data": {"type": "string"}},
|
||||
"required": ["data"],
|
||||
"title": "InputType",
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
|
|
@ -1659,21 +1648,19 @@ class TestInputSchema:
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param_a": {"$ref": "#/$defs/TypeA", "title": "Param A"},
|
||||
"param_b": {"$ref": "#/$defs/TypeB", "title": "Param B"},
|
||||
"param_a": {"$ref": "#/$defs/TypeA"},
|
||||
"param_b": {"$ref": "#/$defs/TypeB"},
|
||||
},
|
||||
"required": IsList("param_b", "param_a", check_order=False),
|
||||
"$defs": {
|
||||
"TypeA": {
|
||||
"properties": {"a": {"title": "A", "type": "string"}},
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"required": ["a"],
|
||||
"title": "TypeA",
|
||||
"type": "object",
|
||||
},
|
||||
"TypeB": {
|
||||
"properties": {"b": {"title": "B", "type": "integer"}},
|
||||
"properties": {"b": {"type": "integer"}},
|
||||
"required": ["b"],
|
||||
"title": "TypeB",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
|
|
@ -1693,15 +1680,12 @@ class TestInputSchema:
|
|||
assert transform2.parameters == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param_a": {"$ref": "#/$defs/TypeA", "title": "Param A"}
|
||||
},
|
||||
"properties": {"param_a": {"$ref": "#/$defs/TypeA"}},
|
||||
"required": ["param_a"],
|
||||
"$defs": {
|
||||
"TypeA": {
|
||||
"properties": {"a": {"title": "A", "type": "string"}},
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"required": ["a"],
|
||||
"title": "TypeA",
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -433,3 +433,75 @@ class TestCompressSchema:
|
|||
"additionalProperties"
|
||||
not in result["properties"]["foo"]["properties"]["nested"]
|
||||
)
|
||||
|
||||
def test_title_pruning_preserves_parameter_named_title(self):
|
||||
"""Test that a parameter named 'title' is not removed during title pruning.
|
||||
|
||||
This is a critical edge case - we want to remove title metadata but preserve
|
||||
actual parameters that happen to be named 'title'.
|
||||
"""
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field, TypeAdapter
|
||||
|
||||
def greet(
|
||||
name: Annotated[str, Field(description="The name to greet")],
|
||||
title: Annotated[str, Field(description="Optional title", default="")],
|
||||
) -> str:
|
||||
"""A greeting function."""
|
||||
return f"Hello {title} {name}"
|
||||
|
||||
adapter = TypeAdapter(greet)
|
||||
schema = adapter.json_schema()
|
||||
|
||||
# Compress with title pruning
|
||||
compressed = compress_schema(schema, prune_titles=True)
|
||||
|
||||
# The 'title' parameter should be preserved
|
||||
assert "title" in compressed["properties"]
|
||||
assert compressed["properties"]["title"]["description"] == "Optional title"
|
||||
assert compressed["properties"]["title"]["default"] == ""
|
||||
|
||||
# But title metadata should be removed
|
||||
assert "title" not in compressed["properties"]["name"]
|
||||
assert "title" not in compressed["properties"]["title"]
|
||||
|
||||
def test_title_pruning_with_nested_properties(self):
|
||||
"""Test that nested property structures are handled correctly."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"title": "OuterObject",
|
||||
"properties": {
|
||||
"title": { # This is a property named "title", not metadata
|
||||
"type": "object",
|
||||
"title": "TitleObject", # This is metadata
|
||||
"properties": {
|
||||
"subtitle": {
|
||||
"type": "string",
|
||||
"title": "SubTitle", # This is metadata
|
||||
}
|
||||
},
|
||||
},
|
||||
"normal_field": {
|
||||
"type": "string",
|
||||
"title": "NormalField", # This is metadata
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
compressed = compress_schema(schema, prune_titles=True)
|
||||
|
||||
# Root title should be removed
|
||||
assert "title" not in compressed
|
||||
|
||||
# The property named "title" should be preserved
|
||||
assert "title" in compressed["properties"]
|
||||
|
||||
# But its metadata title should be removed
|
||||
assert "title" not in compressed["properties"]["title"]
|
||||
|
||||
# Nested metadata titles should be removed
|
||||
assert (
|
||||
"title" not in compressed["properties"]["title"]["properties"]["subtitle"]
|
||||
)
|
||||
assert "title" not in compressed["properties"]["normal_field"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue