Fix: Remove JSON schema title metadata while preserving parameters named 'title' (#1872)

This commit is contained in:
Jeremiah Lowin 2025-09-23 10:19:34 -04:00 committed by GitHub
commit a41600d838
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 153 additions and 83 deletions

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",
}
},

View file

@ -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"]