Skip to content

Commit 72ff38c

Browse files
authored
Drop generated titles from tool input and output schemas (#3757)
1 parent 86ca12b commit 72ff38c

24 files changed

Lines changed: 163 additions & 178 deletions

‎docs/advanced/low-level-server.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ asyncio.run(main())
6161
The same text the `@mcp.tool()` version produced. Two honest differences:
6262

6363
* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
64-
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
64+
* `list_tools` returns the schema **you** typed, character for character. Down here, if it's on the wire, you put it there.
6565

6666
In a test you skip uvicorn and the port: `Client(server)` takes a low-level `Server` in-process exactly like it takes an `MCPServer`, and **[Testing](../get-started/testing.md)** is that pattern.
6767

‎docs/client/index.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,10 @@ and `tool.input_schema` is the JSON Schema the server derived from the function'
7272
{
7373
"type": "object",
7474
"properties": {
75-
"query": {"title": "Query", "type": "string"},
76-
"limit": {"default": 10, "title": "Limit", "type": "integer"}
75+
"query": {"type": "string"},
76+
"limit": {"default": 10, "type": "integer"}
7777
},
78-
"required": ["query"],
79-
"title": "search_booksArguments"
78+
"required": ["query"]
8079
}
8180
```
8281

‎docs/handlers/context.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,9 @@ This is the part to internalise. Here is the input schema `tools/list` reports f
3030
{
3131
"type": "object",
3232
"properties": {
33-
"query": {"title": "Query", "type": "string"}
33+
"query": {"type": "string"}
3434
},
35-
"required": ["query"],
36-
"title": "search_booksArguments"
35+
"required": ["query"]
3736
}
3837
```
3938

‎docs/handlers/dependencies.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,9 @@ Here is the input schema `tools/list` reports for `reserve_book`:
2828
{
2929
"type": "object",
3030
"properties": {
31-
"title": {"title": "Title", "type": "string"}
31+
"title": {"type": "string"}
3232
},
33-
"required": ["title"],
34-
"title": "reserve_bookArguments"
33+
"required": ["title"]
3534
}
3635
```
3736

‎docs/handlers/lifespan.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,9 @@ Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it neve
3232
{
3333
"type": "object",
3434
"properties": {
35-
"genre": {"title": "Genre", "type": "string"}
35+
"genre": {"type": "string"}
3636
},
37-
"required": ["genre"],
38-
"title": "count_booksArguments"
37+
"required": ["genre"]
3938
}
4039
```
4140

‎docs/servers/structured-output.md‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ Because of it, the tool the SDK sends during `tools/list` carries an `output_sch
1919
```json
2020
{
2121
"properties": {
22-
"result": {"title": "Result", "type": "integer"}
22+
"result": {"type": "integer"}
2323
},
2424
"required": ["result"],
25-
"title": "get_temperatureOutput",
2625
"type": "object"
2726
}
2827
```
@@ -59,9 +58,9 @@ Declare the shape as a Pydantic `BaseModel` and return an instance:
5958
```json
6059
{
6160
"properties": {
62-
"temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"},
63-
"humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"},
64-
"conditions": {"title": "Conditions", "type": "string"}
61+
"temperature": {"description": "Degrees Celsius.", "type": "number"},
62+
"humidity": {"description": "Relative humidity, 0 to 1.", "type": "number"},
63+
"conditions": {"type": "string"}
6564
},
6665
"required": ["temperature", "humidity", "conditions"],
6766
"title": "WeatherData",
@@ -125,20 +124,19 @@ A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapp
125124
"$defs": {
126125
"WeatherData": {
127126
"properties": {
128-
"temperature": {"title": "Temperature", "type": "number"},
129-
"humidity": {"title": "Humidity", "type": "number"},
130-
"conditions": {"title": "Conditions", "type": "string"}
127+
"temperature": {"type": "number"},
128+
"humidity": {"type": "number"},
129+
"conditions": {"type": "string"}
131130
},
132131
"required": ["temperature", "humidity", "conditions"],
133132
"title": "WeatherData",
134133
"type": "object"
135134
}
136135
},
137136
"properties": {
138-
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"}
137+
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "type": "array"}
139138
},
140139
"required": ["result"],
141-
"title": "get_forecastOutput",
142140
"type": "object"
143141
}
144142
```
@@ -158,7 +156,6 @@ Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}
158156
```json
159157
{
160158
"additionalProperties": {"type": "number"},
161-
"title": "get_temperaturesDictOutput",
162159
"type": "object"
163160
}
164161
```
@@ -169,7 +166,7 @@ result.structured_content # {"London": 16.2, "Reykjavik": 4.4}
169166

170167
The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper.
171168

172-
Dictionary results use Pydantic's `TypeAdapter` for validation and serialization. If you inspect a tool's `FuncMetadata.output_model`, it holds the dictionary type annotation with its schema title.
169+
Dictionary results use Pydantic's `TypeAdapter` for validation and serialization. If you inspect a tool's `FuncMetadata.output_model`, it holds the dictionary type annotation itself.
173170

174171
## Validation
175172

‎docs/servers/tools.md‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,14 @@ From those type hints the SDK generates a JSON Schema and sends it to the client
2424
{
2525
"type": "object",
2626
"properties": {
27-
"query": {"title": "Query", "type": "string"},
28-
"limit": {"title": "Limit", "type": "integer"}
27+
"query": {"type": "string"},
28+
"limit": {"type": "integer"}
2929
},
30-
"required": ["query", "limit"],
31-
"title": "search_booksArguments"
30+
"required": ["query", "limit"]
3231
}
3332
```
3433

35-
Both arguments are in `required` because neither has a default. You'll fix that in a moment. (The `title` keys are Pydantic artifacts; the properties, their types, and `required` are the contract.)
34+
Both arguments are in `required` because neither has a default. You'll fix that in a moment. There are no `title` keys: the ones Pydantic would derive from the class and field names only repeat them. A title you set yourself with `Field(title=...)` is kept.
3635

3736
There is no `$schema` key either: MCP treats a schema without one as **JSON Schema 2020-12**, which is what Pydantic generates, so there is nothing to choose until you write schemas by hand on the **[low-level Server](../advanced/low-level-server.md#the-dialect-is-json-schema-2020-12)**.
3837

@@ -79,11 +78,10 @@ The schema follows:
7978
{
8079
"type": "object",
8180
"properties": {
82-
"query": {"title": "Query", "type": "string"},
83-
"limit": {"default": 10, "title": "Limit", "type": "integer"}
81+
"query": {"type": "string"},
82+
"limit": {"default": 10, "type": "integer"}
8483
},
85-
"required": ["query"],
86-
"title": "search_booksArguments"
84+
"required": ["query"]
8785
}
8886
```
8987

‎src/mcp/server/mcpserver/tools/base.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
returns_input_required,
2222
)
2323
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
24-
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
24+
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, UntitledJsonSchema, func_metadata
2525
from mcp.shared._callable_inspection import is_async_callable
2626
from mcp.shared.exceptions import MCPError
2727
from mcp.shared.inbound import find_invalid_x_mcp_header
@@ -104,7 +104,9 @@ def from_function(
104104
skip_names=skip_names,
105105
structured_output=structured_output,
106106
)
107-
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
107+
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True, schema_generator=UntitledJsonSchema)
108+
# The arguments model is generated, so its class-name title says nothing.
109+
parameters.pop("title", None)
108110
if (reason := find_invalid_x_mcp_header(parameters)) is not None:
109111
raise InvalidSignature(f"Tool {func_name!r} has an invalid x-mcp-header annotation: {reason}")
110112

‎src/mcp/server/mcpserver/utilities/func_metadata.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,17 @@ def _returns_content(annotation: Any) -> bool:
6363
return False
6464

6565

66-
class StrictJsonSchema(GenerateJsonSchema):
66+
class UntitledJsonSchema(GenerateJsonSchema):
67+
"""A JSON schema generator that leaves out the titles pydantic derives from field names.
68+
69+
They only repeat the property key; titles set explicitly with `Field(title=...)` are kept.
70+
"""
71+
72+
def field_title_should_be_set(self, schema: Any) -> bool:
73+
return False
74+
75+
76+
class StrictJsonSchema(UntitledJsonSchema):
6777
"""A JSON schema generator that raises exceptions instead of emitting warnings.
6878
6979
This is used to detect non-serializable types during schema generation.
@@ -128,6 +138,9 @@ def model_post_init(self, context: Any, /) -> None:
128138
if self.output_model is not None and self.output_schema is None:
129139
# StrictJsonSchema raises instead of warning, so an unserializable return type fails construction.
130140
schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
141+
if self.wrap_output:
142+
# The `{"result": ...}` wrapper is ours, so its class-name title says nothing.
143+
schema.pop("title", None)
131144
self.output_schema = _inline_root_ref(schema)
132145

133146
def _output_adapter(self, output_model: Any) -> TypeAdapter[Any]:
@@ -338,7 +351,7 @@ def func_metadata(
338351
field_metadata: list[Any] = []
339352

340353
if param.annotation is inspect.Parameter.empty:
341-
field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"}))
354+
field_metadata.append(WithJsonSchema({"type": "string"}))
342355
# Check if the parameter name conflicts with BaseModel attributes
343356
# This is necessary because Pydantic warns about shadowing parent attributes
344357
if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)):
@@ -508,7 +521,7 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st
508521
if len(args) == 2 and args[0] is str:
509522
# TODO: should we use the original annotation? We are losing any potential `Annotated`
510523
# metadata for Pydantic here:
511-
model = Annotated[type_expr, Field(title=f"{func_name}DictOutput")]
524+
model = type_expr
512525
else:
513526
# dict with non-str keys needs wrapping
514527
model = _create_wrapped_model(func_name, original_annotation)

‎tests/client/test_client.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,13 @@ async def test_client_list_tools(app: MCPServer):
158158
name="greet",
159159
description="Greet someone by name.",
160160
input_schema={
161-
"properties": {"name": {"title": "Name", "type": "string"}},
161+
"properties": {"name": {"type": "string"}},
162162
"required": ["name"],
163-
"title": "greetArguments",
164163
"type": "object",
165164
},
166165
output_schema={
167-
"properties": {"result": {"title": "Result", "type": "string"}},
166+
"properties": {"result": {"type": "string"}},
168167
"required": ["result"],
169-
"title": "greetOutput",
170168
"type": "object",
171169
},
172170
)

0 commit comments

Comments
 (0)