From f9d90c6d0e5ecd0db7d46e908a52ff7b6ed3ee08 Mon Sep 17 00:00:00 2001 From: wromansky <25023897+wromansky@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:39:11 -0400 Subject: [PATCH] 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. --- src/mcp/server/mcpserver/tools/base.py | 6 ++-- .../mcpserver/utilities/func_metadata.py | 16 +++++++++ tests/client/test_client.py | 2 +- tests/docs_src/test_client.py | 4 +-- tests/docs_src/test_context.py | 2 +- tests/docs_src/test_dependencies.py | 2 +- tests/docs_src/test_first_steps.py | 4 +-- tests/docs_src/test_lifespan.py | 2 +- tests/docs_src/test_progress.py | 4 +-- tests/docs_src/test_real_host.py | 2 +- tests/docs_src/test_tools.py | 9 +++-- tests/server/mcpserver/test_resolve.py | 2 +- tests/server/mcpserver/test_tool_manager.py | 34 +++++++++++++++++-- 13 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 4a8bed792e..07db17ccdd 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -21,7 +21,7 @@ returns_input_required, ) from mcp.server.mcpserver.utilities.context_injection import find_context_parameter -from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata +from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, NoAutoTitleJsonSchema, func_metadata from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import MCPError from mcp.shared.tool_name_validation import validate_and_warn_tool_name @@ -103,7 +103,9 @@ def from_function( skip_names=skip_names, structured_output=structured_output, ) - parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) + parameters = func_arg_metadata.arg_model.model_json_schema( + by_alias=True, schema_generator=NoAutoTitleJsonSchema + ) # Match `model_dump_one_level`'s kwarg keys (alias when present, else field name) # so a by-name resolver param resolves to a key that exists at call time. diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index cc32433568..af7a967059 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -74,6 +74,22 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: raise ValueError(f"JSON schema warning: {kind} - {detail}") +class NoAutoTitleJsonSchema(GenerateJsonSchema): + """A JSON schema generator that omits pydantic's auto-derived field titles. + + Pydantic titles every field by title-casing its name, so ``exercise_id`` + gains ``"title": "Exercise Id"`` - a restatement of the key it sits under. + Tool schemas are sent to a model on every request, where that repetition is + paid for in context and carries nothing the field name does not. + + Titles set explicitly via ``Field(title=...)`` are untouched: this only + suppresses the automatic ones. + """ + + def field_title_should_be_set(self, schema: Any) -> bool: + return False + + _LOCAL_DEFS_PREFIX = "#/$defs/" diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d7278e3a81..9b3bb9998e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -159,7 +159,7 @@ async def test_client_list_tools(app: MCPServer): name="greet", description="Greet someone by name.", input_schema={ - "properties": {"name": {"title": "Name", "type": "string"}}, + "properties": {"name": {"type": "string"}}, "required": ["name"], "title": "greetArguments", "type": "object", diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index d07ee4c9e4..bf8726b7cb 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -56,8 +56,8 @@ async def test_list_tools_returns_the_full_definition() -> None: { "type": "object", "properties": { - "query": {"title": "Query", "type": "string"}, - "limit": {"default": 10, "title": "Limit", "type": "integer"}, + "query": {"type": "string"}, + "limit": {"default": 10, "type": "integer"}, }, "required": ["query"], "title": "search_booksArguments", diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py index 617d113b2b..c8e3c8e5c2 100644 --- a/tests/docs_src/test_context.py +++ b/tests/docs_src/test_context.py @@ -20,7 +20,7 @@ async def test_the_context_parameter_is_not_in_the_input_schema() -> None: assert tool.input_schema == snapshot( { "type": "object", - "properties": {"query": {"title": "Query", "type": "string"}}, + "properties": {"query": {"type": "string"}}, "required": ["query"], "title": "search_booksArguments", } diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py index 8474d55e4f..1b2f0b364e 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -31,7 +31,7 @@ async def test_the_resolved_parameter_is_invisible_to_the_model() -> None: assert tool.input_schema == snapshot( { "type": "object", - "properties": {"title": {"title": "Title", "type": "string"}}, + "properties": {"title": {"type": "string"}}, "required": ["title"], "title": "reserve_bookArguments", } diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py index 11989850a2..499f2d3fe2 100644 --- a/tests/docs_src/test_first_steps.py +++ b/tests/docs_src/test_first_steps.py @@ -27,8 +27,8 @@ async def test_each_decorator_registers_one_primitive() -> None: { "type": "object", "properties": { - "a": {"title": "A", "type": "integer"}, - "b": {"title": "B", "type": "integer"}, + "a": {"type": "integer"}, + "b": {"type": "integer"}, }, "required": ["a", "b"], "title": "addArguments", diff --git a/tests/docs_src/test_lifespan.py b/tests/docs_src/test_lifespan.py index ec6e98d7de..885a2f6526 100644 --- a/tests/docs_src/test_lifespan.py +++ b/tests/docs_src/test_lifespan.py @@ -29,7 +29,7 @@ async def test_context_parameter_never_reaches_the_input_schema() -> None: assert tool.input_schema == snapshot( { "type": "object", - "properties": {"genre": {"title": "Genre", "type": "string"}}, + "properties": {"genre": {"type": "string"}}, "required": ["genre"], "title": "count_booksArguments", } diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py index a05577fbad..b9a5fef3eb 100644 --- a/tests/docs_src/test_progress.py +++ b/tests/docs_src/test_progress.py @@ -19,9 +19,7 @@ async def test_context_parameter_is_invisible_to_the_model() -> None: """tutorial001: `ctx` comes from the type hint and never reaches the input schema.""" async with Client(tutorial001.mcp) as client: (tool,) = (await client.list_tools()).tools - assert tool.input_schema["properties"] == { - "urls": {"items": {"type": "string"}, "title": "Urls", "type": "array"} - } + assert tool.input_schema["properties"] == {"urls": {"items": {"type": "string"}, "type": "array"}} assert tool.input_schema["required"] == ["urls"] diff --git a/tests/docs_src/test_real_host.py b/tests/docs_src/test_real_host.py index 36b0670d0d..88a435d36d 100644 --- a/tests/docs_src/test_real_host.py +++ b/tests/docs_src/test_real_host.py @@ -20,7 +20,7 @@ async def test_the_host_sees_exactly_what_the_decorators_registered() -> None: assert search.input_schema == snapshot( { "type": "object", - "properties": {"query": {"title": "Query", "type": "string"}}, + "properties": {"query": {"type": "string"}}, "required": ["query"], "title": "search_booksArguments", } diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py index c4051794f4..25061de303 100644 --- a/tests/docs_src/test_tools.py +++ b/tests/docs_src/test_tools.py @@ -21,8 +21,8 @@ async def test_signature_becomes_the_schema() -> None: { "type": "object", "properties": { - "query": {"title": "Query", "type": "string"}, - "limit": {"title": "Limit", "type": "integer"}, + "query": {"type": "string"}, + "limit": {"type": "integer"}, }, "required": ["query", "limit"], "title": "search_booksArguments", @@ -50,8 +50,8 @@ async def test_default_value_makes_the_argument_optional() -> None: { "type": "object", "properties": { - "query": {"title": "Query", "type": "string"}, - "limit": {"default": 10, "title": "Limit", "type": "integer"}, + "query": {"type": "string"}, + "limit": {"default": 10, "type": "integer"}, }, "required": ["query"], "title": "search_booksArguments", @@ -73,7 +73,6 @@ async def test_field_constraints_land_in_the_schema() -> None: "description": "Maximum number of results.", "maximum": 50, "minimum": 1, - "title": "Limit", "type": "integer", } ) diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index 49f0f1314f..a47eade59b 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -2766,7 +2766,7 @@ async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str: assert advertised.input_schema == snapshot( { "type": "object", - "properties": {"title": {"title": "Title", "type": "string"}}, + "properties": {"title": {"type": "string"}}, "required": ["title"], "title": "quoteArguments", } diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 221828c60a..67ecc740c7 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -1,11 +1,11 @@ import json import logging from dataclasses import dataclass -from typing import Any, TypedDict +from typing import Annotated, Any, TypedDict import pytest from mcp_types import CallToolResult, TextContent, ToolAnnotations -from pydantic import BaseModel +from pydantic import BaseModel, Field from mcp.server.context import LifespanContextT, RequestT from mcp.server.mcpserver import Context, MCPServer @@ -904,3 +904,33 @@ def test_func() -> str: # pragma: no cover # Remove with correct case manager.remove_tool("test_func") assert manager.get_tool("test_func") is None + + +def test_tool_input_schema_omits_auto_derived_titles(): + """Auto-derived field titles are dropped; explicit ones survive. + + Pydantic titles every field by title-casing its name, which restates the key + it sits under. Tool schemas are sent to the model on every request, so that + repetition costs context for nothing. + """ + + def log_set( + exercise_id: str, + reps: Annotated[int, Field(title="Repetitions performed")], + weight: Annotated[float, Field(description="in kilograms")] = 0.0, + ) -> str: + """Log a set.""" + return "ok" + + manager = ToolManager() + tool = manager.add_tool(log_set) + properties = tool.parameters["properties"] + + assert "title" not in properties["exercise_id"] + assert "title" not in properties["weight"] + # An explicitly-set title is the author's choice, not pydantic's default. + assert properties["reps"]["title"] == "Repetitions performed" + # Nothing else about the schema changes. + assert properties["exercise_id"]["type"] == "string" + assert properties["weight"]["description"] == "in kilograms" + assert properties["weight"]["default"] == 0.0