Skip to content

Commit fb443cc

Browse files
authored
MCPServer: content-block returns are unstructured, prompt messages take Image/Audio (#3320)
1 parent 37b3cb1 commit fb443cc

12 files changed

Lines changed: 290 additions & 56 deletions

File tree

docs/servers/structured-output.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ No `output_schema`, no wrapping, no validation. `structured_content` is `None` a
208208

209209
The opposite, `structured_output=True`, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text.
210210

211+
## Content blocks and media
212+
213+
Content blocks and media (`TextContent`, `EmbeddedResource`, `Image`, `Audio` and friends, on their own, as the items of a `list`, `tuple` or `Sequence`, or as the arms of a union) are opted out for you: they are for the model to read, so auto-detection derives no schema from them (**[Images, audio & icons](media.md)** covers `Image` and `Audio`). `structured_output=True` still forces one for the content-block classes.
214+
211215
## A class without type hints
212216

213217
There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**.
@@ -240,6 +244,6 @@ There is one way to end up unstructured without asking for it: return a class th
240244
* 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.
241245
* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application).
242246
* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
243-
* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it.
247+
* `structured_output=False` opts a tool out. Content blocks, `Image` and `Audio` opt out by default; a class without type hints opts out silently, so watch for it.
244248

245249
You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**.

src/mcp/client/client.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -946,5 +946,4 @@ async def list_tools(
946946
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
947947
async def send_roots_list_changed(self) -> None:
948948
"""Send a notification that the roots list has changed."""
949-
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
950949
await self.session.send_roots_list_changed() # pyright: ignore[reportDeprecated]

src/mcp/server/mcpserver/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414

1515
from .context import Context
16+
from .prompts.base import AssistantMessage, Message, UserMessage
1617
from .resolve import (
1718
AcceptedElicitation,
1819
CancelledElicitation,
@@ -32,6 +33,9 @@
3233
"Context",
3334
"Image",
3435
"Audio",
36+
"Message",
37+
"UserMessage",
38+
"AssistantMessage",
3539
"Icon",
3640
"Resolve",
3741
"Elicit",

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

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import functools
66
from collections.abc import Awaitable, Callable, Sequence
7-
from typing import TYPE_CHECKING, Any, Literal
7+
from typing import TYPE_CHECKING, Annotated, Any, Literal
88

99
import anyio.to_thread
1010
import pydantic_core
@@ -13,6 +13,7 @@
1313

1414
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1515
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
16+
from mcp.server.mcpserver.utilities.types import Audio, Image
1617
from mcp.shared._callable_inspection import is_async_callable
1718
from mcp.shared.exceptions import MCPError
1819

@@ -22,14 +23,26 @@
2223

2324

2425
class Message(BaseModel):
25-
"""Base class for all prompt messages."""
26+
"""Base class for all prompt messages.
27+
28+
`content` may be a plain string (wrapped in `TextContent`), an `Image` or `Audio`
29+
helper (converted to `ImageContent` / `AudioContent`, reading the file for path-backed
30+
helpers), or any ready-made content block.
31+
32+
Raises:
33+
OSError: If a path-backed `Image` or `Audio` cannot be read.
34+
"""
2635

2736
role: Literal["user", "assistant"]
2837
content: ContentBlock
2938

30-
def __init__(self, content: str | ContentBlock, **kwargs: Any):
39+
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
3140
if isinstance(content, str):
3241
content = TextContent(type="text", text=content)
42+
elif isinstance(content, Image):
43+
content = content.to_image_content()
44+
elif isinstance(content, Audio):
45+
content = content.to_audio_content()
3346
super().__init__(content=content, **kwargs)
3447

3548

@@ -38,7 +51,7 @@ class UserMessage(Message):
3851

3952
role: Literal["user", "assistant"] = "user"
4053

41-
def __init__(self, content: str | ContentBlock, **kwargs: Any):
54+
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
4255
super().__init__(content=content, **kwargs)
4356

4457

@@ -47,13 +60,18 @@ class AssistantMessage(Message):
4760

4861
role: Literal["user", "assistant"] = "assistant"
4962

50-
def __init__(self, content: str | ContentBlock, **kwargs: Any):
63+
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
5164
super().__init__(content=content, **kwargs)
5265

5366

54-
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
67+
# Both classes accept either role, so the first arm always matches: validate left to right rather than
68+
# trying both (which converted - and for path-backed Image/Audio, read - the content twice).
69+
message_validator: TypeAdapter[UserMessage | AssistantMessage] = TypeAdapter(
70+
Annotated[UserMessage | AssistantMessage, Field(union_mode="left_to_right")]
71+
)
5572

56-
SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]]
73+
_PromptResultItem = str | ContentBlock | Image | Audio | Message | dict[str, Any]
74+
SyncPromptResult = _PromptResultItem | InputRequiredResult | Sequence[_PromptResultItem]
5775
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
5876

5977

@@ -89,7 +107,7 @@ def from_function(
89107
"""Create a Prompt from a function.
90108
91109
The function can return:
92-
- A string (converted to a message)
110+
- A string, content block, `Image` or `Audio` (each becomes a user message)
93111
- A Message object
94112
- A dict (converted to a message)
95113
- A sequence of any of the above
@@ -105,10 +123,9 @@ def from_function(
105123
if context_kwarg is None: # pragma: no branch
106124
context_kwarg = find_context_parameter(fn)
107125

108-
# Get schema from func_metadata, excluding context parameter
126+
# Only the argument model is needed; a prompt has no output schema to derive
109127
func_arg_metadata = func_metadata(
110-
fn,
111-
skip_names=[context_kwarg] if context_kwarg is not None else [],
128+
fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
112129
)
113130
parameters = func_arg_metadata.arg_model.model_json_schema()
114131

@@ -179,19 +196,15 @@ async def render(
179196
# Convert result to messages
180197
messages: list[Message] = []
181198
for msg in result: # type: ignore[reportUnknownVariableType]
182-
try:
183-
if isinstance(msg, Message):
184-
messages.append(msg)
185-
elif isinstance(msg, dict):
186-
messages.append(message_validator.validate_python(msg))
187-
elif isinstance(msg, str):
188-
content = TextContent(type="text", text=msg)
189-
messages.append(UserMessage(content=content))
190-
else: # pragma: no cover
191-
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
192-
messages.append(Message(role="user", content=content))
193-
except Exception: # pragma: no cover
194-
raise ValueError(f"Could not convert prompt result to message: {msg}")
199+
if isinstance(msg, Message):
200+
messages.append(msg)
201+
elif isinstance(msg, dict):
202+
messages.append(message_validator.validate_python(msg))
203+
elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
204+
messages.append(UserMessage(msg))
205+
else: # pragma: no cover
206+
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
207+
messages.append(Message(role="user", content=content))
195208

196209
return messages
197210
except MCPError:

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,9 @@ def from_function(
152152
if context_kwarg is None: # pragma: no branch
153153
context_kwarg = find_context_parameter(fn)
154154

155-
# Get schema from func_metadata, excluding context parameter
155+
# Only the argument model is needed; a resource has no output schema to derive
156156
func_arg_metadata = func_metadata(
157-
fn,
158-
skip_names=[context_kwarg] if context_kwarg is not None else [],
157+
fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
159158
)
160159
parameters = func_arg_metadata.arg_model.model_json_schema()
161160

src/mcp/server/mcpserver/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -918,8 +918,8 @@ def prompt(
918918
) -> Callable[[_CallableT], _CallableT]:
919919
"""Decorator to register a prompt.
920920
921-
The function returns the prompt messages (a string, `Message`, dict,
922-
or a sequence of these), or an `InputRequiredResult` to request
921+
The function returns the prompt messages (a string, content block, `Image`/`Audio`,
922+
`Message`, dict, or a sequence of these), or an `InputRequiredResult` to request
923923
client input first (the 2026-07-28 multi-round-trip flow — read
924924
`ctx.input_responses` on the retry).
925925

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ def _is_input_required_type(obj: Any) -> bool:
3333
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)
3434

3535

36+
_CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio)
37+
# `_convert_to_content` unrolls list/tuple values; a `Sequence[...]` annotation is one of those at runtime.
38+
_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence)
39+
40+
41+
def _returns_content(annotation: Any) -> bool:
42+
"""Whether a return annotation declares content blocks or the `Image`/`Audio` helpers, bare or as
43+
the items of a list/tuple or the arms of a union: the values `_convert_to_content` renders as blocks
44+
rather than dumping as data. Keep the two in sync."""
45+
origin = get_origin(annotation)
46+
if origin is None:
47+
return isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES)
48+
if origin is Annotated:
49+
return _returns_content(get_args(annotation)[0])
50+
if is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS:
51+
return any(_returns_content(arg) for arg in get_args(annotation))
52+
return False
53+
54+
3655
class StrictJsonSchema(GenerateJsonSchema):
3756
"""A JSON schema generator that raises exceptions instead of emitting warnings.
3857
@@ -222,6 +241,9 @@ def func_metadata(
222241
- TypedDict - converted to a Pydantic model with same fields
223242
- Dataclasses and other annotated classes - converted to Pydantic models
224243
- Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field
244+
- Content blocks (TextContent, EmbeddedResource, ...), Image and Audio, bare or inside a
245+
list, tuple or union - unstructured when auto-detecting; structured_output=True bypasses
246+
this rule (a content block then publishes its own schema; Image/Audio have none and raise)
225247
226248
Returns:
227249
A FuncMetadata object containing:
@@ -345,6 +367,13 @@ def func_metadata(
345367
else:
346368
original_annotation = effective_annotation
347369

370+
if structured_output is None and _returns_content(return_type_expr):
371+
# Content blocks and the Image/Audio helpers are what the model reads, not data for the
372+
# application: a derived schema would advertise the block's own model as output_schema (and,
373+
# unless the tool builds its own CallToolResult, echo every block into structured_content).
374+
# structured_output=True still forces one.
375+
return FuncMetadata(arg_model=arguments_model)
376+
348377
output_model, output_schema, wrap_output = _try_create_model_and_schema(
349378
original_annotation, return_type_expr, func.__name__
350379
)
@@ -546,7 +575,7 @@ def _convert_to_content(result: Any) -> list[ContentBlock]:
546575
Note: This conversion logic comes from previous versions of MCPServer and is being
547576
retained for purposes of backwards compatibility. It produces different unstructured
548577
output than the lowlevel server tool call handler, which just serializes structured
549-
content verbatim.
578+
content verbatim. `_returns_content` is the annotation-level mirror of these branches.
550579
"""
551580
if result is None: # pragma: no cover
552581
return []

tests/docs_src/test_structured_output.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44
from inline_snapshot import snapshot
5-
from mcp_types import TextContent
5+
from mcp_types import EmbeddedResource, ImageContent, TextContent, TextResourceContents
66

77
from docs_src.structured_output import (
88
tutorial001,
@@ -17,6 +17,7 @@
1717
)
1818
from mcp import Client
1919
from mcp.server import MCPServer
20+
from mcp.server.mcpserver import Image
2021
from mcp.server.mcpserver.exceptions import InvalidSignature
2122

2223
# See test_index.py for why this is a per-module mark and not a conftest hook.
@@ -173,6 +174,36 @@ async def test_structured_output_false_opts_out() -> None:
173174
]
174175

175176

177+
async def test_content_blocks_and_media_are_opted_out_of_structured_output() -> None:
178+
"""The "Content blocks and media" section: a content-block or `Image`/`Audio` return annotation, bare or
179+
as list items, derives no output schema and no structured content; the blocks are the result."""
180+
mcp = MCPServer("Reports")
181+
document = EmbeddedResource(
182+
type="resource", resource=TextResourceContents(uri="report://q3", mime_type="text/markdown", text="# Q3")
183+
)
184+
185+
@mcp.tool()
186+
def report() -> EmbeddedResource:
187+
return document
188+
189+
@mcp.tool()
190+
def chart() -> list[str | Image]:
191+
return ["Sales by region:", Image(data=b"png", format="png")]
192+
193+
async with Client(mcp) as client:
194+
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
195+
assert tools["report"].output_schema is None
196+
assert tools["chart"].output_schema is None
197+
report_result = await client.call_tool("report", {})
198+
assert (report_result.content, report_result.structured_content) == ([document], None)
199+
chart_result = await client.call_tool("chart", {})
200+
assert chart_result.structured_content is None
201+
assert chart_result.content == [
202+
TextContent(type="text", text="Sales by region:"),
203+
ImageContent(type="image", data="cG5n", mime_type="image/png"),
204+
]
205+
206+
176207
async def test_class_without_type_hints_is_silently_unstructured() -> None:
177208
"""tutorial009: a class with no annotations on its body gets no schema, and the model gets a `repr`."""
178209
async with Client(tutorial009.mcp) as client:

0 commit comments

Comments
 (0)