From 6cdf09ebd753e7ab90bd2a60899871efa6c8053a Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 21 Jul 2026 20:43:03 -0700 Subject: [PATCH 1/3] Add mirror_structured_content opt-out for structured tool results The spec's serialized-text mirror alongside structuredContent is a SHOULD, not a MUST, but MCPServer forced it on every structured result. Add a per-tool `mirror_structured_content` flag (default True, so existing wire behavior is unchanged) that, when False, returns structuredContent only with empty content -- useful for large payloads or hosts that route structured content to the model themselves. Co-Authored-By: Claude Opus 4.8 --- docs/servers/structured-output.md | 16 +++++++++++- src/mcp/server/mcpserver/server.py | 12 +++++++++ src/mcp/server/mcpserver/tools/base.py | 18 +++++++++++-- .../server/mcpserver/tools/tool_manager.py | 2 ++ .../mcpserver/utilities/func_metadata.py | 26 +++++++++++-------- tests/server/mcpserver/test_server.py | 21 +++++++++++++++ 6 files changed, 81 insertions(+), 14 deletions(-) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index a146e01442..b791126852 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 fills **both** channels: `structured_content` for the application, and a serialized copy in `content` for the model. That copy is the spec's recommendation (a SHOULD, not a MUST) so an old client that only reads `content` still sees the value. When the payload is large, or the host routes `structured_content` to the model itself, the copy is wasted — the same data crosses the wire twice. + +Pass `mirror_structured_content=False` to return `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* model-facing 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..4ef5dac6c7 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 serialised into a + `content` text block (the spec's SHOULD, default True). Set False to return + `structuredContent` only -- useful when the host routes structured content to + the model itself and the serialised copy would double the payload. """ 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 serialised into a + `content` text block (the spec's SHOULD, default True). Set False to return + `structuredContent` only -- useful when the host routes structured content to + the model itself and the serialised copy would double the payload. 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..ef3395e3c5 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 serialised 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..e63acbc45b 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -107,19 +107,23 @@ 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, structured output is returned as + `structuredContent`. By default it is *also* serialised into a `content` + text block, so a client that reads only `content` still sees the value -- + the spec's SHOULD. When ``mirror_structured_content`` is False the + serialised copy is omitted and `structuredContent` becomes the sole + representation; a host that routes `structuredContent` to the model itself + then no longer receives the payload twice. Because the serialised-text + guidance is a SHOULD (not a MUST), opting out stays conformant. """ if isinstance(result, InputRequiredResult): return result @@ -129,10 +133,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 +145,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..8d6e6c78a3 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -537,6 +537,27 @@ def get_user(user_id: int) -> UserOutput: assert isinstance(result.content[0], TextContent) assert '"name": "John Doe"' in result.content[0].text + async def test_tool_mirror_structured_content_false_omits_text_block(self): + """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 serialised text mirror is suppressed; structuredContent is the sole representation. + assert result.content == [] + async def test_tool_structured_output_primitive(self): """Test tool with structured output returning primitive type""" From 2b30123a58856ff8b7ab983f1e83a4382a3353c8 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 22 Jul 2026 21:17:09 -0700 Subject: [PATCH 2/3] Neutralize mirror_structured_content wording: no model-vs-app routing claims The opt-out is a wire choice (emit the serialized content copy or not); which field a client forwards to a model is the client's decision, not the SDK's. Reword the docstring, parameter help, and docs to describe duplication rather than asserting content is "for the model." Co-Authored-By: Claude Opus 4.8 --- docs/servers/structured-output.md | 6 +++--- src/mcp/server/mcpserver/server.py | 12 ++++++------ .../server/mcpserver/utilities/func_metadata.py | 15 ++++++++------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index b791126852..21d108995d 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -236,9 +236,9 @@ There is one way to end up unstructured without asking for it: return a class th ## Skip the text copy -By default a structured tool fills **both** channels: `structured_content` for the application, and a serialized copy in `content` for the model. That copy is the spec's recommendation (a SHOULD, not a MUST) so an old client that only reads `content` still sees the value. When the payload is large, or the host routes `structured_content` to the model itself, the copy is wasted — the same data crosses the wire twice. +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 return `structured_content` only, with empty `content`: +Pass `mirror_structured_content=False` to send `structured_content` only, with empty `content`: ```python @mcp.tool(mirror_structured_content=False) @@ -246,7 +246,7 @@ 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* model-facing rendering rather than none, build the `CallToolResult` yourself and set `content` to a summary. +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 diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 4ef5dac6c7..f7d3864074 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -594,9 +594,9 @@ def add_tool( - 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 serialised into a - `content` text block (the spec's SHOULD, default True). Set False to return - `structuredContent` only -- useful when the host routes structured content to - the model itself and the serialised copy would double the payload. + `content` text block alongside `structuredContent` (the spec's SHOULD, + default True). Set False to send `structuredContent` only, without the + duplicate serialised copy on the wire. """ self._tool_manager.add_tool( fn, @@ -650,9 +650,9 @@ def tool( - 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 serialised into a - `content` text block (the spec's SHOULD, default True). Set False to return - `structuredContent` only -- useful when the host routes structured content to - the model itself and the serialised copy would double the payload. + `content` text block alongside `structuredContent` (the spec's SHOULD, + default True). Set False to send `structuredContent` only, without the + duplicate serialised copy on the wire. Example: ```python diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index e63acbc45b..6b2a423cf7 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -116,14 +116,15 @@ def convert_result( flow surfaces on the wire as `resultType: "input_required"` rather than being JSON-dumped into a text block. - For a tool with an output schema, structured output is returned as + For a tool with an output schema, the structured output is returned as `structuredContent`. By default it is *also* serialised into a `content` - text block, so a client that reads only `content` still sees the value -- - the spec's SHOULD. When ``mirror_structured_content`` is False the - serialised copy is omitted and `structuredContent` becomes the sole - representation; a host that routes `structuredContent` to the model itself - then no longer receives the payload twice. Because the serialised-text - guidance is a SHOULD (not a MUST), opting out stays conformant. + text block -- the spec's SHOULD, which keeps the value available to a + client that consumes `content`. When ``mirror_structured_content`` is + False that serialised copy is omitted and `structuredContent` is the only + representation on the wire, so the same payload is not sent twice. Because + the serialised-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 From cf15e714c431000a80109f3379f06545df7efdb3 Mon Sep 17 00:00:00 2001 From: olaservo Date: Thu, 23 Jul 2026 20:50:50 -0700 Subject: [PATCH 3/3] Align with repo conventions: American spelling, top-level test function Co-Authored-By: Claude Fable 5 --- src/mcp/server/mcpserver/server.py | 8 ++-- src/mcp/server/mcpserver/tools/base.py | 2 +- .../mcpserver/utilities/func_metadata.py | 6 +-- tests/server/mcpserver/test_server.py | 43 ++++++++++--------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index f7d3864074..86d004ff34 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -593,10 +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 serialised into a + 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 serialised copy on the wire. + duplicate serialized copy on the wire. """ self._tool_manager.add_tool( fn, @@ -649,10 +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 serialised into a + 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 serialised copy on the wire. + duplicate serialized copy on the wire. Example: ```python diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index ef3395e3c5..316adafb71 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -51,7 +51,7 @@ class Tool(BaseModel): 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 serialised text content block", + description="Whether structured output is also mirrored into a serialized text content block", ) @cached_property diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 6b2a423cf7..73bb425cc9 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -117,12 +117,12 @@ def convert_result( being JSON-dumped into a text block. For a tool with an output schema, the structured output is returned as - `structuredContent`. By default it is *also* serialised into a `content` + `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 serialised copy is omitted and `structuredContent` is the only + 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 serialised-text guidance is a SHOULD (not a MUST), opting out stays + 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. """ diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 8d6e6c78a3..594de62894 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -537,27 +537,6 @@ def get_user(user_id: int) -> UserOutput: assert isinstance(result.content[0], TextContent) assert '"name": "John Doe"' in result.content[0].text - async def test_tool_mirror_structured_content_false_omits_text_block(self): - """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 serialised text mirror is suppressed; structuredContent is the sole representation. - assert result.content == [] - async def test_tool_structured_output_primitive(self): """Test tool with structured output returning primitive type""" @@ -2366,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 == []