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

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 or as the items of a `list` or `tuple` or 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.

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

View check run for this annotation

Claude / Claude Code Review

[quality] nit: new "Content blocks and media" section says the opt-out applies "on their own or as the items of a `list` or `tuple` or the arms of a union", but `_CONTENT_SEQUENCE_ORIGINS` in src/mcp/server/mcpserver/utilities/func_metadata.py:38 also mat

[quality] nit: new "Content blocks and media" section says the opt-out applies "on their own or as the items of a `list` or `tuple` or the arms of a union", but `_CONTENT_SEQUENCE_ORIGINS` in src/mcp/server/mcpserver/utilities/func_metadata.py:38 also matches `Sequence[...]` annotations, which the docs omit.

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: new "Content blocks and media" section says the opt-out applies "on their own or as the items of a list or tuple or the arms of a union", but _CONTENT_SEQUENCE_ORIGINS in src/mcp/server/mcpserver/utilities/func_metadata.py:38 also matches Sequence[...] annotations, which the docs omit.

Extended reasoning...

Concrete cost: the published docs page is inaccurate about the rule it documents — a user annotating a tool -> Sequence[TextContent] reads the page and expects an output schema and structured_content, but auto-detection silently opts the tool out; one word ("or Sequence") in the sentence fixes it.

Verification: nit — the claim is factually accurate. docs/servers/structured-output.md:213 (new "Content blocks and media" section) says the opt-out applies to content types "on their own or as the items of a list or tuple or the arms of a union", but src/mcp/server/mcpserver/utilities/func_metadata.py:38 defines _CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence) and line 50 (`if is_union_origin(orig


## 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**.
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
32 changes: 20 additions & 12 deletions src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,22 @@


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`), or any ready-made content block.
"""

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 +47,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 +56,14 @@

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)

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 +99,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 +115,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 @@ -184,9 +193,8 @@
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))
elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
messages.append(UserMessage(msg))

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

View check run for this annotation

Claude / Claude Code Review

[quality] nit: Prompt.render's new message-conversion branch performs synchronous file I/O on the event loop: `UserMessage(msg)` for a returned `Image(path=...)`/`Audio(path=...)` calls `to_image_content()`/`to_audio_content()` (src/mcp/server/mcpserver/u

[quality] nit: Prompt.render's new message-conversion branch performs synchronous file I/O on the event loop: `UserMessage(msg)` for a returned `Image(path=...)`/`Audio(path=...)` calls `to_image_content()`/`to_audio_content()` (src/mcp/server/mcpserver/utilities/types.py:44-54, 91-101), which `open()`s and reads the whole media file plus base64-encodes it, inside the async render loop — immediately after the same method deliberately offloads the sync prompt function to a worker thread via `an
Comment on lines 193 to +197

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: Prompt.render's new message-conversion branch performs synchronous file I/O on the event loop: UserMessage(msg) for a returned Image(path=...)/Audio(path=...) calls to_image_content()/to_audio_content() (src/mcp/server/mcpserver/utilities/types.py:44-54, 91-101), which open()s and reads the whole media file plus base64-encodes it, inside the async render loop — immediately after the same method deliberately offloads the sync prompt function to a worker thread via anyio.to_thread.run_sync (line 179) precisely to keep blocking user work off the loop.

Extended reasoning...

Concrete cost: a prompt that returns Image("/data/big-diagram.png") (the path form the Image helper exists for, and which this PR newly makes a supported prompt return value) has the multi-megabyte file read and base64-encoded on the server's event loop thread inside Message.init, stalling every concurrent request/notification on that server for the duration of the read. The conversion of path-backed helpers should happen inside the existing thread offload (e.g. convert returned Image/Audio to content blocks before returning from the threaded call, or via anyio.to_thread), matching the care already taken for the prompt function itself. The identical blocking read pre-exists in _convert_to_content for tools, so a shared fix covers both.

Verification: nit — the factual claim checks out. In Prompt.render (src/mcp/server/mcpserver/prompts/base.py), a sync prompt function is deliberately offloaded at line 179 (result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))), but the new conversion branch at line 196-197 (elif isinstance(msg, str | ContentBlock | Image | Audio): messages.append(UserMessage(msg))) then runs on

else: # pragma: no cover
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
messages.append(Message(role="user", content=content))
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 @@ def _is_input_required_type(obj: Any) -> bool:
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 @@ def func_metadata(
- 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)

Returns:
A FuncMetadata object containing:
Expand Down Expand Up @@ -345,6 +367,13 @@ def func_metadata(
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(

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: with structured_output=True, a return annotation like list[Image] or tuple[Audio, ...] makes _try_create_model_and_schema raise a raw PydanticSchemaGenerationError (wrapper model built outside the try) instead of the intended InvalidSignature; the new content-type rule only shields the structured_output=None path, and the new docstring at line 237 says such annotations "raise" without noting it is an unhandled pydantic internal error.

Extended reasoning...

A user who reads the new docs/docstring and passes structured_output=True to force a schema on def f() -> list[Image] gets an opaque PydanticSchemaGenerationError traceback at tool-registration time instead of the SDK's InvalidSignature error naming the function and return type, making the misconfiguration hard to diagnose.

Verification: pre-existing — the defective code (wrapper model built outside the try) predates this PR, but the diff interacts with it directly: the new guard at src/mcp/server/mcpserver/utilities/func_metadata.py:361 (if structured_output is None and _contains_content_type(return_type_expr):) shields only the auto-detect path, and the new docstring lines 235-237 explicitly advertise the override ("structure

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed and pre-existing: the wrapper model is built outside the try in _try_create_model_and_schema, so containers of unschematizable types surface a raw PydanticSchemaGenerationError instead of InvalidSignature/fallback. Listed as a follow-up in the PR body; it deserves its own small PR since it changes which annotations register at all.

original_annotation, return_type_expr, func.__name__
)
Expand Down Expand Up @@ -546,7 +575,7 @@ def _convert_to_content(result: Any) -> list[ContentBlock]:
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
77 changes: 75 additions & 2 deletions tests/server/mcpserver/prompts/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@

import pytest
from mcp_types import (
AudioContent,
ElicitRequest,
ElicitRequestFormParams,
EmbeddedResource,
ImageContent,
InputRequiredResult,
TextContent,
TextResourceContents,
)

from mcp.server.mcpserver import Context
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
from mcp.server.mcpserver import AssistantMessage, Audio, Context, Image, MCPServer, Message, UserMessage
from mcp.server.mcpserver.prompts.base import Prompt


class TestRenderPrompt:
Expand Down Expand Up @@ -243,3 +245,74 @@ def asking_prompt() -> InputRequiredResult:
prompt = Prompt.from_function(asking_prompt)
result = await prompt.render(None, Context())
assert result is sentinel


@pytest.mark.parametrize(
("helper", "expected"),
[
(Image(data=b"img", format="png"), ImageContent(type="image", data="aW1n", mime_type="image/png")),
(Audio(data=b"snd", format="wav"), AudioContent(type="audio", data="c25k", mime_type="audio/wav")),
],
)
def test_message_converts_image_and_audio_helpers_to_content_blocks(
helper: Image | Audio, expected: ImageContent | AudioContent
) -> None:
"""SDK-defined: prompt messages accept the same `Image`/`Audio` helpers tools return."""
assert UserMessage(helper).content == expected
assert AssistantMessage(content=helper).content == expected


@pytest.mark.anyio
async def test_prompt_dict_result_accepts_image_helper_as_content() -> None:
"""SDK-defined: the dict form is validated through `Message.__init__`, so helpers convert there too."""

def fn() -> dict[str, Any]:
return {"role": "user", "content": Image(data=b"img", format="png")}

assert await Prompt.from_function(fn).render(None, Context()) == [
UserMessage(ImageContent(type="image", data="aW1n", mime_type="image/png"))
]


class _Slide:
"""A plain class pydantic cannot build a schema for."""


@pytest.mark.anyio
async def test_prompt_return_annotation_is_not_run_through_tool_output_schema_derivation() -> None:
"""SDK-defined: a prompt only needs its argument model, so an unschematizable return annotation
registers (it used to raise from the tool structured-output machinery)."""
mcp = MCPServer()

@mcp.prompt()
def deck(topic: str) -> list[_Slide]:
raise NotImplementedError

[listed] = await mcp.list_prompts()
assert [arg.name for arg in listed.arguments or []] == ["topic"]


_PNG = Image(data=b"img", format="png")
_PNG_BLOCK = ImageContent(type="image", data="aW1n", mime_type="image/png")
_DOC = EmbeddedResource(
type="resource", resource=TextResourceContents(uri="file://notes.md", text="notes", mime_type="text/markdown")
)


@pytest.mark.anyio
@pytest.mark.parametrize(
("returned", "expected"),
[
(_PNG, [UserMessage(_PNG_BLOCK)]),
(_DOC, [UserMessage(_DOC)]),
(["Look at this:", _PNG], [UserMessage("Look at this:"), UserMessage(_PNG_BLOCK)]),
],
)
async def test_bare_content_returned_from_a_prompt_becomes_user_messages(returned: Any, expected: list[Message]):
"""SDK-defined: what a tool may return bare (a content block, `Image`, `Audio`), a prompt may too;
each item becomes one user message instead of being JSON-dumped into text."""

def fn() -> Any:
return returned

assert await Prompt.from_function(fn).render(None, Context()) == expected
15 changes: 15 additions & 0 deletions tests/server/mcpserver/resources/test_resource_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,3 +505,18 @@ def ask(topic: str) -> InputRequiredResult:
template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}")
result = await template.create_resource("ask://databases", {"topic": "databases"}, Context())
assert result is sentinel


class _Chart:
"""A plain class pydantic cannot build a schema for."""


def test_template_return_annotation_is_not_run_through_tool_output_schema_derivation() -> None:
"""SDK-defined: a resource template only needs its argument model, so an unschematizable return
annotation registers (it used to raise from the tool structured-output machinery)."""

def charts(year: str) -> list[_Chart]:
raise NotImplementedError

template = ResourceTemplate.from_function(charts, uri_template="charts://{year}")
assert template.uri_template == "charts://{year}"
Loading
Loading