diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index a146e01442..21d108995d 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -234,11 +234,25 @@ There is one way to end up unstructured without asking for it: return a class th Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the application can see but the model can't)? That's **[The low-level Server](../advanced/low-level-server.md)**. +## Skip the text copy + +By default a structured tool sends the value twice: as `structured_content`, and as a serialized copy of it in a `content` text block. That copy is the spec's recommendation (a SHOULD, not a MUST): it keeps the value available to a client that consumes `content`. When the payload is large it is pure duplication — the same data crosses the wire twice. Which field a client actually forwards to a model is the client's choice; this flag only controls whether the serialized copy is emitted. + +Pass `mirror_structured_content=False` to send `structured_content` only, with empty `content`: + +```python +@mcp.tool(mirror_structured_content=False) +def list_accounts(segment: str) -> list[Account]: + return query_accounts(segment) # only structured_content is sent +``` + +The default is `True`, so nothing changes unless you opt out. A tool with no `output_schema` is unaffected — its `content` is the only representation and is always sent. If you want a *smaller* `content` rendering rather than none, build the `CallToolResult` yourself and set `content` to a summary. + ## Recap * The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`. * Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes and `dict[str, ...]` are objects already and stay as they are. -* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application). +* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application) — unless you pass `mirror_structured_content=False`, which sends `structured_content` only. * What you return is validated against the schema. A mismatch is a tool error, not a corrupt result. * `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index c6a75ec925..86d004ff34 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -574,6 +574,7 @@ def add_tool( icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, structured_output: bool | None = None, + mirror_structured_content: bool = True, ) -> None: """Add a tool to the server. @@ -592,6 +593,10 @@ def add_tool( - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) - If False, unconditionally creates an unstructured tool + mirror_structured_content: Whether structured output is also serialized into a + `content` text block alongside `structuredContent` (the spec's SHOULD, + default True). Set False to send `structuredContent` only, without the + duplicate serialized copy on the wire. """ self._tool_manager.add_tool( fn, @@ -602,6 +607,7 @@ def add_tool( icons=icons, meta=meta, structured_output=structured_output, + mirror_structured_content=mirror_structured_content, ) def remove_tool(self, name: str) -> None: @@ -624,6 +630,7 @@ def tool( icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, structured_output: bool | None = None, + mirror_structured_content: bool = True, ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a tool. @@ -642,6 +649,10 @@ def tool( - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) - If False, unconditionally creates an unstructured tool + mirror_structured_content: Whether structured output is also serialized into a + `content` text block alongside `structuredContent` (the spec's SHOULD, + default True). Set False to send `structuredContent` only, without the + duplicate serialized copy on the wire. Example: ```python @@ -680,6 +691,7 @@ def decorator(fn: _CallableT) -> _CallableT: icons=icons, meta=meta, structured_output=structured_output, + mirror_structured_content=mirror_structured_content, ) return fn diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 23248707a3..316adafb71 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -49,6 +49,10 @@ class Tool(BaseModel): annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") + mirror_structured_content: bool = Field( + default=True, + description="Whether structured output is also mirrored into a serialized text content block", + ) @cached_property def output_schema(self) -> dict[str, Any] | None: @@ -66,6 +70,7 @@ def from_function( icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, structured_output: bool | None = None, + mirror_structured_content: bool = True, ) -> Tool: """Create a Tool from a function.""" func_name = name or fn.__name__ @@ -118,6 +123,7 @@ def from_function( annotations=annotations, icons=icons, meta=meta, + mirror_structured_content=mirror_structured_content, ) async def run( @@ -146,7 +152,13 @@ async def run( if isinstance(resolved, InputRequiredResult): # A resolver still needs client input (>= 2026-07-28): surface the # batched questions instead of running the tool body this round. - return self.fn_metadata.convert_result(resolved) if convert_result else resolved + return ( + self.fn_metadata.convert_result( + resolved, mirror_structured_content=self.mirror_structured_content + ) + if convert_result + else resolved + ) pass_directly |= resolved result = await self.fn_metadata.call_fn_with_arg_validation( @@ -167,7 +179,9 @@ async def run( ) if convert_result: - result = self.fn_metadata.convert_result(result) + result = self.fn_metadata.convert_result( + result, mirror_structured_content=self.mirror_structured_content + ) return result except MCPError: diff --git a/src/mcp/server/mcpserver/tools/tool_manager.py b/src/mcp/server/mcpserver/tools/tool_manager.py index 9e7910ea93..2e0521d6b7 100644 --- a/src/mcp/server/mcpserver/tools/tool_manager.py +++ b/src/mcp/server/mcpserver/tools/tool_manager.py @@ -46,6 +46,7 @@ def add_tool( icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, structured_output: bool | None = None, + mirror_structured_content: bool = True, ) -> Tool: """Add a tool to the server.""" tool = Tool.from_function( @@ -57,6 +58,7 @@ def add_tool( icons=icons, meta=meta, structured_output=structured_output, + mirror_structured_content=mirror_structured_content, ) existing = self._tools.get(tool.name) if existing: diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..73bb425cc9 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -107,19 +107,24 @@ async def call_fn_with_arg_validation( else: return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict)) - def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: + def convert_result( + self, result: Any, *, mirror_structured_content: bool = True + ) -> CallToolResult | InputRequiredResult: """Convert a function call result into a `CallToolResult`. An `InputRequiredResult` is passed through unchanged so the multi-round flow surfaces on the wire as `resultType: "input_required"` rather than being JSON-dumped into a text block. - Note: we build unstructured content here **even though the lowlevel server - tool call handler provides generic backwards compatibility serialization of - structured content**. This is for MCPServer backwards compatibility: we need to - retain MCPServer's ad hoc conversion logic for constructing unstructured output - from function return values, whereas the lowlevel server simply serializes - the structured output. + For a tool with an output schema, the structured output is returned as + `structuredContent`. By default it is *also* serialized into a `content` + text block -- the spec's SHOULD, which keeps the value available to a + client that consumes `content`. When ``mirror_structured_content`` is + False that serialized copy is omitted and `structuredContent` is the only + representation on the wire, so the same payload is not sent twice. Because + the serialized-text guidance is a SHOULD (not a MUST), opting out stays + conformant. The SDK makes no assumption about which field a client routes + to a model versus elsewhere; that is the client's choice. """ if isinstance(result, InputRequiredResult): return result @@ -129,10 +134,10 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: self.output_model.model_validate(result.structured_content) return result - unstructured_content = _convert_to_content(result) - if self.output_schema is None: - return CallToolResult(content=unstructured_content) + return CallToolResult(content=_convert_to_content(result)) + + content: list[ContentBlock] = _convert_to_content(result) if mirror_structured_content else [] if self.wrap_output: result = {"result": result} @@ -141,7 +146,7 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: validated = self.output_model.model_validate(result) structured_content = validated.model_dump(mode="json", by_alias=True) - return CallToolResult(content=unstructured_content, structured_content=structured_content) + return CallToolResult(content=content, structured_content=structured_content) def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: """Pre-parse data from JSON. diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 98d59e98cb..594de62894 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -2345,3 +2345,25 @@ def greeting() -> str: # pragma: no cover assert mcp._prompt_manager.list_prompts() == [] with pytest.raises(ValueError, match="Unknown prompt: greeting"): mcp.remove_prompt("greeting") + + +async def test_tool_mirror_structured_content_false_omits_text_block(): + """mirror_structured_content=False returns structuredContent only, with empty content.""" + + class UserOutput(BaseModel): + name: str + age: int + + mcp = MCPServer() + + @mcp.tool(mirror_structured_content=False) + def get_user(user_id: int) -> UserOutput: + """Get user by ID""" + return UserOutput(name="John Doe", age=30) + + async with Client(mcp) as client: + result = await client.call_tool("get_user", {"user_id": 123}) + assert result.is_error is False + assert result.structured_content == {"name": "John Doe", "age": 30} + # The serialized text mirror is suppressed; structuredContent is the sole representation. + assert result.content == []