diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 4a8bed792e..387ec743b6 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, NoTitleJsonSchema, 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,7 @@ 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=NoTitleJsonSchema) # 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..50968ec88e 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -74,6 +74,14 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: raise ValueError(f"JSON schema warning: {kind} - {detail}") +class NoTitleJsonSchema(StrictJsonSchema): + """A strict JSON schema generator that omits titles derived from field names.""" + + def field_title_should_be_set(self, schema: Any) -> bool: + """Return false so Pydantic does not add a title derived from the field name.""" + return False + + _LOCAL_DEFS_PREFIX = "#/$defs/" diff --git a/tests/server/mcpserver/test_title.py b/tests/server/mcpserver/test_title.py index ff76bdc0af..c084cdff94 100644 --- a/tests/server/mcpserver/test_title.py +++ b/tests/server/mcpserver/test_title.py @@ -1,7 +1,10 @@ """Integration tests for title field functionality.""" +from typing import Annotated + import pytest from mcp_types import Prompt, Resource, ResourceTemplate, Tool, ToolAnnotations +from pydantic import Field from mcp import Client from mcp.server.mcpserver import MCPServer @@ -87,6 +90,23 @@ def tool_with_both(message: str) -> str: # pragma: no cover assert both.title == "Primary Title" +@pytest.mark.anyio +async def test_tool_input_schema_omits_derived_titles_but_preserves_explicit_titles(): + """Tool schemas omit Pydantic's field-name titles while preserving explicit titles.""" + mcp = MCPServer(name="SchemaTitleServer") + + @mcp.tool() + def typed_tool(required: str, explicit: Annotated[int, Field(title="Explicit Count")]) -> str: # pragma: no cover + return f"{required}: {explicit}" + + async with Client(mcp) as client: + tools = await client.list_tools() + + properties = tools.tools[0].input_schema["properties"] + assert properties["required"] == {"type": "string"} + assert properties["explicit"] == {"title": "Explicit Count", "type": "integer"} + + @pytest.mark.anyio async def test_prompt_title(): """Test that prompt titles work correctly."""