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
12 changed files with 290 additions and 56 deletions

View file

@ -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. 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 ## 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**. 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. * 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). * 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. * 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)**. You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**.

View file

@ -946,5 +946,4 @@ class Client:
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def send_roots_list_changed(self) -> None: async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed.""" """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] await self.session.send_roots_list_changed() # pyright: ignore[reportDeprecated]

View file

@ -13,6 +13,7 @@ from mcp.server.request_state import (
) )
from .context import Context from .context import Context
from .prompts.base import AssistantMessage, Message, UserMessage
from .resolve import ( from .resolve import (
AcceptedElicitation, AcceptedElicitation,
CancelledElicitation, CancelledElicitation,
@ -32,6 +33,9 @@ __all__ = [
"Context", "Context",
"Image", "Image",
"Audio", "Audio",
"Message",
"UserMessage",
"AssistantMessage",
"Icon", "Icon",
"Resolve", "Resolve",
"Elicit", "Elicit",

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import functools import functools
from collections.abc import Awaitable, Callable, Sequence 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 anyio.to_thread
import pydantic_core import pydantic_core
@ -13,6 +13,7 @@ from pydantic import BaseModel, Field, TypeAdapter, validate_call
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context 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.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._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
@ -22,14 +23,26 @@ if TYPE_CHECKING:
class Message(BaseModel): 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.
"""
role: Literal["user", "assistant"] role: Literal["user", "assistant"]
content: ContentBlock content: ContentBlock
def __init__(self, content: str | ContentBlock, **kwargs: Any): def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
if isinstance(content, str): if isinstance(content, str):
content = TextContent(type="text", text=content) 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) super().__init__(content=content, **kwargs)
@ -38,7 +51,7 @@ class UserMessage(Message):
role: Literal["user", "assistant"] = "user" 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) super().__init__(content=content, **kwargs)
@ -47,13 +60,18 @@ class AssistantMessage(Message):
role: Literal["user", "assistant"] = "assistant" 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) 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")]
)
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] PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
@ -89,7 +107,7 @@ class Prompt(BaseModel):
"""Create a Prompt from a function. """Create a Prompt from a function.
The function can return: 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 Message object
- A dict (converted to a message) - A dict (converted to a message)
- A sequence of any of the above - A sequence of any of the above
@ -105,10 +123,9 @@ class Prompt(BaseModel):
if context_kwarg is None: # pragma: no branch if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn) 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( func_arg_metadata = func_metadata(
fn, fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
skip_names=[context_kwarg] if context_kwarg is not None else [],
) )
parameters = func_arg_metadata.arg_model.model_json_schema() parameters = func_arg_metadata.arg_model.model_json_schema()
@ -179,19 +196,15 @@ class Prompt(BaseModel):
# Convert result to messages # Convert result to messages
messages: list[Message] = [] messages: list[Message] = []
for msg in result: # type: ignore[reportUnknownVariableType] for msg in result: # type: ignore[reportUnknownVariableType]
try:
if isinstance(msg, Message): if isinstance(msg, Message):
messages.append(msg) messages.append(msg)
elif isinstance(msg, dict): elif isinstance(msg, dict):
messages.append(message_validator.validate_python(msg)) messages.append(message_validator.validate_python(msg))
elif isinstance(msg, str): elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
content = TextContent(type="text", text=msg) messages.append(UserMessage(msg))
messages.append(UserMessage(content=content))
else: # pragma: no cover else: # pragma: no cover
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode() content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
messages.append(Message(role="user", content=content)) messages.append(Message(role="user", content=content))
except Exception: # pragma: no cover
raise ValueError(f"Could not convert prompt result to message: {msg}")
return messages return messages
except MCPError: except MCPError:

View file

@ -152,10 +152,9 @@ class ResourceTemplate(BaseModel):
if context_kwarg is None: # pragma: no branch if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn) 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( func_arg_metadata = func_metadata(
fn, fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
skip_names=[context_kwarg] if context_kwarg is not None else [],
) )
parameters = func_arg_metadata.arg_model.model_json_schema() parameters = func_arg_metadata.arg_model.model_json_schema()

View file

@ -918,8 +918,8 @@ class MCPServer(Generic[LifespanResultT]):
) -> Callable[[_CallableT], _CallableT]: ) -> Callable[[_CallableT], _CallableT]:
"""Decorator to register a prompt. """Decorator to register a prompt.
The function returns the prompt messages (a string, `Message`, dict, The function returns the prompt messages (a string, content block, `Image`/`Audio`,
or a sequence of these), or an `InputRequiredResult` to request `Message`, dict, or a sequence of these), or an `InputRequiredResult` to request
client input first (the 2026-07-28 multi-round-trip flow — read client input first (the 2026-07-28 multi-round-trip flow — read
`ctx.input_responses` on the retry). `ctx.input_responses` on the retry).

View file

@ -33,6 +33,25 @@ def _is_input_required_type(obj: Any) -> bool:
return isinstance(obj, type) and issubclass(obj, InputRequiredResult) 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): class StrictJsonSchema(GenerateJsonSchema):
"""A JSON schema generator that raises exceptions instead of emitting warnings. """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 - TypedDict - converted to a Pydantic model with same fields
- Dataclasses and other annotated classes - converted to Pydantic models - Dataclasses and other annotated classes - converted to Pydantic models
- Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field - 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: Returns:
A FuncMetadata object containing: A FuncMetadata object containing:
@ -345,6 +367,13 @@ def func_metadata(
else: else:
original_annotation = effective_annotation 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( output_model, output_schema, wrap_output = _try_create_model_and_schema(
original_annotation, return_type_expr, func.__name__ 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 Note: This conversion logic comes from previous versions of MCPServer and is being
retained for purposes of backwards compatibility. It produces different unstructured retained for purposes of backwards compatibility. It produces different unstructured
output than the lowlevel server tool call handler, which just serializes structured 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 if result is None: # pragma: no cover
return [] return []

View file

@ -2,7 +2,7 @@
import pytest import pytest
from inline_snapshot import snapshot from inline_snapshot import snapshot
from mcp_types import TextContent from mcp_types import EmbeddedResource, ImageContent, TextContent, TextResourceContents
from docs_src.structured_output import ( from docs_src.structured_output import (
tutorial001, tutorial001,
@ -17,6 +17,7 @@ from docs_src.structured_output import (
) )
from mcp import Client from mcp import Client
from mcp.server import MCPServer from mcp.server import MCPServer
from mcp.server.mcpserver import Image
from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.exceptions import InvalidSignature
# See test_index.py for why this is a per-module mark and not a conftest hook. # 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: 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`.""" """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: async with Client(tutorial009.mcp) as client:

View file

@ -1,18 +1,21 @@
import threading import threading
from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from mcp_types import ( from mcp_types import (
AudioContent,
ElicitRequest, ElicitRequest,
ElicitRequestFormParams, ElicitRequestFormParams,
EmbeddedResource, EmbeddedResource,
ImageContent,
InputRequiredResult, InputRequiredResult,
TextContent, TextContent,
TextResourceContents, TextResourceContents,
) )
from mcp.server.mcpserver import Context from mcp.server.mcpserver import AssistantMessage, Audio, Context, Image, MCPServer, Message, UserMessage
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage from mcp.server.mcpserver.prompts.base import Prompt
class TestRenderPrompt: class TestRenderPrompt:
@ -243,3 +246,86 @@ async def test_render_passes_input_required_result_through_unchanged():
prompt = Prompt.from_function(asking_prompt) prompt = Prompt.from_function(asking_prompt)
result = await prompt.render(None, Context()) result = await prompt.render(None, Context())
assert result is sentinel 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}") template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}")
result = await template.create_resource("ask://databases", {"topic": "databases"}, Context()) result = await template.create_resource("ask://databases", {"topic": "databases"}, Context())
assert result is sentinel 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 annotated_types
import pytest import pytest
from dirty_equals import IsPartialDict 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 pydantic import BaseModel, Field
from mcp.server.mcpserver import Audio, Image
from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.exceptions import InvalidSignature
from mcp.server.mcpserver.utilities.func_metadata import func_metadata 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 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 test_tool_call_result_is_unstructured_and_not_converted():
def func_returning_call_tool_result() -> CallToolResult: def func_returning_call_tool_result() -> CallToolResult:
return CallToolResult(content=[]) return CallToolResult(content=[])

View file

@ -437,20 +437,8 @@ class TestServerTools:
assert isinstance(content3, AudioContent) assert isinstance(content3, AudioContent)
assert content3.mime_type == "audio/wav" assert content3.mime_type == "audio/wav"
assert content3.data == "def" assert content3.data == "def"
assert result.structured_content is not None # Content blocks are for the model, not data: no output schema, nothing echoed as structured
assert "result" in result.structured_content assert result.structured_content is None
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
async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): 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 """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 = tmp_path / "test.wav"
audio_path.write_bytes(b"test audio data") 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. def mixed_list_fn() -> list[str | Image | Audio | dict[str, str] | TextContent]:
# We need to fix this. return [
def mixed_list_fn() -> list: # type: ignore
return [ # type: ignore
"text message", "text message",
Image(image_path), Image(image_path),
Audio(audio_path), Audio(audio_path),
@ -475,7 +461,7 @@ class TestServerTools:
] ]
mcp = MCPServer() mcp = MCPServer()
mcp.add_tool(mixed_list_fn) # type: ignore mcp.add_tool(mixed_list_fn)
async with Client(mcp) as client: async with Client(mcp) as client:
result = await client.call_tool("mixed_list_fn", {}) result = await client.call_tool("mixed_list_fn", {})
assert len(result.content) == 5 assert len(result.content) == 5
@ -501,7 +487,7 @@ class TestServerTools:
content5 = result.content[4] content5 = result.content[4]
assert isinstance(content5, TextContent) assert isinstance(content5, TextContent)
assert content5.text == "direct content" 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 assert result.structured_content is None
async def test_tool_structured_output_basemodel(self): async def test_tool_structured_output_basemodel(self):