From 1af96b5d5a6d9f591c181a1721d40f26a0e72001 Mon Sep 17 00:00:00 2001 From: aritejhg Date: Sat, 3 Oct 2026 11:50:29 +0800 Subject: [PATCH] docs: clarify Pydantic tool argument envelopes --- docs/servers/tools.md | 14 ++++++ tests/docs_src/test_tools.py | 87 ++++++++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 445700266e..a4e7de8bb4 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -130,6 +130,20 @@ When a tool takes more than a couple of arguments, group them into a Pydantic mo The `Book` schema is nested inside the tool's input schema (as a `$defs` reference), the model fills it in as a JSON object, and your function receives a **real `Book` instance**, already validated, with `.title`, `.author` and `.year` attributes. +In this example, `book` is the envelope key. For `add_book(book: Book)`, send: + +```json +{"book": {"title": "Dune", "author": "Frank Herbert", "year": 1965}} +``` + +If you already have a `Book` instance, use `{"book": book.model_dump(mode="json", by_alias=True)}` as the call arguments. Passing `book.model_dump()` directly does not bind the `book` parameter. + +A model parameter with a default keeps the same envelope; the default only makes that parameter optional. Flat model fields do not populate it. Setting `extra="allow"` on the model accepts extension fields **inside** the envelope. + +If your application's wire protocol requires the model fields at the top level, use the **[low-level Server](../advanced/low-level-server.md)**: publish `Book.model_json_schema(by_alias=True)` as `Tool.input_schema` and validate `params.arguments` with `Book.model_validate(...)`. This lets the model define the wire shape without re-declaring its fields as function parameters. + +Catch `ValidationError` and return a `CallToolResult` with `is_error=True` for invalid arguments; the low-level handler owns validation and **[error handling](../advanced/low-level-server.md#nothing-is-checked-for-you)**. + You can mix and match: plain parameters next to model parameters, nested models, lists of models. It's Pydantic all the way down. ## `async def` diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py index c4051794f4..479b241f3c 100644 --- a/tests/docs_src/test_tools.py +++ b/tests/docs_src/test_tools.py @@ -1,11 +1,14 @@ """`docs/servers/tools.md`: every claim the page makes, proved against the real SDK.""" +import anyio import pytest from inline_snapshot import snapshot from mcp_types import TextContent, ToolAnnotations +from pydantic import BaseModel from docs_src.tools import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 from mcp import Client +from mcp.server import MCPServer # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -93,12 +96,90 @@ async def test_pydantic_model_parameter() -> None: """tutorial004: a `BaseModel` parameter nests its own schema and arrives as a real instance.""" async with Client(tutorial004.mcp) as client: (tool,) = (await client.list_tools()).tools - assert tool.input_schema["$defs"]["Book"]["required"] == ["title", "author", "year"] - book = {"title": "Dune", "author": "Frank Herbert", "year": 1965} - result = await client.call_tool("add_book", {"book": book}) + assert tool.input_schema == snapshot( + { + "type": "object", + "$defs": { + "Book": { + "properties": { + "title": {"title": "Title", "type": "string"}, + "author": {"title": "Author", "type": "string"}, + "year": { + "description": "Year of first publication.", + "minimum": 1450, + "title": "Year", + "type": "integer", + }, + }, + "required": ["title", "author", "year"], + "title": "Book", + "type": "object", + } + }, + "properties": {"book": {"$ref": "#/$defs/Book"}}, + "required": ["book"], + "title": "add_bookArguments", + } + ) + book = tutorial004.Book(title="Dune", author="Frank Herbert", year=1965) + result = await client.call_tool("add_book", {"book": book.model_dump(mode="json", by_alias=True)}) + assert not result.is_error assert result.structured_content == {"result": "Added 'Dune' by Frank Herbert (1965)."} +async def test_model_defaults_and_extensions_keep_the_named_parameter_envelope() -> None: + """SDK-defined: a default changes requiredness, while extension fields belong inside the model. + + Pin the current SDK behavior from #3637: flat fields do not populate a defaulted model parameter. + """ + + class Request(BaseModel): + model_config = {"extra": "allow"} + adcp_version: str | None = None + + mcp = MCPServer("model-envelope") + + @mcp.tool() + async def capabilities(request: Request = Request()) -> Request: + assert isinstance(request, Request) + return request + + payload = {"adcp_version": "3.2", "future_extension": {"enabled": True}} + with anyio.fail_after(5): + async with Client(mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema == snapshot( + { + "type": "object", + "$defs": { + "Request": { + "additionalProperties": True, + "properties": { + "adcp_version": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "title": "Adcp Version", + } + }, + "title": "Request", + "type": "object", + } + }, + "properties": {"request": {"$ref": "#/$defs/Request", "default": {"adcp_version": None}}}, + "title": "capabilitiesArguments", + } + ) + nested = await client.call_tool("capabilities", {"request": payload}) + assert not nested.is_error + assert nested.structured_content == payload + omitted = await client.call_tool("capabilities", {}) + assert not omitted.is_error + assert omitted.structured_content == snapshot({"adcp_version": None}) + flat = await client.call_tool("capabilities", payload) + assert not flat.is_error + assert flat.structured_content == omitted.structured_content + + async def test_title_and_annotations() -> None: """tutorial005: `title` and `ToolAnnotations` are display and behaviour metadata for the client.""" async with Client(tutorial005.mcp) as client: