Skip to content
Merged
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
6 changes: 5 additions & 1 deletion docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,13 @@

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.

## Content blocks and media

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.

## A class without type hints

There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**.

Check warning on line 217 in docs/servers/structured-output.md

View check run for this annotation

Claude / Claude Code Review

[quality] nit: stale claim "There is one way to end up unstructured without asking for it" now contradicts the new "Content blocks and media" section added two paragraphs above on the same page, which introduces a second default opt-out path (content-bloc

[quality] nit: stale claim "There is one way to end up unstructured without asking for it" now contradicts the new "Content blocks and media" section added two paragraphs above on the same page, which introduces a second default opt-out path (content-block/Image/Audio return annotations derive no schema). The PR updated the Recap bullet at line 247 to list both opt-outs ("Content blocks, `Image` and `Audio` opt out by default; a class without type hints opts out silently") but left the section o

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] nit: stale claim "There is one way to end up unstructured without asking for it" now contradicts the new "Content blocks and media" section added two paragraphs above on the same page, which introduces a second default opt-out path (content-block/Image/Audio return annotations derive no schema). The PR updated the Recap bullet at line 247 to list both opt-outs ("Content blocks, Image and Audio opt out by default; a class without type hints opts out silently") but left the section opener asserting the annotation-less class is the only such path.

Extended reasoning...

Concrete cost: the published structured-output page contradicts itself. A reader who lands on the "A class without type hints" section (or skims from its heading) is told the only way to get an unstructured tool without passing structured_output=False is an annotation-less class, and will not suspect that their -> EmbeddedResource / -> list[TextContent] tool also silently stopped advertising outputSchema after this release — exactly the behaviour change the PR calls out for release notes. Fix is one sentence: reword the opener (e.g. "Besides content blocks, there is one more way...") so the two sections on the same page agree.

Verification: nit — the factual basis checks out. docs/servers/structured-output.md line 217 still reads "There is one way to end up unstructured without asking for it: return a class that has no annotations on its body." — pre-existing text the PR did not touch — while the new "Content blocks and media" section added two paragraphs above (lines 211–213) introduces a second default path to an unstructur


```python title="server.py" hl_lines="6-9"
--8<-- "docs_src/structured_output/tutorial009.py"
Expand Down Expand Up @@ -240,6 +244,6 @@
* 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).
* 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.
* `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.

You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**.
1 change: 0 additions & 1 deletion src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -946,5 +946,4 @@ async def list_tools(
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed."""
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
await self.session.send_roots_list_changed() # pyright: ignore[reportDeprecated]
4 changes: 4 additions & 0 deletions src/mcp/server/mcpserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
)

from .context import Context
from .prompts.base import AssistantMessage, Message, UserMessage
Comment thread
maxisbey marked this conversation as resolved.
from .resolve import (
AcceptedElicitation,
CancelledElicitation,
Expand All @@ -32,6 +33,9 @@
"Context",
"Image",
"Audio",
"Message",
"UserMessage",
"AssistantMessage",
"Icon",
"Resolve",
"Elicit",
Expand Down
61 changes: 37 additions & 24 deletions src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import functools
from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Annotated, Any, Literal

import anyio.to_thread
import pydantic_core
Expand All @@ -13,6 +13,7 @@

from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
from mcp.server.mcpserver.utilities.types import Audio, Image
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError

Expand All @@ -22,14 +23,26 @@


class Message(BaseModel):
"""Base class for all prompt messages."""
"""Base class for all prompt messages.

`content` may be a plain string (wrapped in `TextContent`), an `Image` or `Audio`
helper (converted to `ImageContent` / `AudioContent`, reading the file for path-backed
helpers), or any ready-made content block.

Raises:
OSError: If a path-backed `Image` or `Audio` cannot be read.
"""
Comment thread
maxisbey marked this conversation as resolved.

role: Literal["user", "assistant"]
content: ContentBlock

def __init__(self, content: str | ContentBlock, **kwargs: Any):
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
Comment thread
maxisbey marked this conversation as resolved.
if isinstance(content, str):
content = TextContent(type="text", text=content)
elif isinstance(content, Image):
content = content.to_image_content()
elif isinstance(content, Audio):
content = content.to_audio_content()
Comment thread
maxisbey marked this conversation as resolved.
super().__init__(content=content, **kwargs)


Expand All @@ -38,7 +51,7 @@

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

def __init__(self, content: str | ContentBlock, **kwargs: Any):
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
super().__init__(content=content, **kwargs)


Expand All @@ -47,13 +60,18 @@

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

def __init__(self, content: str | ContentBlock, **kwargs: Any):
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
super().__init__(content=content, **kwargs)


message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
# Both classes accept either role, so the first arm always matches: validate left to right rather than
# trying both (which converted - and for path-backed Image/Audio, read - the content twice).
message_validator: TypeAdapter[UserMessage | AssistantMessage] = TypeAdapter(
Annotated[UserMessage | AssistantMessage, Field(union_mode="left_to_right")]
)
Comment thread
maxisbey marked this conversation as resolved.

SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]]
_PromptResultItem = str | ContentBlock | Image | Audio | Message | dict[str, Any]
SyncPromptResult = _PromptResultItem | InputRequiredResult | Sequence[_PromptResultItem]
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]


Expand Down Expand Up @@ -89,7 +107,7 @@
"""Create a Prompt from a function.

The function can return:
- A string (converted to a message)
- A string, content block, `Image` or `Audio` (each becomes a user message)
Comment thread
maxisbey marked this conversation as resolved.
- A Message object
- A dict (converted to a message)
- A sequence of any of the above
Expand All @@ -105,10 +123,9 @@
if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn)

# Get schema from func_metadata, excluding context parameter
# Only the argument model is needed; a prompt has no output schema to derive
func_arg_metadata = func_metadata(
fn,
skip_names=[context_kwarg] if context_kwarg is not None else [],
fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
)
parameters = func_arg_metadata.arg_model.model_json_schema()

Expand Down Expand Up @@ -179,19 +196,15 @@
# Convert result to messages
messages: list[Message] = []
for msg in result: # type: ignore[reportUnknownVariableType]
try:
if isinstance(msg, Message):
messages.append(msg)
elif isinstance(msg, dict):
messages.append(message_validator.validate_python(msg))
elif isinstance(msg, str):
content = TextContent(type="text", text=msg)
messages.append(UserMessage(content=content))
else: # pragma: no cover
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
messages.append(Message(role="user", content=content))
except Exception: # pragma: no cover
raise ValueError(f"Could not convert prompt result to message: {msg}")
if isinstance(msg, Message):
messages.append(msg)
elif isinstance(msg, dict):
messages.append(message_validator.validate_python(msg))
elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
messages.append(UserMessage(msg))
else: # pragma: no cover

Check warning on line 205 in src/mcp/server/mcpserver/prompts/base.py

View check run for this annotation

Claude / Claude Code Review

[quality] Rewritten render() conversion loop re-adds `else: # pragma: no cover` on the JSON-dump fallback — the only pragma line the AGENTS.md diff audit flags — even though the branch is trivially testable and the PR presents this fallback as retain

[quality] Rewritten render() conversion loop re-adds `else: # pragma: no cover` on the JSON-dump fallback — the only pragma line the AGENTS.md diff audit flags — even though the branch is trivially testable and the PR presents this fallback as retained, documented behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] Rewritten render() conversion loop re-adds else: # pragma: no cover on the JSON-dump fallback — the only pragma line the AGENTS.md diff audit flags — even though the branch is trivially testable and the PR presents this fallback as retained, documented behavior.

Extended reasoning...

Concrete cost: a documented library-code behavior (a prompt returning a non-content value such as an int or a BaseModel is JSON-dumped via pydantic_core.to_json into a user text message, src/mcp/server/mcpserver/prompts/base.py:205-207) stays permanently excluded from the repo's 100%-coverage gate. CLAUDE.md -> AGENTS.md states 'Avoid adding new # pragma: no cover ... comments' and 'In library code (src/), a # pragma: no cover needs very good reasoning — it usually means a test is missing', and its audit command git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' flags exactly this added line. The diff already deleted the sibling except Exception: # pragma: no cover and added tests for every other branch of the loop (Message, dict, str, ContentBlock, Image, Audio); a one-line test returning e.g. 42 from a prompt would cover this branch and let the pragma be removed, keeping the fallback's behavior (and any future regression in it) actually pinned.

Verification: nit — src/mcp/server/mcpserver/prompts/base.py:205 in the rewritten loop reads else: # pragma: no cover followed by the JSON-dump fallback (pydantic_core.to_json(msg, fallback=str, indent=2)), and running the AGENTS.md audit (git diff <base>..HEAD | grep -E '^\+.*(pragma|type: ignore|noqa)') flags exactly this line as an added pragma. The branch is trivially testable: validate_call (line

content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
messages.append(Message(role="user", content=content))

Check notice on line 207 in src/mcp/server/mcpserver/prompts/base.py

View check run for this annotation

Claude / Claude Code Review

Pre-existing, surfaced by this rewrite: a prompt function returning the wire type mcp_types.PromptMessage (the type GetPromptResult actually carries, and what lowlevel-server prompt handlers return) falls through the rewritten conversion loop to the JSON-

Pre-existing, surfaced by this rewrite: a prompt function returning the wire type mcp_types.PromptMessage (the type GetPromptResult actually carries, and what lowlevel-server prompt handlers return) falls through the rewritten conversion loop to the JSON-dump fallback: it is not a mcpserver Message, not a dict, and not in the new `str | ContentBlock | Image | Audio` arm, so `pydantic_core.to_json(msg)` stringifies it and `Message(role="user", content=...)` hardcodes the role. The loop was just w
Comment on lines +205 to +207

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing, surfaced by this rewrite: a prompt function returning the wire type mcp_types.PromptMessage (the type GetPromptResult actually carries, and what lowlevel-server prompt handlers return) falls through the rewritten conversion loop to the JSON-dump fallback: it is not a mcpserver Message, not a dict, and not in the new str | ContentBlock | Image | Audio arm, so pydantic_core.to_json(msg) stringifies it and Message(role="user", content=...) hardcodes the role. The loop was just widened to accept "bare content the way a tool does" (content blocks, Image, Audio) but omits PromptMessage, whose .role/.content map 1:1 onto Message.

Extended reasoning...

A user migrating a lowlevel-server prompt handler (or following the spec's vocabulary) writes @ mcp.prompt()\ndef p(): return PromptMessage(role="assistant", content=TextContent(type="text", text="hi")). Instead of an assistant message with a text block, the client silently receives a USER-role message whose text is the JSON dump '{"role": "assistant", "content": {"type": "text", ...}}' — the declared assistant role is dropped and the content arrives as a JSON string blob. No error is raised, so the corruption goes unnoticed until the LLM output looks wrong. One elif isinstance(msg, PromptMessage): messages.append(Message(role=msg.role, content=msg.content)) arm (or including PromptMessage in the widened union) fixes it.

Verification: pre-existing — behavior predates this PR (the old loop also JSON-dumped anything that wasn't str/Message/dict), but the PR rewrites this exact conversion loop and widens the accepted types, so the gap is squarely in reviewed code. The claim checks out line by line in /home/claude/python-sdk/src/mcp/server/mcpserver/prompts/base.py: the loop at lines 198-207 handles isinstance(msg, Message) (the


return messages
except MCPError:
Expand Down
5 changes: 2 additions & 3 deletions src/mcp/server/mcpserver/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,9 @@ def from_function(
if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn)

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

Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -918,8 +918,8 @@ def prompt(
) -> Callable[[_CallableT], _CallableT]:
"""Decorator to register a prompt.
The function returns the prompt messages (a string, `Message`, dict,
or a sequence of these), or an `InputRequiredResult` to request
The function returns the prompt messages (a string, content block, `Image`/`Audio`,
`Message`, dict, or a sequence of these), or an `InputRequiredResult` to request
client input first (the 2026-07-28 multi-round-trip flow — read
`ctx.input_responses` on the retry).
Expand Down
31 changes: 30 additions & 1 deletion src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)


_CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio)
# `_convert_to_content` unrolls list/tuple values; a `Sequence[...]` annotation is one of those at runtime.
_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence)


def _returns_content(annotation: Any) -> bool:
"""Whether a return annotation declares content blocks or the `Image`/`Audio` helpers, bare or as
the items of a list/tuple or the arms of a union: the values `_convert_to_content` renders as blocks
rather than dumping as data. Keep the two in sync."""
origin = get_origin(annotation)
if origin is None:
return isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES)
if origin is Annotated:
return _returns_content(get_args(annotation)[0])
if is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS:
return any(_returns_content(arg) for arg in get_args(annotation))
return False


class StrictJsonSchema(GenerateJsonSchema):
"""A JSON schema generator that raises exceptions instead of emitting warnings.

Expand Down Expand Up @@ -222,6 +241,9 @@
- TypedDict - converted to a Pydantic model with same fields
- Dataclasses and other annotated classes - converted to Pydantic models
- Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field
- Content blocks (TextContent, EmbeddedResource, ...), Image and Audio, bare or inside a
list, tuple or union - unstructured when auto-detecting; structured_output=True bypasses
this rule (a content block then publishes its own schema; Image/Audio have none and raise)

Check warning on line 246 in src/mcp/server/mcpserver/utilities/func_metadata.py

View check run for this annotation

Claude / Claude Code Review

[quality] nit: in-code docs for the new content rule omit `Sequence`: the `structured_output` docstring bullet (lines 244-246, "bare or inside a list, tuple or union") and `_returns_content`'s own docstring (lines 42-43, "items of a list/tuple") both unde

[quality] nit: in-code docs for the new content rule omit `Sequence`: the `structured_output` docstring bullet (lines 244-246, "bare or inside a list, tuple or union") and `_returns_content`'s own docstring (lines 42-43, "items of a list/tuple") both understate `_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence)` on line 38. The review round fixed only docs/servers/structured-output.md (which now correctly says "list, tuple or Sequence"); the docstrings shipped in this same diff were not swept.
Comment on lines +244 to +246

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] nit: in-code docs for the new content rule omit Sequence: the structured_output docstring bullet (lines 244-246, "bare or inside a list, tuple or union") and _returns_content's own docstring (lines 42-43, "items of a list/tuple") both understate _CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence) on line 38. The review round fixed only docs/servers/structured-output.md (which now correctly says "list, tuple or Sequence"); the docstrings shipped in this same diff were not swept.

Extended reasoning...

Concrete cost: the func_metadata docstring is the reference for the public structured_output parameter, and _returns_content's docstring explicitly instructs "Keep the two in sync" while itself being out of sync with the constant one line above it. A maintainer or user reading either docstring concludes -> Sequence[TextContent] derives a schema when it is actually opted out, and a future edit guided by the docstring (e.g. tightening the origins to list/tuple to "match the docs") would silently change advertised outputSchema for existing Sequence-annotated tools. Fix: mention Sequence in both docstrings, matching the already-corrected wording in docs/servers/structured-output.md.

Verification: nit — the claim is factually accurate. In /home/claude/python-sdk/src/mcp/server/mcpserver/utilities/func_metadata.py, line 38 defines _CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence) and line 50 uses it, so -> Sequence[TextContent] is opted out of structured output. Yet both in-code docs added by this diff omit Sequence: the _returns_content docstring (lines 42-44) says "bare or as th


Returns:
A FuncMetadata object containing:
Expand Down Expand Up @@ -345,6 +367,13 @@
else:
original_annotation = effective_annotation

if structured_output is None and _returns_content(return_type_expr):
# Content blocks and the Image/Audio helpers are what the model reads, not data for the
# application: a derived schema would advertise the block's own model as output_schema (and,
# unless the tool builds its own CallToolResult, echo every block into structured_content).
# structured_output=True still forces one.
return FuncMetadata(arg_model=arguments_model)

output_model, output_schema, wrap_output = _try_create_model_and_schema(
Comment thread
maxisbey marked this conversation as resolved.
original_annotation, return_type_expr, func.__name__
)
Expand Down Expand Up @@ -546,7 +575,7 @@
Note: This conversion logic comes from previous versions of MCPServer and is being
retained for purposes of backwards compatibility. It produces different unstructured
output than the lowlevel server tool call handler, which just serializes structured
content verbatim.
content verbatim. `_returns_content` is the annotation-level mirror of these branches.
"""
if result is None: # pragma: no cover
return []
Expand Down
33 changes: 32 additions & 1 deletion tests/docs_src/test_structured_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent
from mcp_types import EmbeddedResource, ImageContent, TextContent, TextResourceContents

from docs_src.structured_output import (
tutorial001,
Expand All @@ -17,6 +17,7 @@
)
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver import Image
from mcp.server.mcpserver.exceptions import InvalidSignature

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


async def test_content_blocks_and_media_are_opted_out_of_structured_output() -> None:
"""The "Content blocks and media" section: a content-block or `Image`/`Audio` return annotation, bare or
as list items, derives no output schema and no structured content; the blocks are the result."""
mcp = MCPServer("Reports")
document = EmbeddedResource(
type="resource", resource=TextResourceContents(uri="report://q3", mime_type="text/markdown", text="# Q3")
)

@mcp.tool()
def report() -> EmbeddedResource:
return document

@mcp.tool()
def chart() -> list[str | Image]:
return ["Sales by region:", Image(data=b"png", format="png")]

async with Client(mcp) as client:
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
assert tools["report"].output_schema is None
assert tools["chart"].output_schema is None
report_result = await client.call_tool("report", {})
assert (report_result.content, report_result.structured_content) == ([document], None)
chart_result = await client.call_tool("chart", {})
assert chart_result.structured_content is None
assert chart_result.content == [
TextContent(type="text", text="Sales by region:"),
ImageContent(type="image", data="cG5n", mime_type="image/png"),
]


async def test_class_without_type_hints_is_silently_unstructured() -> None:
"""tutorial009: a class with no annotations on its body gets no schema, and the model gets a `repr`."""
async with Client(tutorial009.mcp) as client:
Expand Down
Loading
Loading