Skip to content

Commit 6cdf09e

Browse files
olaservoclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 837ef90 commit 6cdf09e

6 files changed

Lines changed: 81 additions & 14 deletions

File tree

docs/servers/structured-output.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,11 +234,25 @@ There is one way to end up unstructured without asking for it: return a class th
234234
Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the
235235
application can see but the model can't)? That's **[The low-level Server](../advanced/low-level-server.md)**.
236236

237+
## Skip the text copy
238+
239+
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.
240+
241+
Pass `mirror_structured_content=False` to return `structured_content` only, with empty `content`:
242+
243+
```python
244+
@mcp.tool(mirror_structured_content=False)
245+
def list_accounts(segment: str) -> list[Account]:
246+
return query_accounts(segment) # only structured_content is sent
247+
```
248+
249+
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.
250+
237251
## Recap
238252

239253
* The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`.
240254
* 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.
241-
* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application).
255+
* 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.
242256
* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
243257
* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it.
244258

src/mcp/server/mcpserver/server.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,7 @@ def add_tool(
574574
icons: list[Icon] | None = None,
575575
meta: dict[str, Any] | None = None,
576576
structured_output: bool | None = None,
577+
mirror_structured_content: bool = True,
577578
) -> None:
578579
"""Add a tool to the server.
579580
@@ -592,6 +593,10 @@ def add_tool(
592593
- If None, auto-detects based on the function's return type annotation
593594
- If True, creates a structured tool (return type annotation permitting)
594595
- If False, unconditionally creates an unstructured tool
596+
mirror_structured_content: Whether structured output is also serialised into a
597+
`content` text block (the spec's SHOULD, default True). Set False to return
598+
`structuredContent` only -- useful when the host routes structured content to
599+
the model itself and the serialised copy would double the payload.
595600
"""
596601
self._tool_manager.add_tool(
597602
fn,
@@ -602,6 +607,7 @@ def add_tool(
602607
icons=icons,
603608
meta=meta,
604609
structured_output=structured_output,
610+
mirror_structured_content=mirror_structured_content,
605611
)
606612

607613
def remove_tool(self, name: str) -> None:
@@ -624,6 +630,7 @@ def tool(
624630
icons: list[Icon] | None = None,
625631
meta: dict[str, Any] | None = None,
626632
structured_output: bool | None = None,
633+
mirror_structured_content: bool = True,
627634
) -> Callable[[_CallableT], _CallableT]:
628635
"""Decorator to register a tool.
629636
@@ -642,6 +649,10 @@ def tool(
642649
- If None, auto-detects based on the function's return type annotation
643650
- If True, creates a structured tool (return type annotation permitting)
644651
- If False, unconditionally creates an unstructured tool
652+
mirror_structured_content: Whether structured output is also serialised into a
653+
`content` text block (the spec's SHOULD, default True). Set False to return
654+
`structuredContent` only -- useful when the host routes structured content to
655+
the model itself and the serialised copy would double the payload.
645656
646657
Example:
647658
```python
@@ -680,6 +691,7 @@ def decorator(fn: _CallableT) -> _CallableT:
680691
icons=icons,
681692
meta=meta,
682693
structured_output=structured_output,
694+
mirror_structured_content=mirror_structured_content,
683695
)
684696
return fn
685697

src/mcp/server/mcpserver/tools/base.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ class Tool(BaseModel):
4949
annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool")
5050
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool")
5151
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool")
52+
mirror_structured_content: bool = Field(
53+
default=True,
54+
description="Whether structured output is also mirrored into a serialised text content block",
55+
)
5256

5357
@cached_property
5458
def output_schema(self) -> dict[str, Any] | None:
@@ -66,6 +70,7 @@ def from_function(
6670
icons: list[Icon] | None = None,
6771
meta: dict[str, Any] | None = None,
6872
structured_output: bool | None = None,
73+
mirror_structured_content: bool = True,
6974
) -> Tool:
7075
"""Create a Tool from a function."""
7176
func_name = name or fn.__name__
@@ -118,6 +123,7 @@ def from_function(
118123
annotations=annotations,
119124
icons=icons,
120125
meta=meta,
126+
mirror_structured_content=mirror_structured_content,
121127
)
122128

123129
async def run(
@@ -146,7 +152,13 @@ async def run(
146152
if isinstance(resolved, InputRequiredResult):
147153
# A resolver still needs client input (>= 2026-07-28): surface the
148154
# batched questions instead of running the tool body this round.
149-
return self.fn_metadata.convert_result(resolved) if convert_result else resolved
155+
return (
156+
self.fn_metadata.convert_result(
157+
resolved, mirror_structured_content=self.mirror_structured_content
158+
)
159+
if convert_result
160+
else resolved
161+
)
150162
pass_directly |= resolved
151163

152164
result = await self.fn_metadata.call_fn_with_arg_validation(
@@ -167,7 +179,9 @@ async def run(
167179
)
168180

169181
if convert_result:
170-
result = self.fn_metadata.convert_result(result)
182+
result = self.fn_metadata.convert_result(
183+
result, mirror_structured_content=self.mirror_structured_content
184+
)
171185

172186
return result
173187
except MCPError:

src/mcp/server/mcpserver/tools/tool_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def add_tool(
4646
icons: list[Icon] | None = None,
4747
meta: dict[str, Any] | None = None,
4848
structured_output: bool | None = None,
49+
mirror_structured_content: bool = True,
4950
) -> Tool:
5051
"""Add a tool to the server."""
5152
tool = Tool.from_function(
@@ -57,6 +58,7 @@ def add_tool(
5758
icons=icons,
5859
meta=meta,
5960
structured_output=structured_output,
61+
mirror_structured_content=mirror_structured_content,
6062
)
6163
existing = self._tools.get(tool.name)
6264
if existing:

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,23 @@ async def call_fn_with_arg_validation(
107107
else:
108108
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict))
109109

110-
def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
110+
def convert_result(
111+
self, result: Any, *, mirror_structured_content: bool = True
112+
) -> CallToolResult | InputRequiredResult:
111113
"""Convert a function call result into a `CallToolResult`.
112114
113115
An `InputRequiredResult` is passed through unchanged so the multi-round
114116
flow surfaces on the wire as `resultType: "input_required"` rather than
115117
being JSON-dumped into a text block.
116118
117-
Note: we build unstructured content here **even though the lowlevel server
118-
tool call handler provides generic backwards compatibility serialization of
119-
structured content**. This is for MCPServer backwards compatibility: we need to
120-
retain MCPServer's ad hoc conversion logic for constructing unstructured output
121-
from function return values, whereas the lowlevel server simply serializes
122-
the structured output.
119+
For a tool with an output schema, structured output is returned as
120+
`structuredContent`. By default it is *also* serialised into a `content`
121+
text block, so a client that reads only `content` still sees the value --
122+
the spec's SHOULD. When ``mirror_structured_content`` is False the
123+
serialised copy is omitted and `structuredContent` becomes the sole
124+
representation; a host that routes `structuredContent` to the model itself
125+
then no longer receives the payload twice. Because the serialised-text
126+
guidance is a SHOULD (not a MUST), opting out stays conformant.
123127
"""
124128
if isinstance(result, InputRequiredResult):
125129
return result
@@ -129,10 +133,10 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
129133
self.output_model.model_validate(result.structured_content)
130134
return result
131135

132-
unstructured_content = _convert_to_content(result)
133-
134136
if self.output_schema is None:
135-
return CallToolResult(content=unstructured_content)
137+
return CallToolResult(content=_convert_to_content(result))
138+
139+
content: list[ContentBlock] = _convert_to_content(result) if mirror_structured_content else []
136140

137141
if self.wrap_output:
138142
result = {"result": result}
@@ -141,7 +145,7 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
141145
validated = self.output_model.model_validate(result)
142146
structured_content = validated.model_dump(mode="json", by_alias=True)
143147

144-
return CallToolResult(content=unstructured_content, structured_content=structured_content)
148+
return CallToolResult(content=content, structured_content=structured_content)
145149

146150
def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
147151
"""Pre-parse data from JSON.

tests/server/mcpserver/test_server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,27 @@ def get_user(user_id: int) -> UserOutput:
537537
assert isinstance(result.content[0], TextContent)
538538
assert '"name": "John Doe"' in result.content[0].text
539539

540+
async def test_tool_mirror_structured_content_false_omits_text_block(self):
541+
"""mirror_structured_content=False returns structuredContent only, with empty content."""
542+
543+
class UserOutput(BaseModel):
544+
name: str
545+
age: int
546+
547+
mcp = MCPServer()
548+
549+
@mcp.tool(mirror_structured_content=False)
550+
def get_user(user_id: int) -> UserOutput:
551+
"""Get user by ID"""
552+
return UserOutput(name="John Doe", age=30)
553+
554+
async with Client(mcp) as client:
555+
result = await client.call_tool("get_user", {"user_id": 123})
556+
assert result.is_error is False
557+
assert result.structured_content == {"name": "John Doe", "age": 30}
558+
# The serialised text mirror is suppressed; structuredContent is the sole representation.
559+
assert result.content == []
560+
540561
async def test_tool_structured_output_primitive(self):
541562
"""Test tool with structured output returning primitive type"""
542563

0 commit comments

Comments
 (0)