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