Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"


Expand Down
2 changes: 1 addition & 1 deletion tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions tests/docs_src/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/docs_src/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
2 changes: 1 addition & 1 deletion tests/docs_src/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
4 changes: 2 additions & 2 deletions tests/docs_src/test_first_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/docs_src/test_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
4 changes: 1 addition & 3 deletions tests/docs_src/test_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand Down
2 changes: 1 addition & 1 deletion tests/docs_src/test_real_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
9 changes: 4 additions & 5 deletions tests/docs_src/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
}
)
Expand Down
2 changes: 1 addition & 1 deletion tests/server/mcpserver/test_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
34 changes: 32 additions & 2 deletions tests/server/mcpserver/test_tool_manager.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading