Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ asyncio.run(main())
The same text the `@mcp.tool()` version produced. Two honest differences:

* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
* `list_tools` returns the schema **you** typed, character for character. Down here, if it's on the wire, you put it there.

In a test you skip uvicorn and the port: `Client(server)` takes a low-level `Server` in-process exactly like it takes an `MCPServer`, and **[Testing](../get-started/testing.md)** is that pattern.

Expand Down
7 changes: 3 additions & 4 deletions docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,10 @@ and `tool.input_schema` is the JSON Schema the server derived from the function'
{
"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"
"required": ["query"]
}
```

Expand Down
5 changes: 2 additions & 3 deletions docs/handlers/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@ This is the part to internalise. Here is the input schema `tools/list` reports f
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"}
"query": {"type": "string"}
},
"required": ["query"],
"title": "search_booksArguments"
"required": ["query"]
}
```

Expand Down
5 changes: 2 additions & 3 deletions docs/handlers/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ Here is the input schema `tools/list` reports for `reserve_book`:
{
"type": "object",
"properties": {
"title": {"title": "Title", "type": "string"}
"title": {"type": "string"}
},
"required": ["title"],
"title": "reserve_bookArguments"
"required": ["title"]
}
```

Expand Down
5 changes: 2 additions & 3 deletions docs/handlers/lifespan.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it neve
{
"type": "object",
"properties": {
"genre": {"title": "Genre", "type": "string"}
"genre": {"type": "string"}
},
"required": ["genre"],
"title": "count_booksArguments"
"required": ["genre"]
}
```

Expand Down
21 changes: 9 additions & 12 deletions docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ Because of it, the tool the SDK sends during `tools/list` carries an `output_sch
```json
{
"properties": {
"result": {"title": "Result", "type": "integer"}
"result": {"type": "integer"}
},
"required": ["result"],
"title": "get_temperatureOutput",
"type": "object"
}
```
Expand Down Expand Up @@ -59,9 +58,9 @@ Declare the shape as a Pydantic `BaseModel` and return an instance:
```json
{
"properties": {
"temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"},
"humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"}
"temperature": {"description": "Degrees Celsius.", "type": "number"},
"humidity": {"description": "Relative humidity, 0 to 1.", "type": "number"},
"conditions": {"type": "string"}
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
Expand Down Expand Up @@ -125,20 +124,19 @@ A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapp
"$defs": {
"WeatherData": {
"properties": {
"temperature": {"title": "Temperature", "type": "number"},
"humidity": {"title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"}
"temperature": {"type": "number"},
"humidity": {"type": "number"},
"conditions": {"type": "string"}
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object"
}
},
"properties": {
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"}
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "type": "array"}
},
"required": ["result"],
"title": "get_forecastOutput",
"type": "object"
}
```
Expand All @@ -158,7 +156,6 @@ Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}
```json
{
"additionalProperties": {"type": "number"},
"title": "get_temperaturesDictOutput",
"type": "object"
}
```
Expand All @@ -169,7 +166,7 @@ result.structured_content # {"London": 16.2, "Reykjavik": 4.4}

The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper.

Dictionary results use Pydantic's `TypeAdapter` for validation and serialization. If you inspect a tool's `FuncMetadata.output_model`, it holds the dictionary type annotation with its schema title.
Dictionary results use Pydantic's `TypeAdapter` for validation and serialization. If you inspect a tool's `FuncMetadata.output_model`, it holds the dictionary type annotation itself.

## Validation

Expand Down
16 changes: 7 additions & 9 deletions docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,14 @@ From those type hints the SDK generates a JSON Schema and sends it to the client
{
"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"
"required": ["query", "limit"]
}
```

Both arguments are in `required` because neither has a default. You'll fix that in a moment. (The `title` keys are Pydantic artifacts; the properties, their types, and `required` are the contract.)
Both arguments are in `required` because neither has a default. You'll fix that in a moment. There are no `title` keys: the ones Pydantic would derive from the class and field names only repeat them. A title you set yourself with `Field(title=...)` is kept.

There is no `$schema` key either: MCP treats a schema without one as **JSON Schema 2020-12**, which is what Pydantic generates, so there is nothing to choose until you write schemas by hand on the **[low-level Server](../advanced/low-level-server.md#the-dialect-is-json-schema-2020-12)**.

Expand Down Expand Up @@ -79,11 +78,10 @@ The schema follows:
{
"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"
"required": ["query"]
}
```

Expand Down
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, UntitledJsonSchema, func_metadata
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError
from mcp.shared.inbound import find_invalid_x_mcp_header
Expand Down Expand Up @@ -104,7 +104,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=UntitledJsonSchema)
# The arguments model is generated, so its class-name title says nothing.
parameters.pop("title", None)
Comment on lines +107 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): AGENTS.md makes the v2 public API a 2.x compatibility contract and says any change to observable behaviour is a design decision a maintainer makes explicitly: this drops generated title keys from every MCPServer tool's tools/list input and output schema, and changes StrictJsonSchema and FuncMetadata.output_model for dict[str, X]. Fix: record an explicit maintainer decision on the PR, or keep the current output as default and make untitled schemas opt-in, which covers the 6 sites listed. Same instruction at 6 sites (tools/base.py:107, tools/base.py:109, utilities/func_metadata.py:76, :143, :354, :524).

Why this was flagged

Nothing fails at runtime, and validation is unaffected because title is an annotation keyword. What the instruction guards is the 2.x compatibility contract: after this change every MCPServer tool's tools/list entry loses the <func>Arguments root title, the <func>Output root title of wrapped results, the <func>DictOutput title of dict[str, X] results, the parameter-name title of unannotated parameters, and all field-name-derived property titles. It bites for a 2.x user who upgrades and has a client or UI that displays or keys on those titles, a snapshot test of their own tool schemas, or code that relies on output_schema == Model.model_json_schema() (the diff itself had to rewrite test_basemodel_union_input_required_result_derives_model_schema because that equality no longer holds). Two importable classes also change behaviour: StrictJsonSchema now suppresses field titles for anyone using it directly, and FuncMetadata.output_model for dict[str, X] is the bare type rather than Annotated[..., Field(title=...)].

Verification: Instruction: AGENTS.md (base 86ca12b), "Branching Model", second bullet, present as quoted: "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour (including ones softened by a @ deprecated shim) is a design decision a maintainer makes explicitly, and should generally be avoided." It binds library code under src/.

Finding ID: f-833dd265

if (reason := find_invalid_x_mcp_header(parameters)) is not None:
raise InvalidSignature(f"Tool {func_name!r} has an invalid x-mcp-header annotation: {reason}")

Expand Down
19 changes: 16 additions & 3 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,17 @@ def _returns_content(annotation: Any) -> bool:
return False


class StrictJsonSchema(GenerateJsonSchema):
class UntitledJsonSchema(GenerateJsonSchema):
"""A JSON schema generator that leaves out the titles pydantic derives from field names.

They only repeat the property key; titles set explicitly with `Field(title=...)` are kept.
"""

def field_title_should_be_set(self, schema: Any) -> bool:
return False


class StrictJsonSchema(UntitledJsonSchema):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit (optional): code outside the SDK that passes StrictJsonSchema as a schema generator silently loses field titles after upgrading within 2.x. func_metadata.py:76 rebases this existing, API-reference-documented class onto UntitledJsonSchema. On the base branch it only turned schema warnings into errors. Fix: scope the title drop to tool schemas without changing the existing class, e.g. keep StrictJsonSchema(GenerateJsonSchema) and have func_metadata.py:140 use a private subclass of both. Tool output stays as this PR intends, so this is not the opt-in fix for the wire change.

Why this was flagged

Condition: a caller outside this repo uses mcp.server.mcpserver.utilities.func_metadata.StrictJsonSchema directly, e.g. Model.model_json_schema(schema_generator=StrictJsonSchema), or subclasses it. Such callers cannot be enumerated from this checkout; the only in-repo use is…

Verification: Triggers when code outside this repo passes or subclasses StrictJsonSchema directly. Base: class StrictJsonSchema(GenerateJsonSchema) overrode only emit_warning. Head, src/mcp/server/mcpserver/utilities/func_metadata.py:76: class StrictJsonSchema(UntitledJsonSchema), so it now also drops every field-name-derived title. The docstring (lines 77-80) is unchanged.

Finding ID: f-2795f25f

"""A JSON schema generator that raises exceptions instead of emitting warnings.

This is used to detect non-serializable types during schema generation.
Expand Down Expand Up @@ -128,6 +138,9 @@ def model_post_init(self, context: Any, /) -> None:
if self.output_model is not None and self.output_schema is None:
# StrictJsonSchema raises instead of warning, so an unserializable return type fails construction.
schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
if self.wrap_output:
# The `{"result": ...}` wrapper is ours, so its class-name title says nothing.
schema.pop("title", None)
self.output_schema = _inline_root_ref(schema)

def _output_adapter(self, output_model: Any) -> TypeAdapter[Any]:
Expand Down Expand Up @@ -338,7 +351,7 @@ def func_metadata(
field_metadata: list[Any] = []

if param.annotation is inspect.Parameter.empty:
field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"}))
field_metadata.append(WithJsonSchema({"type": "string"}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Unannotated parameters keep a title outside tools, and it changes from the parameter name to a pydantic title-cased one (x becomes X). func_metadata.py:354 drops the explicit "title": param.name. Only base.py:107 passes UntitledJsonSchema. The default generator at resources/templates.py:156 and prompts/base.py:130 then derives its own title. Fix: make every arg_model schema call agree, either by passing schema_generator=UntitledJsonSchema at those two sites or by keeping the name title for non-tool callers.

Why this was flagged

Trigger: a function with an unannotated parameter (def f(city): ..., or a lambda) registered as a resource template or prompt, or any caller of func_metadata(fn).arg_model.model_json_schema(). On the base, func_metadata.py:354 attached WithJsonSchema({"title": param.name, "type": "string"}), so the property was {"title": "city", "type": "string"}. Now it attaches {"type": "string"} and the default pydantic generator fills in a derived title. The diff's own test updates show the result: tests/server/mcpserver/test_func_metadata.py:314-315 go from "title": "x" to "title": "X". ResourceTemplate.parameters (resources/templates.py:116) is built at templates.py:156 with no schema_generator, so its stored schema changes value instead of losing the title. The PR text says this title is dropped; that holds only for tools. No guard applies: the field itself is public output.

Verification: Triggered when a function with an unannotated parameter is registered as a resource template. src/mcp/server/mcpserver/utilities/func_metadata.py:354 now attaches WithJsonSchema({"type": "string"}). Only src/mcp/server/mcpserver/tools/base.py:107 passes schema_generator=UntitledJsonSchema. So outside tools the title is replaced by a title-cased one, not dropped.

Finding ID: f-3d8f4ff9

# Check if the parameter name conflicts with BaseModel attributes
# This is necessary because Pydantic warns about shadowing parent attributes
if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)):
Expand Down Expand Up @@ -508,7 +521,7 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st
if len(args) == 2 and args[0] is str:
# TODO: should we use the original annotation? We are losing any potential `Annotated`
# metadata for Pydantic here:
model = Annotated[type_expr, Field(title=f"{func_name}DictOutput")]
model = type_expr
Comment on lines 522 to +524

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) pre-existing: A tool returning Annotated[dict[str, T], Field(title=...)] still loses its explicit title, although this change promises explicit titles are kept. func_metadata.py:524 builds the model from type_expr, which has the Annotated metadata stripped. The title and any description never reach outputSchema. Fix: build the dict[str, T] output model from original_annotation so Field(...) metadata survives, as it already does for wrapped returns. The base overwrote the title with <func>DictOutput; now the schema has no title at all.

Why this was flagged

Trigger: a tool declared -> Annotated[dict[str, float], Field(title="Temperatures", description="By city")], registered through Tool.from_function (src/mcp/server/mcpserver/tools/base.py:102), which calls func_metadata and then _create_output_model (func_metadata.py:457). inspect_annotation(...).type at func_metadata.py:390 strips Annotated, and line 524 sets model = type_expr. The schema derived at func_metadata.py:140 is {"additionalProperties": {"type": "number"}, "type": "object"} with neither the title nor the description. The base lost the same metadata: it wrapped type_expr in Field(title=f"{func_name}DictOutput"), so this is not a regression, and the TODO at func_metadata.py:522-523 names the loss. The wrapped path passes original_annotation to _create_wrapped_model (func_metadata.py:531), which is why Field metadata survives there. No safeguard applies: the new test at tests/server/mcpserver/test_tool_manager.py:910 only checks a BaseModel return.

Verification: pre-existing: triggers when a tool's return annotation is Annotated[dict[str, T], Field(title=..., description=...)]. For dict[str, T] (src/mcp/server/mcpserver/utilities/func_metadata.py lines 519-524) the model is model = type_expr, so the Field(...) metadata held only in original_annotation is discarded. The base line was model = Annotated[type_expr, Field(title=f"{func_name}DictOutput")], also built from the stripped type_expr.

Finding ID: f-fe6a2e07

else:
# dict with non-str keys needs wrapping
model = _create_wrapped_model(func_name, original_annotation)
Expand Down
6 changes: 2 additions & 4 deletions tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,13 @@ 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",
},
output_schema={
"properties": {"result": {"title": "Result", "type": "string"}},
"properties": {"result": {"type": "string"}},
"required": ["result"],
"title": "greetOutput",
"type": "object",
},
)
Expand Down
5 changes: 2 additions & 3 deletions tests/docs_src/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,10 @@ 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",
}
)
assert lookup.name == "lookup_book"
Expand Down
11 changes: 2 additions & 9 deletions tests/docs_src/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,7 @@ async def test_the_context_parameter_is_not_in_the_input_schema() -> None:
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {"query": {"title": "Query", "type": "string"}},
"required": ["query"],
"title": "search_booksArguments",
}
{"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
)


Expand Down Expand Up @@ -55,9 +50,7 @@ async def test_a_context_only_tool_takes_no_arguments() -> None:
"""tutorial002: a tool whose only parameter is the `Context` has an empty input schema."""
async with Client(tutorial002.mcp) as client:
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
assert tools["describe_catalog"].input_schema == snapshot(
{"type": "object", "properties": {}, "title": "describe_catalogArguments"}
)
assert tools["describe_catalog"].input_schema == snapshot({"type": "object", "properties": {}})


async def test_register_a_tool_at_runtime_and_notify_the_client() -> None:
Expand Down
7 changes: 1 addition & 6 deletions tests/docs_src/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ async def test_the_resolved_parameter_is_invisible_to_the_model() -> None:
(tool,) = (await client.list_tools()).tools

assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {"title": {"title": "Title", "type": "string"}},
"required": ["title"],
"title": "reserve_bookArguments",
}
{"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]}
)


Expand Down
5 changes: 2 additions & 3 deletions tests/docs_src/test_first_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,10 @@ 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
7 changes: 1 addition & 6 deletions tests/docs_src/test_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,7 @@ async def test_context_parameter_never_reaches_the_input_schema() -> None:
async with Client(tutorial001.mcp) as client:
(tool,) = (await client.list_tools()).tools
assert tool.input_schema == snapshot(
{
"type": "object",
"properties": {"genre": {"title": "Genre", "type": "string"}},
"required": ["genre"],
"title": "count_booksArguments",
}
{"type": "object", "properties": {"genre": {"type": "string"}}, "required": ["genre"]}
)


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
7 changes: 1 addition & 6 deletions tests/docs_src/test_real_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,7 @@ async def test_the_host_sees_exactly_what_the_decorators_registered() -> None:
assert search.name == "search_books"
assert search.description == "Search the catalog by title or author."
assert search.input_schema == snapshot(
{
"type": "object",
"properties": {"query": {"title": "Query", "type": "string"}},
"required": ["query"],
"title": "search_booksArguments",
}
{"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
)
assert get.name == "get_author"

Expand Down
Loading
Loading