MCPServer: content-block returns are unstructured, prompt messages take Image/Audio (#3320)
This commit is contained in:
@@ -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, 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**.
|
||||
@@ -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)**.
|
||||
|
||||
@@ -946,5 +946,4 @@ class Client:
|
||||
@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]
|
||||
|
||||
@@ -13,6 +13,7 @@ from mcp.server.request_state import (
|
||||
)
|
||||
|
||||
from .context import Context
|
||||
from .prompts.base import AssistantMessage, Message, UserMessage
|
||||
from .resolve import (
|
||||
AcceptedElicitation,
|
||||
CancelledElicitation,
|
||||
@@ -32,6 +33,9 @@ __all__ = [
|
||||
"Context",
|
||||
"Image",
|
||||
"Audio",
|
||||
"Message",
|
||||
"UserMessage",
|
||||
"AssistantMessage",
|
||||
"Icon",
|
||||
"Resolve",
|
||||
"Elicit",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -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.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,26 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
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"]
|
||||
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 +51,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 +60,18 @@ 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)
|
||||
# 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]
|
||||
|
||||
|
||||
@@ -89,7 +107,7 @@ class Prompt(BaseModel):
|
||||
"""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 +123,9 @@ class Prompt(BaseModel):
|
||||
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()
|
||||
|
||||
@@ -179,19 +196,15 @@ class Prompt(BaseModel):
|
||||
# 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
|
||||
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
|
||||
messages.append(Message(role="user", content=content))
|
||||
|
||||
return messages
|
||||
except MCPError:
|
||||
|
||||
@@ -152,10 +152,9 @@ class ResourceTemplate(BaseModel):
|
||||
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()
|
||||
|
||||
|
||||
@@ -918,8 +918,8 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
) -> 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).
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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=[])
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user