Skip to content

Commit a41600d

Browse files
authored
Fix: Remove JSON schema title metadata while preserving parameters named 'title' (#1872)
1 parent 7770e3a commit a41600d

File tree

6 files changed

+153
-83
lines changed

6 files changed

+153
-83
lines changed

src/fastmcp/tools/tool.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,9 @@ def from_function(
413413

414414
input_type_adapter = get_cached_typeadapter(fn)
415415
input_schema = input_type_adapter.json_schema()
416-
input_schema = compress_schema(input_schema, prune_params=prune_params)
416+
input_schema = compress_schema(
417+
input_schema, prune_params=prune_params, prune_titles=True
418+
)
417419

418420
output_schema = None
419421
# Get the return annotation from the signature
@@ -473,7 +475,7 @@ def from_function(
473475
else:
474476
output_schema = base_schema
475477

476-
output_schema = compress_schema(output_schema)
478+
output_schema = compress_schema(output_schema, prune_titles=True)
477479

478480
except PydanticSchemaGenerationError as e:
479481
if "_UnserializableType" not in str(e):

src/fastmcp/utilities/json_schema.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,25 @@ def traverse_and_clean(
109109
root_refs.add(referenced_def)
110110

111111
# Apply cleanups
112+
# Only remove "title" if it's a schema metadata field
113+
# Schema objects have keywords like "type", "properties", "$ref", etc.
114+
# If we see these, then "title" is metadata, not a property name
112115
if prune_titles and "title" in node:
113-
node.pop("title")
116+
# Check if this looks like a schema node
117+
if any(
118+
k in node
119+
for k in [
120+
"type",
121+
"properties",
122+
"$ref",
123+
"items",
124+
"allOf",
125+
"oneOf",
126+
"anyOf",
127+
"required",
128+
]
129+
):
130+
node.pop("title")
114131

115132
if (
116133
prune_additional_properties

tests/server/test_server_interactions.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -935,12 +935,13 @@ def f() -> annotation: # type: ignore
935935
assert len(tools) == 1
936936

937937
type_schema = TypeAdapter(annotation).json_schema()
938+
# Remove title fields from the schema for comparison (title pruning is enabled)
939+
type_schema = compress_schema(type_schema, prune_titles=True)
938940
# this line will fail until MCP adds output schemas!!
939941
assert tools[0].outputSchema == {
940942
"type": "object",
941-
"properties": {"result": {**type_schema, "title": "Result"}},
943+
"properties": {"result": type_schema},
942944
"required": ["result"],
943-
"title": "_WrappedResult",
944945
"x-fastmcp-wrap-result": True,
945946
}
946947

@@ -958,7 +959,9 @@ def f() -> annotation: # type: ignore[valid-type]
958959
async with Client(mcp) as client:
959960
tools = await client.list_tools()
960961

961-
type_schema = compress_schema(TypeAdapter(annotation).json_schema())
962+
type_schema = compress_schema(
963+
TypeAdapter(annotation).json_schema(), prune_titles=True
964+
)
962965
assert len(tools) == 1
963966

964967
# Normalize anyOf ordering for comparison since union type order
@@ -1071,9 +1074,8 @@ def primitive_tool() -> str:
10711074
tool = next(t for t in tools if t.name == "primitive_tool")
10721075
expected_schema = {
10731076
"type": "object",
1074-
"properties": {"result": {"type": "string", "title": "Result"}},
1077+
"properties": {"result": {"type": "string"}},
10751078
"required": ["result"],
1076-
"title": "_WrappedResult",
10771079
"x-fastmcp-wrap-result": True,
10781080
}
10791081
assert tool.outputSchema == expected_schema
@@ -1095,12 +1097,13 @@ def complex_tool() -> list[dict[str, int]]:
10951097
# List tools and verify schema shows wrapped array
10961098
tools = await client.list_tools()
10971099
tool = next(t for t in tools if t.name == "complex_tool")
1098-
expected_inner_schema = TypeAdapter(list[dict[str, int]]).json_schema()
1100+
expected_inner_schema = compress_schema(
1101+
TypeAdapter(list[dict[str, int]]).json_schema(), prune_titles=True
1102+
)
10991103
expected_schema = {
11001104
"type": "object",
1101-
"properties": {"result": {**expected_inner_schema, "title": "Result"}},
1105+
"properties": {"result": expected_inner_schema},
11021106
"required": ["result"],
1103-
"title": "_WrappedResult",
11041107
"x-fastmcp-wrap-result": True,
11051108
}
11061109
assert tool.outputSchema == expected_schema
@@ -1129,7 +1132,9 @@ def dataclass_tool() -> User:
11291132
# List tools and verify schema is object type (not wrapped)
11301133
tools = await client.list_tools()
11311134
tool = next(t for t in tools if t.name == "dataclass_tool")
1132-
expected_schema = compress_schema(TypeAdapter(User).json_schema())
1135+
expected_schema = compress_schema(
1136+
TypeAdapter(User).json_schema(), prune_titles=True
1137+
)
11331138
assert tool.outputSchema == expected_schema
11341139
assert (
11351140
tool.outputSchema and "x-fastmcp-wrap-result" not in tool.outputSchema

tests/tools/test_tool.py

Lines changed: 33 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,15 @@ def add(a: int, b: int) -> int:
4040
"enabled": True,
4141
"parameters": {
4242
"properties": {
43-
"a": {"title": "A", "type": "integer"},
44-
"b": {"title": "B", "type": "integer"},
43+
"a": {"type": "integer"},
44+
"b": {"type": "integer"},
4545
},
4646
"required": ["a", "b"],
4747
"type": "object",
4848
},
4949
"output_schema": {
50-
"properties": {"result": {"title": "Result", "type": "integer"}},
50+
"properties": {"result": {"type": "integer"}},
5151
"required": ["result"],
52-
"title": "_WrappedResult",
5352
"type": "object",
5453
"x-fastmcp-wrap-result": True,
5554
},
@@ -90,14 +89,13 @@ async def fetch_data(url: str) -> str:
9089
"tags": set(),
9190
"enabled": True,
9291
"parameters": {
93-
"properties": {"url": {"title": "Url", "type": "string"}},
92+
"properties": {"url": {"type": "string"}},
9493
"required": ["url"],
9594
"type": "object",
9695
},
9796
"output_schema": {
98-
"properties": {"result": {"title": "Result", "type": "string"}},
97+
"properties": {"result": {"type": "string"}},
9998
"required": ["result"],
100-
"title": "_WrappedResult",
10199
"type": "object",
102100
"x-fastmcp-wrap-result": True,
103101
},
@@ -123,16 +121,15 @@ def __call__(self, x: int, y: int) -> int:
123121
"enabled": True,
124122
"parameters": {
125123
"properties": {
126-
"x": {"title": "X", "type": "integer"},
127-
"y": {"title": "Y", "type": "integer"},
124+
"x": {"type": "integer"},
125+
"y": {"type": "integer"},
128126
},
129127
"required": ["x", "y"],
130128
"type": "object",
131129
},
132130
"output_schema": {
133-
"properties": {"result": {"title": "Result", "type": "integer"}},
131+
"properties": {"result": {"type": "integer"}},
134132
"required": ["result"],
135-
"title": "_WrappedResult",
136133
"type": "object",
137134
"x-fastmcp-wrap-result": True,
138135
},
@@ -157,16 +154,15 @@ async def __call__(self, x: int, y: int) -> int:
157154
"enabled": True,
158155
"parameters": {
159156
"properties": {
160-
"x": {"title": "X", "type": "integer"},
161-
"y": {"title": "Y", "type": "integer"},
157+
"x": {"type": "integer"},
158+
"y": {"type": "integer"},
162159
},
163160
"required": ["x", "y"],
164161
"type": "object",
165162
},
166163
"output_schema": {
167-
"properties": {"result": {"title": "Result", "type": "integer"}},
164+
"properties": {"result": {"type": "integer"}},
168165
"required": ["result"],
169-
"title": "_WrappedResult",
170166
"type": "object",
171167
"x-fastmcp-wrap-result": True,
172168
},
@@ -196,17 +192,16 @@ def create_user(user: UserInput, flag: bool) -> dict:
196192
"$defs": {
197193
"UserInput": {
198194
"properties": {
199-
"name": {"title": "Name", "type": "string"},
200-
"age": {"title": "Age", "type": "integer"},
195+
"name": {"type": "string"},
196+
"age": {"type": "integer"},
201197
},
202198
"required": ["name", "age"],
203-
"title": "UserInput",
204199
"type": "object",
205200
}
206201
},
207202
"properties": {
208-
"user": {"$ref": "#/$defs/UserInput", "title": "User"},
209-
"flag": {"title": "Flag", "type": "boolean"},
203+
"user": {"$ref": "#/$defs/UserInput"},
204+
"flag": {"type": "boolean"},
210205
},
211206
"required": ["user", "flag"],
212207
"type": "object",
@@ -300,8 +295,8 @@ def add(_a: int, _b: int) -> int:
300295
"enabled": True,
301296
"parameters": {
302297
"properties": {
303-
"_a": {"title": "A", "type": "integer"},
304-
"_b": {"title": "B", "type": "integer"},
298+
"_a": {"type": "integer"},
299+
"_b": {"type": "integer"},
305300
},
306301
"required": ["_a", "_b"],
307302
"type": "object",
@@ -348,16 +343,15 @@ def add(self, x: int, y: int) -> int:
348343
"enabled": True,
349344
"parameters": {
350345
"properties": {
351-
"x": {"title": "X", "type": "integer"},
352-
"y": {"title": "Y", "type": "integer"},
346+
"x": {"type": "integer"},
347+
"y": {"type": "integer"},
353348
},
354349
"required": ["x", "y"],
355350
"type": "object",
356351
},
357352
"output_schema": {
358-
"properties": {"result": {"title": "Result", "type": "integer"}},
353+
"properties": {"result": {"type": "integer"}},
359354
"required": ["result"],
360-
"title": "_WrappedResult",
361355
"type": "object",
362356
"x-fastmcp-wrap-result": True,
363357
},
@@ -467,9 +461,8 @@ def func() -> annotation: # type: ignore
467461
# Non-object types get wrapped
468462
expected_schema = {
469463
"type": "object",
470-
"properties": {"result": {**base_schema, "title": "Result"}},
464+
"properties": {"result": base_schema},
471465
"required": ["result"],
472-
"title": "_WrappedResult",
473466
"x-fastmcp-wrap-result": True,
474467
}
475468
assert tool.output_schema == expected_schema
@@ -495,9 +488,8 @@ def func() -> annotation: # type: ignore
495488
base_schema = TypeAdapter(annotation).json_schema()
496489
expected_schema = {
497490
"type": "object",
498-
"properties": {"result": {**base_schema, "title": "Result"}},
491+
"properties": {"result": base_schema},
499492
"required": ["result"],
500-
"title": "_WrappedResult",
501493
"x-fastmcp-wrap-result": True,
502494
}
503495
assert tool.output_schema == expected_schema
@@ -545,7 +537,9 @@ def func() -> Person:
545537
return Person(name="John", age=30)
546538

547539
tool = Tool.from_function(func)
548-
expected_schema = compress_schema(TypeAdapter(Person).json_schema())
540+
expected_schema = compress_schema(
541+
TypeAdapter(Person).json_schema(), prune_titles=True
542+
)
549543
assert tool.output_schema == expected_schema
550544

551545
async def test_base_model_return_annotation(self):
@@ -561,11 +555,10 @@ def func() -> Person:
561555
assert tool.output_schema == snapshot(
562556
{
563557
"properties": {
564-
"name": {"title": "Name", "type": "string"},
565-
"age": {"title": "Age", "type": "integer"},
558+
"name": {"type": "string"},
559+
"age": {"type": "integer"},
566560
},
567561
"required": ["name", "age"],
568-
"title": "Person",
569562
"type": "object",
570563
}
571564
)
@@ -582,11 +575,10 @@ def func() -> Person:
582575
assert tool.output_schema == snapshot(
583576
{
584577
"properties": {
585-
"name": {"title": "Name", "type": "string"},
586-
"age": {"title": "Age", "type": "integer"},
578+
"name": {"type": "string"},
579+
"age": {"type": "integer"},
587580
},
588581
"required": ["name", "age"],
589-
"title": "Person",
590582
"type": "object",
591583
}
592584
)
@@ -766,9 +758,8 @@ def func() -> int:
766758
tool = Tool.from_function(func)
767759
assert tool.output_schema == snapshot(
768760
{
769-
"properties": {"result": {"title": "Result", "type": "integer"}},
761+
"properties": {"result": {"type": "integer"}},
770762
"required": ["result"],
771-
"title": "_WrappedResult",
772763
"type": "object",
773764
"x-fastmcp-wrap-result": True,
774765
}
@@ -839,9 +830,8 @@ def func() -> str:
839830
tool = Tool.from_function(func)
840831
assert tool.output_schema == snapshot(
841832
{
842-
"properties": {"result": {"title": "Result", "type": "string"}},
833+
"properties": {"result": {"type": "string"}},
843834
"required": ["result"],
844-
"title": "_WrappedResult",
845835
"type": "object",
846836
"x-fastmcp-wrap-result": True,
847837
}
@@ -1405,8 +1395,8 @@ def get_profile(user_id: str) -> UserProfile:
14051395
async with Client(mcp) as client:
14061396
result = await client.call_tool("get_profile", {"user_id": "456"})
14071397

1408-
# Client should deserialize back to a dataclass (type name preserved with new compression)
1409-
assert result.data.__class__.__name__ == "UserProfile"
1398+
# Client should deserialize back to a dataclass (but type name is lost with title pruning)
1399+
assert result.data.__class__.__name__ == "Root"
14101400
assert result.data.name == "Bob"
14111401
assert result.data.age == 25
14121402
assert result.data.verified is True

0 commit comments

Comments
 (0)