diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 111ad62799..859e82fb78 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -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. diff --git a/docs/client/index.md b/docs/client/index.md index 9433e703b0..2edf48e11f 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -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"] } ``` diff --git a/docs/handlers/context.md b/docs/handlers/context.md index f43521aa05..7fefc9c1bc 100644 --- a/docs/handlers/context.md +++ b/docs/handlers/context.md @@ -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"] } ``` diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md index b347e1b7d6..fc42d64cfc 100644 --- a/docs/handlers/dependencies.md +++ b/docs/handlers/dependencies.md @@ -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"] } ``` diff --git a/docs/handlers/lifespan.md b/docs/handlers/lifespan.md index 66d9c91e7b..b08d223fb6 100644 --- a/docs/handlers/lifespan.md +++ b/docs/handlers/lifespan.md @@ -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"] } ``` diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index fbe09ba817..35c2585616 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -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" } ``` @@ -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", @@ -125,9 +124,9 @@ 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", @@ -135,10 +134,9 @@ A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapp } }, "properties": { - "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "type": "array"} }, "required": ["result"], - "title": "get_forecastOutput", "type": "object" } ``` @@ -158,7 +156,6 @@ Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...} ```json { "additionalProperties": {"type": "number"}, - "title": "get_temperaturesDictOutput", "type": "object" } ``` @@ -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 diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 445700266e..c8d2d77f63 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -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)**. @@ -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"] } ``` diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 787f1761b2..10a2d6c489 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, 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 @@ -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) 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}") diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 96d25ec5ab..995b89a0a1 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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): """A JSON schema generator that raises exceptions instead of emitting warnings. This is used to detect non-serializable types during schema generation. @@ -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]: @@ -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"})) # 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)): @@ -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 else: # dict with non-str keys needs wrapping model = _create_wrapped_model(func_name, original_annotation) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index ca5c8aec2f..c63248798e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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", }, ) diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index 7121e98d02..bf9d42b259 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -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" diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py index 617d113b2b..ced87e5eb9 100644 --- a/tests/docs_src/test_context.py +++ b/tests/docs_src/test_context.py @@ -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"]} ) @@ -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: diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py index 8474d55e4f..2dd340b787 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -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"]} ) diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py index 11989850a2..d4f81bac37 100644 --- a/tests/docs_src/test_first_steps.py +++ b/tests/docs_src/test_first_steps.py @@ -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", } ) diff --git a/tests/docs_src/test_lifespan.py b/tests/docs_src/test_lifespan.py index 684ac2fada..80de402d8a 100644 --- a/tests/docs_src/test_lifespan.py +++ b/tests/docs_src/test_lifespan.py @@ -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"]} ) diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py index cfe0731523..92f7e5b22c 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..c2987e62b6 100644 --- a/tests/docs_src/test_real_host.py +++ b/tests/docs_src/test_real_host.py @@ -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" diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py index dcac214378..8bca1d4414 100644 --- a/tests/docs_src/test_structured_output.py +++ b/tests/docs_src/test_structured_output.py @@ -32,9 +32,8 @@ async def test_scalar_return_is_wrapped() -> None: (tool,) = (await client.list_tools()).tools assert tool.output_schema == snapshot( { - "properties": {"result": {"title": "Result", "type": "integer"}}, + "properties": {"result": {"type": "integer"}}, "required": ["result"], - "title": "get_temperatureOutput", "type": "object", } ) @@ -51,9 +50,9 @@ async def test_basemodel_is_the_schema() -> None: assert tool.output_schema == snapshot( { "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", @@ -73,9 +72,9 @@ async def test_typeddict_produces_the_same_schema() -> None: assert tool.output_schema == snapshot( { "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", @@ -93,9 +92,9 @@ async def test_dataclass_produces_the_same_schema() -> None: assert tool.output_schema == snapshot( { "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", @@ -115,20 +114,17 @@ async def test_list_return_is_wrapped() -> None: "$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"} - }, + "properties": {"result": {"items": {"$ref": "#/$defs/WeatherData"}, "type": "array"}}, "required": ["result"], - "title": "get_forecastOutput", "type": "object", } ) @@ -146,9 +142,7 @@ async def test_dict_str_return_is_not_wrapped() -> None: """tutorial006: `dict[str, float]` is already a JSON object, so there is no `result` wrapper.""" async with Client(tutorial006.mcp) as client: (tool,) = (await client.list_tools()).tools - assert tool.output_schema == snapshot( - {"additionalProperties": {"type": "number"}, "title": "get_temperaturesDictOutput", "type": "object"} - ) + assert tool.output_schema == snapshot({"additionalProperties": {"type": "number"}, "type": "object"}) result = await client.call_tool("get_temperatures", {"cities": ["London", "Reykjavik"]}) assert result.structured_content == {"London": 16.2, "Reykjavik": 4.4} diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py index c4051794f4..1427f5a536 100644 --- a/tests/docs_src/test_tools.py +++ b/tests/docs_src/test_tools.py @@ -21,11 +21,10 @@ 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,11 +49,10 @@ 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", } ) result = await client.call_tool("search_books", {"query": "dune"}) @@ -73,7 +71,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/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index dcf239d407..912b0d8a61 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -163,8 +163,8 @@ def get_weather() -> Weather: assert listed.tools[0].output_schema == snapshot( { "properties": { - "temperature": {"title": "Temperature", "type": "number"}, - "conditions": {"title": "Conditions", "type": "string"}, + "temperature": {"type": "number"}, + "conditions": {"type": "string"}, }, "required": ["temperature", "conditions"], "title": "Weather", @@ -205,9 +205,8 @@ def primes() -> list[int]: assert listed.tools[0].output_schema == snapshot( { - "properties": {"result": {"items": {"type": "integer"}, "title": "Result", "type": "array"}}, + "properties": {"result": {"items": {"type": "integer"}, "type": "array"}}, "required": ["result"], - "title": "primesOutput", "type": "object", } ) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c707591287..fe6c7211ac 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -271,7 +271,7 @@ def func_dict_any() -> dict[str, Any]: # pragma: no cover meta = func_metadata(func_dict_any) - assert meta.output_schema == IsPartialDict(type="object", title="func_dict_anyDictOutput") + assert meta.output_schema == IsPartialDict(type="object") # Test dict[str, str] def func_dict_str() -> dict[str, str]: # pragma: no cover @@ -281,7 +281,6 @@ def func_dict_str() -> dict[str, str]: # pragma: no cover assert meta.output_schema == { "type": "object", "additionalProperties": {"type": "string"}, - "title": "func_dict_strDictOutput", } # Test dict[str, list[int]] @@ -292,7 +291,6 @@ def func_dict_list() -> dict[str, list[int]]: # pragma: no cover assert meta.output_schema == { "type": "object", "additionalProperties": {"type": "array", "items": {"type": "integer"}}, - "title": "func_dict_listDictOutput", } # Test dict[int, str] - should be wrapped since key is not str @@ -313,8 +311,8 @@ async def test_lambda_function(): # Test schema assert meta.arg_model.model_json_schema() == { "properties": { - "x": {"title": "x", "type": "string"}, - "y": {"default": 5, "title": "y", "type": "string"}, + "x": {"title": "X", "type": "string"}, + "y": {"default": 5, "title": "Y", "type": "string"}, }, "required": ["x"], "title": "Arguments", @@ -445,7 +443,7 @@ def test_complex_function_json_schema(): "title": "Field With Default Via Field Annotation Before Nondefault Arg", "type": "integer", }, - "unannotated": {"title": "unannotated", "type": "string"}, + "unannotated": {"title": "Unannotated", "type": "string"}, "my_model_a": {"$ref": "#/$defs/SomeInputModelA"}, "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"}, "my_model_b": {"$ref": "#/$defs/SomeInputModelB"}, @@ -457,7 +455,7 @@ def test_complex_function_json_schema(): }, "unannotated_with_default": { "default": 5, - "title": "unannotated_with_default", + "title": "Unannotated With Default", "type": "string", }, "my_model_a_with_default": { @@ -630,9 +628,8 @@ def func_none_annotation() -> None: # pragma: no cover meta = func_metadata(func_none_annotation) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "type": "null"}}, + "properties": {"result": {"type": "null"}}, "required": ["result"], - "title": "func_none_annotationOutput", } @@ -651,9 +648,9 @@ def func_returning_person() -> PersonModel: # pragma: no cover assert meta.output_schema == { "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, - "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None, "title": "Email"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None}, }, "required": ["name", "age"], "title": "PersonModel", @@ -682,45 +679,40 @@ def func_bytes() -> bytes: # pragma: no cover meta = func_metadata(func_str) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "type": "string"}}, + "properties": {"result": {"type": "string"}}, "required": ["result"], - "title": "func_strOutput", } # Test int meta = func_metadata(func_int) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "type": "integer"}}, + "properties": {"result": {"type": "integer"}}, "required": ["result"], - "title": "func_intOutput", } # Test float meta = func_metadata(func_float) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "type": "number"}}, + "properties": {"result": {"type": "number"}}, "required": ["result"], - "title": "func_floatOutput", } # Test bool meta = func_metadata(func_bool) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "type": "boolean"}}, + "properties": {"result": {"type": "boolean"}}, "required": ["result"], - "title": "func_boolOutput", } # Test bytes meta = func_metadata(func_bytes) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "type": "string", "format": "binary"}}, + "properties": {"result": {"type": "string", "format": "binary"}}, "required": ["result"], - "title": "func_bytesOutput", } @@ -743,9 +735,8 @@ def func_optional() -> str | None: # pragma: no cover meta = func_metadata(func_list_str) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "type": "array", "items": {"type": "string"}}}, + "properties": {"result": {"type": "array", "items": {"type": "string"}}}, "required": ["result"], - "title": "func_list_strOutput", } # Test dict[str, int] - should NOT be wrapped @@ -753,25 +744,22 @@ def func_optional() -> str | None: # pragma: no cover assert meta.output_schema == { "type": "object", "additionalProperties": {"type": "integer"}, - "title": "func_dict_str_intDictOutput", } # Test Union meta = func_metadata(func_union) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "anyOf": [{"type": "string"}, {"type": "integer"}]}}, + "properties": {"result": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, "required": ["result"], - "title": "func_unionOutput", } # Test Optional meta = func_metadata(func_optional) assert meta.output_schema == { "type": "object", - "properties": {"result": {"title": "Result", "anyOf": [{"type": "string"}, {"type": "null"}]}}, + "properties": {"result": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, "required": ["result"], - "title": "func_optionalOutput", } @@ -792,13 +780,12 @@ def func_returning_dataclass() -> PersonDataClass: # pragma: no cover assert meta.output_schema == { "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, - "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None, "title": "Email"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None}, "tags": { "anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": None, - "title": "Tags", }, }, "required": ["name", "age"], @@ -821,8 +808,8 @@ def func_returning_typeddict_optional() -> PersonTypedDictOptional: assert meta.output_schema == { "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, }, "required": ["name"], "title": "PersonTypedDictOptional", @@ -845,9 +832,9 @@ def func_returning_typeddict_required() -> PersonTypedDictRequired: # pragma: n assert meta.output_schema == { "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, - "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}]}, }, "required": ["name", "age", "email"], "title": "PersonTypedDictRequired", @@ -880,10 +867,10 @@ def forecast() -> Forecast: "title": "Forecast", "description": "Tomorrow's weather.", "properties": { - "cityName": {"title": "Cityname", "type": "string", "description": "City name"}, - "high": {"title": "High", "type": "number"}, - "low": {"title": "Low", "type": "number"}, - "summary": {"title": "Summary", "type": "string", "maxLength": 80}, + "cityName": {"type": "string", "description": "City name"}, + "high": {"type": "number"}, + "low": {"type": "number"}, + "summary": {"type": "string", "maxLength": 80}, }, "required": ["cityName", "high"], } @@ -946,9 +933,9 @@ def func_returning_class() -> PersonClass: # pragma: no cover assert meta.output_schema == { "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, - "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}]}, }, "required": ["name", "age", "email"], "title": "PersonClass", @@ -1058,7 +1045,7 @@ def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Per assert meta.output_schema == { "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, + "name": {"type": "string"}, }, "required": ["name"], "title": "PersonClass", @@ -1155,8 +1142,8 @@ def func_with_descriptions() -> ModelWithDescriptions: # pragma: no cover assert meta.output_schema == { "type": "object", "properties": { - "name": {"title": "Name", "type": "string", "description": "The person's full name"}, - "age": {"title": "Age", "type": "integer", "description": "Age in years", "minimum": 0, "maximum": 150}, + "name": {"type": "string", "description": "The person's full name"}, + "age": {"type": "integer", "description": "Age in years", "minimum": 0, "maximum": 150}, }, "required": ["name", "age"], "title": "ModelWithDescriptions", @@ -1185,16 +1172,16 @@ def func_nested() -> PersonWithAddress: # pragma: no cover "Address": { "type": "object", "properties": { - "street": {"title": "Street", "type": "string"}, - "city": {"title": "City", "type": "string"}, - "zipcode": {"title": "Zipcode", "type": "string"}, + "street": {"type": "string"}, + "city": {"type": "string"}, + "zipcode": {"type": "string"}, }, "required": ["street", "city", "zipcode"], "title": "Address", } }, "properties": { - "name": {"title": "Name", "type": "string"}, + "name": {"type": "string"}, "address": {"$ref": "#/$defs/Address"}, }, "required": ["name", "address"], @@ -1215,8 +1202,8 @@ def tree() -> Node: node_definition: dict[str, Any] = { "properties": { - "name": {"title": "Name", "type": "string"}, - "children": {"default": [], "items": {"$ref": "#/$defs/Node"}, "title": "Children", "type": "array"}, + "name": {"type": "string"}, + "children": {"default": [], "items": {"$ref": "#/$defs/Node"}, "type": "array"}, }, "required": ["name"], "title": "Node", @@ -1451,7 +1438,7 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc meta = func_metadata(func_with_metadata) assert meta.output_schema is not None - assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} + assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "type": "integer"} def test_convert_result_passes_input_required_result_through_unchanged(): @@ -1499,7 +1486,12 @@ def fn() -> Payload | InputRequiredResult: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_model is Payload assert meta.wrap_output is False - assert meta.output_schema == Payload.model_json_schema() + assert meta.output_schema == { + "properties": {"x": {"type": "integer"}}, + "required": ["x"], + "title": "Payload", + "type": "object", + } def test_call_tool_result_in_union_with_input_required_result_is_still_rejected(): diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index 49f0f1314f..c33149a4d8 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -2764,12 +2764,7 @@ async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str: (advertised,) = (await client.list_tools()).tools assert advertised.input_schema == snapshot( - { - "type": "object", - "properties": {"title": {"title": "Title", "type": "string"}}, - "required": ["title"], - "title": "quoteArguments", - } + {"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]} ) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index a30e4defba..a266714298 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -749,7 +749,7 @@ def get_person() -> Person: assert tool.output_schema == { "type": "object", "title": "Person", - "properties": {"name": {"title": "Name", "type": "string"}, "age": {"title": "Age", "type": "integer"}}, + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name"], } result = await client.call_tool("get_person", {}) diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 221828c60a..2f7e6f9819 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -1,11 +1,12 @@ import json import logging from dataclasses import dataclass -from typing import Any, TypedDict +from typing import Annotated, Any, TypedDict import pytest +from inline_snapshot import snapshot 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 @@ -563,7 +564,7 @@ def get_user() -> UserOutput: # pragma: no cover # Test that output_schema is populated expected_schema = { - "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}}, + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name", "age"], "title": "UserOutput", "type": "object", @@ -904,3 +905,32 @@ 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_schemas_keep_only_titles_set_explicitly(): + class Book(BaseModel): + name: str = Field(title="Book title") + year: int + + def find_book(query: Annotated[str, Field(title="Search query")], limit: int = 10) -> Book: ... + + tool = ToolManager().add_tool(find_book) + + assert tool.parameters == snapshot( + { + "properties": { + "query": {"title": "Search query", "type": "string"}, + "limit": {"default": 10, "type": "integer"}, + }, + "required": ["query"], + "type": "object", + } + ) + assert tool.output_schema == snapshot( + { + "properties": {"name": {"title": "Book title", "type": "string"}, "year": {"type": "integer"}}, + "required": ["name", "year"], + "title": "Book", + "type": "object", + } + )