diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index a146e01442..ae2987db53 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -208,6 +208,10 @@ No `output_schema`, no wrapping, no validation. `structured_content` is `None` a 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. + ## 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**. @@ -240,6 +244,6 @@ There is one way to end up unstructured without asking for it: return a class th * 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)**. diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index ed7c40f123..b4ceefab24 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -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] diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 56d1c23cba..7c9b67e990 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -13,6 +13,7 @@ ) from .context import Context +from .prompts.base import AssistantMessage, Message, UserMessage from .resolve import ( AcceptedElicitation, CancelledElicitation, @@ -32,6 +33,9 @@ "Context", "Image", "Audio", + "Message", + "UserMessage", + "AssistantMessage", "Icon", "Resolve", "Elicit", diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 0a010de7d2..7170249ed5 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -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 @@ -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): 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() super().__init__(content=content, **kwargs) @@ -38,7 +47,7 @@ class UserMessage(Message): 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) @@ -47,13 +56,14 @@ class AssistantMessage(Message): 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] @@ -89,7 +99,7 @@ def from_function( """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) - A Message object - A dict (converted to a message) - A sequence of any of the above @@ -105,10 +115,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 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() @@ -184,9 +193,8 @@ async def render( 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)) else: # pragma: no cover content = pydantic_core.to_json(msg, fallback=str, indent=2).decode() messages.append(Message(role="user", content=content)) diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 096e821d81..2ea99c19b6 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -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() diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index bc79c44a36..70e45329c5 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -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). diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..2037b860a1 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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. @@ -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: @@ -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( original_annotation, return_type_expr, func.__name__ ) @@ -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 [] diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py index c0b900d2d3..a12e6d2e7d 100644 --- a/tests/docs_src/test_structured_output.py +++ b/tests/docs_src/test_structured_output.py @@ -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, @@ -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. @@ -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: diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index e88a096ba8..bc23086f21 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -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: @@ -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 diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 42a1099537..b27c77a585 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -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}" diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 62a9612b95..2dfe5d389d 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -10,9 +10,10 @@ import annotated_types import pytest from dirty_equals import IsPartialDict -from mcp_types import CallToolResult, InputRequiredResult +from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent from pydantic import BaseModel, Field +from mcp.server.mcpserver import Audio, Image from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.func_metadata import func_metadata @@ -854,6 +855,73 @@ def func_returning_unannotated() -> UnannotatedClass: # pragma: no cover assert meta.output_schema is None +def _returns_block() -> EmbeddedResource: + raise NotImplementedError + + +def _returns_blocks() -> list[ContentBlock]: + raise NotImplementedError + + +def _returns_strings_and_images() -> list[str | Image]: + raise NotImplementedError + + +def _returns_audio_clips() -> tuple[Audio, ...]: + raise NotImplementedError + + +def _returns_described_blocks() -> list[Annotated[TextContent, Field(description="one line each")]]: + raise NotImplementedError + + +def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResult, list[TextContent]]: + raise NotImplementedError + + +@pytest.mark.parametrize( + "tool", + [ + _returns_block, + _returns_blocks, + _returns_strings_and_images, + _returns_audio_clips, + _returns_described_blocks, + _returns_call_tool_result_annotated_with_blocks, + ], +) +def test_content_block_return_annotation_yields_no_output_schema(tool: Callable[..., Any]): + """SDK-defined: content blocks and the Image/Audio helpers anywhere in the return annotation are + presentation, not data, so auto-detection derives no output schema (and `list[str | Image]` / + `tuple[Audio, ...]`, which pydantic cannot build a schema for, register instead of raising).""" + assert func_metadata(tool).output_schema is None + + +def test_structured_output_true_overrides_the_content_block_rule(): + """SDK-defined: the explicit flag still publishes the block's own schema for callers who want it.""" + assert func_metadata(_returns_block, structured_output=True).output_model is EmbeddedResource + + +class _Report(BaseModel): + summary: str + attachment: EmbeddedResource + + +def _returns_report() -> _Report: + raise NotImplementedError + + +def _returns_blocks_by_key() -> dict[str, TextContent]: + raise NotImplementedError + + +@pytest.mark.parametrize("tool", [_returns_report, _returns_blocks_by_key]) +def test_content_blocks_as_model_fields_or_mapping_values_stay_structured(tool: Callable[..., Any]): + """SDK-defined: the rule mirrors `_convert_to_content`, which renders blocks only when they are the + value itself or list/tuple items; a model field or a mapping value is data and keeps its schema.""" + assert func_metadata(tool).output_schema is not None + + def test_tool_call_result_is_unstructured_and_not_converted(): def func_returning_call_tool_result() -> CallToolResult: return CallToolResult(content=[]) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index bc3fb14918..81b490c544 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -437,20 +437,8 @@ async def test_tool_mixed_content(self): assert isinstance(content3, AudioContent) assert content3.mime_type == "audio/wav" assert content3.data == "def" - assert result.structured_content is not None - assert "result" in result.structured_content - structured_result = result.structured_content["result"] - assert len(structured_result) == 3 - - expected_content = [ - {"type": "text", "text": "Hello"}, - {"type": "image", "data": "abc", "mimeType": "image/png"}, - {"type": "audio", "data": "def", "mimeType": "audio/wav"}, - ] - - for i, expected in enumerate(expected_content): - for key, value in expected.items(): - assert structured_result[i][key] == value + # Content blocks are for the model, not data: no output schema, nothing echoed as structured + assert result.structured_content is None async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): """Test that lists containing Image objects and other types are handled @@ -463,10 +451,8 @@ async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): audio_path = tmp_path / "test.wav" audio_path.write_bytes(b"test audio data") - # TODO(Marcelo): It seems if we add the proper type hint, it generates an invalid JSON schema. - # We need to fix this. - def mixed_list_fn() -> list: # type: ignore - return [ # type: ignore + def mixed_list_fn() -> list[str | Image | Audio | dict[str, str] | TextContent]: + return [ "text message", Image(image_path), Audio(audio_path), @@ -475,7 +461,7 @@ def mixed_list_fn() -> list: # type: ignore ] mcp = MCPServer() - mcp.add_tool(mixed_list_fn) # type: ignore + mcp.add_tool(mixed_list_fn) async with Client(mcp) as client: result = await client.call_tool("mixed_list_fn", {}) assert len(result.content) == 5 @@ -501,7 +487,7 @@ def mixed_list_fn() -> list: # type: ignore content5 = result.content[4] assert isinstance(content5, TextContent) assert content5.text == "direct content" - # Check structured content - untyped list with Image objects should NOT have structured output + # Image/Audio/TextContent in the annotation: no output schema, so nothing echoed as structured assert result.structured_content is None async def test_tool_structured_output_basemodel(self):