Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 16 additions & 2 deletions src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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__
Expand Down Expand Up @@ -118,6 +123,7 @@ def from_function(
annotations=annotations,
icons=icons,
meta=meta,
mirror_structured_content=mirror_structured_content,
)

async def run(
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/server/mcpserver/tools/tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down
27 changes: 16 additions & 11 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Loading