Skip to content

Commit 0be83f5

Browse files
committed
Treat content-block, Image and Audio return annotations as unstructured tool output
A tool annotated to return a content block (-> EmbeddedResource, -> TextContent, -> list[ContentBlock], ...) had the block model's own pydantic schema published as its output_schema and every block echoed into structured_content a second time, while Image/Audio inside a generic (-> list[Image], -> Image | Audio) failed to register at all. -> Image escaped only because Image is a plain class. In auto-detect mode, an annotation that mentions a content block class or the Image/Audio helpers anywhere in its type tree now derives no output schema, matching what _convert_to_content already does with those values at runtime. structured_output=True still forces a schema. Behaviour change vs v1/2.0, so it is documented in the migration guide and the structured-output page.
1 parent 5234189 commit 0be83f5

5 files changed

Lines changed: 101 additions & 24 deletions

File tree

docs/migration.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.
677677
Beyond the changes covered in this section, the everyday `FastMCP` surface carries over to `MCPServer` as-is:
678678

679679
- **Decorators.** `@mcp.tool()`, `@mcp.resource()`, `@mcp.prompt()`, and `@mcp.completion()` take the same arguments and handler signatures as v1. The lowlevel [`on_completion` reshape](#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params) applies only to the lowlevel `Server`; a high-level `@mcp.completion()` handler is still called as `(ref, argument, context)`.
680-
- **Tool return handling.** A returned `CallToolResult` (including an `Annotated[CallToolResult, YourModel]` output schema, and `_meta`) is passed through, `Image` and `Audio` convert to content blocks as before, ready-made content blocks are kept as-is, and dict, list, scalar, and model returns are wrapped into `content` and `structured_content` by the same rules.
680+
- **Tool return handling.** A returned `CallToolResult` (including an `Annotated[CallToolResult, YourModel]` output schema, and `_meta`) is passed through, `Image` and `Audio` convert to content blocks as before, ready-made content blocks are kept as-is (neither is [structured by default](#content-block-image-and-audio-return-annotations-are-unstructured) now, even inside a `list`), and dict, list, scalar, and model returns are wrapped into `content` and `structured_content` by the same rules.
681681
- **Listing and registration methods.** `list_tools()`, `list_resources()`, `list_resource_templates()`, and `list_prompts()` return the same lists and are still what the protocol handlers call, so subclass overrides still take effect. `add_tool()`, `add_resource()`, and `add_prompt()` are unchanged.
682682
- **Helpers.** `Image.to_image_content()`, `Audio.to_audio_content()`, and the prompt `Message`, `UserMessage`, and `AssistantMessage` classes.
683683
- **Lifespan.** The `lifespan=` constructor argument and `ctx.request_context.lifespan_context` work as before, and the class is still generic over the lifespan result: `FastMCP[MyState]` becomes `MCPServer[MyState]`. (`Context`'s own type parameters did change; see [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified).)
@@ -924,6 +924,22 @@ running on the event-loop thread:
924924

925925
Declare the handler `async def` to keep it on the event loop.
926926

927+
### Content-block, `Image`, and `Audio` return annotations are unstructured
928+
929+
A tool whose return annotation mentions a content block (`TextContent`,
930+
`ImageContent`, `AudioContent`, `ResourceLink`, `EmbeddedResource`), `Image`, or
931+
`Audio` anywhere (alone, or inside a `list`, `tuple`, or union) now registers
932+
with no `output_schema` and returns no `structured_content`; `content` is built
933+
exactly as before. v1 (and 2.0) published the block type's own Pydantic schema as
934+
the tool's `output_schema` and echoed the serialized blocks into
935+
`structured_content` a second time, while an annotation with `Image` or `Audio`
936+
inside a generic (`-> list[Image]`) raised at registration (a bare `-> Image` was
937+
already unstructured).
938+
939+
If a client reads `structured_content` from such a tool, return a model,
940+
`TypedDict`, or `dict` instead, or pass `structured_output=True` to keep
941+
publishing a content block's schema.
942+
927943
### `MCPServer.call_tool()` returns `CallToolResult`
928944

929945
`MCPServer.call_tool()` now returns a `CallToolResult` (or an

docs/servers/structured-output.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ The opposite, `structured_output=True`, turns the automatic detection into a req
210210

211211
## A class without type hints
212212

213-
There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**.
213+
Content blocks and media (`TextContent`, `EmbeddedResource`, `Image`, `Audio` and friends, alone or inside a `list` or union) are not structured automatically: they are for the model to read (**[Images, audio & icons](media.md)** covers `Image` and `Audio`). Beyond those, there is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**.
214214

215215
```python title="server.py" hl_lines="6-9"
216216
--8<-- "docs_src/structured_output/tutorial009.py"
@@ -240,6 +240,6 @@ There is one way to end up unstructured without asking for it: return a class th
240240
* 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.
241241
* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application).
242242
* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
243-
* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it.
243+
* `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.
244244

245245
You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**.

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ def _is_input_required_type(obj: Any) -> bool:
3333
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)
3434

3535

36+
_CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio)
37+
38+
39+
def _contains_content_type(tp: Any) -> bool:
40+
"""Whether `tp` is, or is parameterized by, a content block class or the `Image`/`Audio` helpers."""
41+
if get_origin(tp) is not None:
42+
return any(_contains_content_type(arg) for arg in get_args(tp))
43+
return isinstance(tp, type) and issubclass(tp, _CONTENT_TYPES)
44+
45+
3646
class StrictJsonSchema(GenerateJsonSchema):
3747
"""A JSON schema generator that raises exceptions instead of emitting warnings.
3848
@@ -222,6 +232,9 @@ def func_metadata(
222232
- TypedDict - converted to a Pydantic model with same fields
223233
- Dataclasses and other annotated classes - converted to Pydantic models
224234
- Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field
235+
- Content blocks (TextContent, EmbeddedResource, ...), Image and Audio, anywhere in the
236+
annotation - unstructured when auto-detecting; structured_output=True bypasses this rule
237+
(a content block then publishes its own schema; Image/Audio have none and raise)
225238
226239
Returns:
227240
A FuncMetadata object containing:
@@ -345,6 +358,12 @@ def func_metadata(
345358
else:
346359
original_annotation = effective_annotation
347360

361+
if structured_output is None and _contains_content_type(return_type_expr):
362+
# Content blocks and the Image/Audio helpers are what the model reads, not data for the
363+
# application: deriving a schema would publish the block's own model as output_schema and
364+
# echo every block into structured_content. structured_output=True still forces one.
365+
return FuncMetadata(arg_model=arguments_model)
366+
348367
output_model, output_schema, wrap_output = _try_create_model_and_schema(
349368
original_annotation, return_type_expr, func.__name__
350369
)

tests/server/mcpserver/test_func_metadata.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
import annotated_types
1111
import pytest
1212
from dirty_equals import IsPartialDict
13-
from mcp_types import CallToolResult, InputRequiredResult
13+
from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent
1414
from pydantic import BaseModel, Field
1515

16+
from mcp.server.mcpserver import Audio, Image
1617
from mcp.server.mcpserver.exceptions import InvalidSignature
1718
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1819

@@ -854,6 +855,61 @@ def func_returning_unannotated() -> UnannotatedClass: # pragma: no cover
854855
assert meta.output_schema is None
855856

856857

858+
def _returns_block() -> EmbeddedResource:
859+
raise NotImplementedError
860+
861+
862+
def _returns_blocks() -> list[ContentBlock]:
863+
raise NotImplementedError
864+
865+
866+
def _returns_strings_and_images() -> list[str | Image]:
867+
raise NotImplementedError
868+
869+
870+
def _returns_audio_clips() -> tuple[Audio, ...]:
871+
raise NotImplementedError
872+
873+
874+
def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResult, list[TextContent]]:
875+
raise NotImplementedError
876+
877+
878+
@pytest.mark.parametrize(
879+
"tool",
880+
[
881+
_returns_block,
882+
_returns_blocks,
883+
_returns_strings_and_images,
884+
_returns_audio_clips,
885+
_returns_call_tool_result_annotated_with_blocks,
886+
],
887+
)
888+
def test_content_block_return_annotation_yields_no_output_schema(tool: Callable[..., Any]):
889+
"""SDK-defined: content blocks and the Image/Audio helpers anywhere in the return annotation are
890+
presentation, not data, so auto-detection derives no output schema (and `list[str | Image]` /
891+
`tuple[Audio, ...]`, which pydantic cannot build a schema for, register instead of raising)."""
892+
assert func_metadata(tool).output_schema is None
893+
894+
895+
def test_structured_output_true_overrides_the_content_block_rule():
896+
"""SDK-defined: the explicit flag still publishes the block's own schema for callers who want it."""
897+
assert func_metadata(_returns_block, structured_output=True).output_model is EmbeddedResource
898+
899+
900+
def test_model_with_a_content_block_field_stays_structured():
901+
"""SDK-defined: only the return annotation is inspected for content types, never a model's fields."""
902+
903+
class Report(BaseModel):
904+
summary: str
905+
attachment: EmbeddedResource
906+
907+
def tool() -> Report:
908+
raise NotImplementedError
909+
910+
assert func_metadata(tool).output_model is Report
911+
912+
857913
def test_tool_call_result_is_unstructured_and_not_converted():
858914
def func_returning_call_tool_result() -> CallToolResult:
859915
return CallToolResult(content=[])

tests/server/mcpserver/test_server.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -437,20 +437,8 @@ async def test_tool_mixed_content(self):
437437
assert isinstance(content3, AudioContent)
438438
assert content3.mime_type == "audio/wav"
439439
assert content3.data == "def"
440-
assert result.structured_content is not None
441-
assert "result" in result.structured_content
442-
structured_result = result.structured_content["result"]
443-
assert len(structured_result) == 3
444-
445-
expected_content = [
446-
{"type": "text", "text": "Hello"},
447-
{"type": "image", "data": "abc", "mimeType": "image/png"},
448-
{"type": "audio", "data": "def", "mimeType": "audio/wav"},
449-
]
450-
451-
for i, expected in enumerate(expected_content):
452-
for key, value in expected.items():
453-
assert structured_result[i][key] == value
440+
# Content blocks are for the model, not data: no output schema, nothing echoed as structured
441+
assert result.structured_content is None
454442

455443
async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path):
456444
"""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):
463451
audio_path = tmp_path / "test.wav"
464452
audio_path.write_bytes(b"test audio data")
465453

466-
# TODO(Marcelo): It seems if we add the proper type hint, it generates an invalid JSON schema.
467-
# We need to fix this.
468-
def mixed_list_fn() -> list: # type: ignore
469-
return [ # type: ignore
454+
def mixed_list_fn() -> list[str | Image | Audio | dict[str, str] | TextContent]:
455+
return [
470456
"text message",
471457
Image(image_path),
472458
Audio(audio_path),
@@ -475,7 +461,7 @@ def mixed_list_fn() -> list: # type: ignore
475461
]
476462

477463
mcp = MCPServer()
478-
mcp.add_tool(mixed_list_fn) # type: ignore
464+
mcp.add_tool(mixed_list_fn)
479465
async with Client(mcp) as client:
480466
result = await client.call_tool("mixed_list_fn", {})
481467
assert len(result.content) == 5
@@ -501,7 +487,7 @@ def mixed_list_fn() -> list: # type: ignore
501487
content5 = result.content[4]
502488
assert isinstance(content5, TextContent)
503489
assert content5.text == "direct content"
504-
# Check structured content - untyped list with Image objects should NOT have structured output
490+
# Image/Audio/TextContent in the annotation: no output schema, so nothing echoed as structured
505491
assert result.structured_content is None
506492

507493
async def test_tool_structured_output_basemodel(self):

0 commit comments

Comments
 (0)