Skip to content

Commit f9d90c6

Browse files
committed
Drop pydantic's auto-derived field titles from tool input schemas
Pydantic titles every field by title-casing its name, so a parameter named exercise_id gains "title": "Exercise Id" - a restatement of the key it already sits under. Tool schemas go to the model on every request, so that repetition is paid for in context on every turn and carries nothing the field name does not. Measured against a real server: the wger MCP server's tools/list is 43,710 bytes across 49 tools, of which 8,333 bytes - 19% - is 297 auto-derived title keys. For an agent granted 43 of those tools on a 32k-context local model, dropping them returns about 2,000 tokens per request. NoAutoTitleJsonSchema suppresses only the automatic titles. A title set explicitly through Field(title=...) is the author's choice and is left alone, as are descriptions and every constraint. Scoped to tool input schemas. Output schemas, prompts and resource templates generate titles the same way and are deliberately left for a separate change.
1 parent 0921d94 commit f9d90c6

13 files changed

Lines changed: 67 additions & 22 deletions

File tree

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, NoAutoTitleJsonSchema, func_metadata
2525
from mcp.shared._callable_inspection import is_async_callable
2626
from mcp.shared.exceptions import MCPError
2727
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
@@ -103,7 +103,9 @@ def from_function(
103103
skip_names=skip_names,
104104
structured_output=structured_output,
105105
)
106-
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
106+
parameters = func_arg_metadata.arg_model.model_json_schema(
107+
by_alias=True, schema_generator=NoAutoTitleJsonSchema
108+
)
107109

108110
# Match `model_dump_one_level`'s kwarg keys (alias when present, else field name)
109111
# so a by-name resolver param resolves to a key that exists at call time.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
7474
raise ValueError(f"JSON schema warning: {kind} - {detail}")
7575

7676

77+
class NoAutoTitleJsonSchema(GenerateJsonSchema):
78+
"""A JSON schema generator that omits pydantic's auto-derived field titles.
79+
80+
Pydantic titles every field by title-casing its name, so ``exercise_id``
81+
gains ``"title": "Exercise Id"`` - a restatement of the key it sits under.
82+
Tool schemas are sent to a model on every request, where that repetition is
83+
paid for in context and carries nothing the field name does not.
84+
85+
Titles set explicitly via ``Field(title=...)`` are untouched: this only
86+
suppresses the automatic ones.
87+
"""
88+
89+
def field_title_should_be_set(self, schema: Any) -> bool:
90+
return False
91+
92+
7793
_LOCAL_DEFS_PREFIX = "#/$defs/"
7894

7995

tests/client/test_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ async def test_client_list_tools(app: MCPServer):
159159
name="greet",
160160
description="Greet someone by name.",
161161
input_schema={
162-
"properties": {"name": {"title": "Name", "type": "string"}},
162+
"properties": {"name": {"type": "string"}},
163163
"required": ["name"],
164164
"title": "greetArguments",
165165
"type": "object",

tests/docs_src/test_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ async def test_list_tools_returns_the_full_definition() -> None:
5656
{
5757
"type": "object",
5858
"properties": {
59-
"query": {"title": "Query", "type": "string"},
60-
"limit": {"default": 10, "title": "Limit", "type": "integer"},
59+
"query": {"type": "string"},
60+
"limit": {"default": 10, "type": "integer"},
6161
},
6262
"required": ["query"],
6363
"title": "search_booksArguments",

tests/docs_src/test_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ async def test_the_context_parameter_is_not_in_the_input_schema() -> None:
2020
assert tool.input_schema == snapshot(
2121
{
2222
"type": "object",
23-
"properties": {"query": {"title": "Query", "type": "string"}},
23+
"properties": {"query": {"type": "string"}},
2424
"required": ["query"],
2525
"title": "search_booksArguments",
2626
}

tests/docs_src/test_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def test_the_resolved_parameter_is_invisible_to_the_model() -> None:
3131
assert tool.input_schema == snapshot(
3232
{
3333
"type": "object",
34-
"properties": {"title": {"title": "Title", "type": "string"}},
34+
"properties": {"title": {"type": "string"}},
3535
"required": ["title"],
3636
"title": "reserve_bookArguments",
3737
}

tests/docs_src/test_first_steps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ async def test_each_decorator_registers_one_primitive() -> None:
2727
{
2828
"type": "object",
2929
"properties": {
30-
"a": {"title": "A", "type": "integer"},
31-
"b": {"title": "B", "type": "integer"},
30+
"a": {"type": "integer"},
31+
"b": {"type": "integer"},
3232
},
3333
"required": ["a", "b"],
3434
"title": "addArguments",

tests/docs_src/test_lifespan.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async def test_context_parameter_never_reaches_the_input_schema() -> None:
2929
assert tool.input_schema == snapshot(
3030
{
3131
"type": "object",
32-
"properties": {"genre": {"title": "Genre", "type": "string"}},
32+
"properties": {"genre": {"type": "string"}},
3333
"required": ["genre"],
3434
"title": "count_booksArguments",
3535
}

tests/docs_src/test_progress.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ async def test_context_parameter_is_invisible_to_the_model() -> None:
1919
"""tutorial001: `ctx` comes from the type hint and never reaches the input schema."""
2020
async with Client(tutorial001.mcp) as client:
2121
(tool,) = (await client.list_tools()).tools
22-
assert tool.input_schema["properties"] == {
23-
"urls": {"items": {"type": "string"}, "title": "Urls", "type": "array"}
24-
}
22+
assert tool.input_schema["properties"] == {"urls": {"items": {"type": "string"}, "type": "array"}}
2523
assert tool.input_schema["required"] == ["urls"]
2624

2725

tests/docs_src/test_real_host.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ async def test_the_host_sees_exactly_what_the_decorators_registered() -> None:
2020
assert search.input_schema == snapshot(
2121
{
2222
"type": "object",
23-
"properties": {"query": {"title": "Query", "type": "string"}},
23+
"properties": {"query": {"type": "string"}},
2424
"required": ["query"],
2525
"title": "search_booksArguments",
2626
}

0 commit comments

Comments
 (0)