MCPServer: content-block returns are unstructured, prompt messages take Image/Audio (#3320)

This commit is contained in:
Max 2026-08-17 14:12:31 +01:00 • committed by GitHub
commit fb443cc4b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 290 additions and 56 deletions

View file

@ -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 docs_src.structured_output import (
)
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:

View file

@ -1,18 +1,21 @@
import threading
from pathlib import Path
from typing import Any
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 +246,86 @@ async def test_render_passes_input_required_result_through_unchanged():
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
@pytest.mark.anyio
async def test_prompt_returning_media_with_an_unreadable_file_fails_to_render(tmp_path: Path) -> None:
"""SDK-defined: a bare `Image` whose file cannot be read fails the render (an error for the client)
instead of degrading to a text message."""
def fn() -> Image:
return Image(path=tmp_path / "missing.png")
with pytest.raises(ValueError):
await Prompt.from_function(fn).render(None, Context())

View file

@ -505,3 +505,18 @@ async def test_create_resource_passes_input_required_result_through_unchanged():
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}"

View file

@ -10,9 +10,10 @@ from typing import Annotated, Any, Final, NamedTuple, TypedDict
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 test_unstructured_output_unannotated_class():
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=[])

View file

@ -437,20 +437,8 @@ class TestServerTools:
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 @@ class TestServerTools:
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 @@ class TestServerTools:
]
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 @@ class TestServerTools:
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):