From a41600d83894643c85021c8e22da653c42bc676f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:19:34 -0400 Subject: [PATCH] Fix: Remove JSON schema title metadata while preserving parameters named 'title' (#1872) --- src/fastmcp/tools/tool.py | 6 +- src/fastmcp/utilities/json_schema.py | 19 +++++- tests/server/test_server_interactions.py | 23 ++++--- tests/tools/test_tool.py | 76 ++++++++++-------------- tests/tools/test_tool_transform.py | 40 ++++--------- tests/utilities/test_json_schema.py | 72 ++++++++++++++++++++++ 6 files changed, 153 insertions(+), 83 deletions(-) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index ec80dfde0..a4b20d5bb 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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): diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index d0d9e37cd..52a897177 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -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 diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index dd5be983f..a5644e0be 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -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 diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 2746d369d..ac6a78041 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -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 diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index d01cf666d..565e16ad3 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -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", } }, diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 65ba9b2e3..5fce3be68 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -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"]