Skip to content

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

Open
maxisbey wants to merge 8 commits into
mainfrom
mcpserver-content-and-prompt-ergonomics
Open

MCPServer: content-block returns are unstructured, prompt messages take Image/Audio#3320
maxisbey wants to merge 8 commits into
mainfrom
mcpserver-content-and-prompt-ergonomics

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Four small MCPServer/client fixes that remove traps the server docs would otherwise have to explain around. One commit per item so they can be reviewed (or dropped) independently.

Behaviour change to call out in the release notes (item 1): a tool whose return annotation is a content-block type (-> EmbeddedResource, -> list[TextContent], -> tuple[TextContent, ...], -> str | TextContent, ...) or has Image/Audio as its list/tuple items no longer advertises outputSchema and no longer returns structuredContent; its content is unchanged. Pass structured_output=True to keep the old shape.

Motivation and Context

1. Content-block, Image and Audio return annotations are unstructured (func_metadata)

@mcp.tool() def f() -> EmbeddedResource published the pydantic schema of the EmbeddedResource class itself (~2 KB) as the tool's outputSchema and echoed the block into structuredContent a second time. Same for -> ResourceLink, -> TextContent, -> list[ContentBlock], tuple[...], unions. -> Image escaped only because Image is a plain class, and -> list[Image] / -> list[str | Image] / -> Image | Audio didn't register at all (PydanticSchemaGenerationError from create_model, outside the existing try).

In auto-detect mode (structured_output=None), a return annotation that declares content blocks or the Image/Audio helpers — bare, as the items of a list/tuple/Sequence, or as the arms of a union (through Annotated/Optional) — now derives no output schema. That is the annotation-level mirror of what _convert_to_content already does with those values at runtime; mapping values and model fields are data and keep their schema exactly as before. structured_output=True still forces a schema. The check sits right before schema derivation, so there is one rule and one override; Annotated[CallToolResult, list[TextContent]] is covered by the same rule (on main that spelling failed every call unless structured_content was hand-built).

Prompt.from_function and ResourceTemplate.from_function only ever needed the argument model but ran the same auto-detection, so an unschematizable return annotation on a prompt or resource template (-> list[SomePlainClass]) failed registration with a tool structured-output error; they now pass structured_output=False, which also keeps this rule from reaching beyond tools.

2. Prompt messages accept Image / Audio (prompts/base.py)

Tools convert the helpers; UserMessage(Image(...)) was a pydantic validation error (client saw -32603), so you had to write Image(...).to_image_content(). Message.__init__ now does the same conversion str already gets. The dict form ({"role": "user", "content": Image(...)}) works too since validation goes through __init__.

A prompt function may also return bare content the way a tool does — Image(...), a ready-made content block, or a list mixing captions and images — and each item becomes one user message; previously anything that wasn't a str/Message/dict was JSON-dumped (an Image arrived as its repr). That last part is its own commit (Prompt functions may return bare content blocks, Image or Audio) and can be dropped independently; the JSON-dump fallback for other values is untouched.

3. Prompt message classes exported from mcp.server.mcpserver (__init__.py)

Message, UserMessage, AssistantMessage are re-exported next to Image/Audio, so the prompt examples import everything from one place. (An earlier revision also let add_prompt() take a function like add_tool(); that was dropped — add_resource()/add_prompt() take built objects today, and changing the imperative registration API deserves its own pass across all three primitives. mcp.add_prompt(Prompt.from_function(fn, ...)) remains the runtime-registration spelling.)

4. Stale TODO in Client.send_roots_list_changed

The comment claimed the server can't handle the notification; the lowlevel Server has on_roots_list_changed and tests/interaction/lowlevel/test_roots.py drives it. (The runtime deprecation warning currently fires once per decorated layer for the Client -> ClientSession delegations and for ctx.info() -> ctx.log() -> send_log_message; that is the same across ~10 call sites and is left for a follow-up rather than special-casing roots here.)

How Has This Been Tested?

  • New/updated unit tests: parametrized func_metadata cases (bare block, list[ContentBlock], list[str | Image], tuple[Audio, ...], Annotated[CallToolResult, list[TextContent]]), the structured_output=True override, a model with a content-block field staying structured; Image/Audio in UserMessage/AssistantMessage.
  • Review-round pins: dict[str, TextContent] and a model with a block field stay structured; a prompt and a resource template with an unschematizable return annotation register; dict-form prompt message with an Image; bare Image/EmbeddedResource/[str, Image] prompt returns; tests/docs_src/test_structured_output.py proves the new page section.
  • The existing test_tool_mixed_content flips to structured_content is None; test_tool_mixed_list_with_audio_and_image gets its real annotation back and loses a TODO plus three type: ignores.
  • Exercised a user-style server over a real stdio subprocess before/after: on main the module fails to import (-> list[str | Image]), report/blocks advertise outputSchema, and the image prompt is an internal error; on this branch tools/list shows no outputSchema for the content tools (and still one for structured_output=True and a dict[str, float] control) and the prompt renders text/image/audio.

Breaking Changes

None. No signature, export, or documented behaviour changes; per VERSIONING.md these are bug fixes plus additive API for a minor release, so the migration guide is untouched.

The one observable difference is item 1: tools whose return annotation is a content-block type stop advertising outputSchema and stop returning structuredContent (their content is unchanged). No docs page presented that shape as the intended contract (the media page says such results carry no output schema; the structured-output page enumerates models, TypedDicts, dataclasses, scalars and generics), and -> list[Image] not registering was a plain bug. It does show up in the everything-server's test_image_content / test_audio_content / test_embedded_resource / test_multiple_content_types; the conformance scenarios only assert on content, so they are unaffected. Anyone who wants the old shape passes structured_output=True. docs/servers/structured-output.md gets two sentences so the published page stays accurate.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Not done here, noted as follow-ups:

  • Deprecated public methods delegating to other deprecated public methods emit MCPDeprecationWarning once per layer (Client.set_logging_level/subscribe_resource/unsubscribe_resource/send_progress_notification/send_roots_list_changed, and Context.debug/info/warning/error -> log -> ServerSession.send_log_message). The clean fix is undecorated private bodies that both public layers call, plus a "one warning per call, attributed to the caller" regression test. (The roots deprecation text cites SEP-2577; the notification's removal at 2026-07-28 is SEP-2575 — same pass.)
  • Typing indirections: type X = ... (PEP 695) and NewType return annotations are not unwrapped by the content rule, by the existing InputRequiredResult stripping, or by Annotated[CallToolResult, ...] detection (baseline behaviour, not a regression). One alias-resolution step on the inspected return type feeding all three is the right place.
  • _try_create_model_and_schema builds its wrapper models outside the try, so -> list[SomePlainClass] on a tool (and structured_output=True with -> list[Image], or dict[str, Image]) still raises a raw PydanticSchemaGenerationError instead of falling back / raising InvalidSignature.
  • Prompt message union: UserMessage and AssistantMessage both declare role: Literal["user", "assistant"], so the dict-form validator cannot discriminate and tries both arms (with Image(path=...) content the file is read twice, and a missing file surfaces as a generic conversion error). Distinct role literals or a left-to-right union belong with any further prompt-coercion consolidation.

The docs pages that motivated this (media, prompts) are being rewritten separately; the doc edits here are only the ones needed to keep currently published statements true.

AI Disclaimer

The lowlevel Server has handled roots/list_changed via on_roots_list_changed
for a while (see tests/interaction/lowlevel/test_roots.py); the comment was
left behind when the pragma next to it was removed.
Tools already convert the Image/Audio helpers to ImageContent/AudioContent;
prompt messages rejected them with a pydantic validation error, forcing
UserMessage(Image(...).to_image_content()). Message.__init__ now performs the
same conversion, so UserMessage(Image(...)) works, including via the dict form.
…ed 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.
add_tool(fn) registers a function but add_prompt() only took a ready-made Prompt,
so registering a prompt outside the decorator meant importing Prompt from a
subpackage and calling Prompt.from_function yourself. add_prompt() now also
accepts the function with the same keyword options as @prompt(); the Prompt form
(including add_prompt(prompt=...)) is unchanged and @prompt() still hands
add_prompt a Prompt, so subclass overrides keep intercepting registrations.

Message, UserMessage and AssistantMessage are re-exported from
mcp.server.mcpserver next to Image and Audio.
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3320.mcp-python-docs.pages.dev
Deployment https://a0b612f3.mcp-python-docs.pages.dev
Commit 2251112
Triggered by @maxisbey
Updated 2026-08-16 17:37:37 UTC

The migration guide documents breaking changes between majors. Nothing here
changes a signature or documented behaviour, so the notes belong in the release
notes, not the guide.

No-Verification-Needed: docs-only revert
Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/migration.md">

<violation number="1">
P2: This line now says tool return handling is unchanged, but content-block/Image/Audio return annotations are no longer auto-structured in auto-detect mode. Document that exception here (or keep the dedicated migration note) so users who relied on `output_schema`/`structured_content` understand the behavior change and override path (`structured_output=True`).</violation>

<violation number="2">
P2: `add_prompt()` is not unchanged: it now accepts a plain function plus `name/title/description/icons`, while v1 only accepted a `Prompt`. Keep this bullet aligned with the current API so migration readers see the supported registration form.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/mcp/server/mcpserver/server.py — [quality] Now that add_prompt() accepts a plain function with name/title/description/icons, the @ prompt() decorator should delegate to it (self.add_prompt(func, name=name, title=title, description=description, icons=icons)) instead of duplicating the Prompt.from_function(...) construction, matching how @ tool() delegates to add_tool(fn, ...) at server.py:677.

    Extended reasoning...

    Concrete cost: two separate code paths construct a Prompt from a function (server.py:1004 in the decorator and server.py:931 in add_prompt), so any future change to registration (extra validation, new kwargs, duplicate-name policy) must be applied in both places or the decorator and add_prompt drift apart. The sibling @ tool() decorator already uses the delegation form (add_tool(fn, ...)), so the prompt decorator is now the odd one out for no benefit; delegating removes the duplicated Prompt.from_function call added alongside this PR's new add_prompt function form.

    Verification: nit — the claim is factually true. This diff added a callable overload to add_prompt (src/mcp/server/mcpserver/server.py:893-933) whose body does prompt = Prompt.from_function(prompt, name=name, title=title, description=description, icons=icons) — exactly the same construction the @ prompt() decorator still performs itself at lines 1003-1005: `prompt = Prompt.from_function(func, name=name, title

  • 🟣 src/mcp/server/mcpserver/prompts/base.py — Prompt functions returning a bare Image/Audio (the shape tools accept, and which this PR now advertises for prompt message content) are silently stringified to the object's repr instead of converting to ImageContent/AudioContent: Message.__init__ gained the conversion but Prompt.render's per-item dispatch (Message/dict/str, else JSON-dump with fallback=str) was not extended.

    Extended reasoning...

    A user reads the new Message docstring ('content may be ... an Image or Audio helper') or is used to tools, and writes @ mcp.prompt()\ndef p(): return ["look at this", Image(path)] (or return Image(path)). render() hits the else branch at lines 199-201: pydantic_core.to_json(Image_instance, fallback=str) produces a JSON string like '"<mcp.server.mcpserver.utilities.types.Image object at 0x7f...>"', which is sent to the client as a TextContent message — silent garbage, no error. Inconsistently, the dict form {"role": "user", "content": Image(path)} DOES work, because pydantic's custom_init routes message_validator dict validation through the new init. The else branch is pre-existing (and marked pragma: no cover), but the PR's widening of the prompt content surface to Image/Audio is what makes this path a realistic user trigger; the fix is adding the same isinstance(Image/Audio) conversion in render's dispatch.

    Verification: pre-existing — the failure path is real and reachable, though the dispatch lines themselves predate this diff; the PR extends the same feature and makes the mistake more likely. This PR adds Image/Audio conversion only to Message.__init__ (src/mcp/server/mcpserver/prompts/base.py:38-41, new in this diff) and advertises it in the new docstring at lines 28-29 ("content may be ... an Image or

Comment on lines +39 to +43
def _contains_content_type(tp: Any) -> bool:
"""Whether `tp` is, or is parameterized by, a content block class or the `Image`/`Audio` helpers."""
if get_origin(tp) is not None:
return any(_contains_content_type(arg) for arg in get_args(tp))
return isinstance(tp, type) and issubclass(tp, _CONTENT_TYPES)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 _contains_content_type does not unwrap typing indirections (PEP 695 TypeAliasType, NewType), so a tool annotated type Blocks = list[TextContent]; def tool() -> Blocks (or -> NewType("Block", TextContent)) bypasses the new content-block rule and still registers structured, unlike the identical inline annotation -> list[TextContent].

Extended reasoning...

On Python 3.12+ a server author writes type Blocks = list[TextContent] and @ mcp.tool()\ndef tool() -> Blocks. inspect_annotation is called with the default unpack_type_aliases='skip' (func_metadata.py:297), so return_type_expr is the raw TypeAliasType object; in _contains_content_type, get_origin(alias) is None and isinstance(alias, type) is False, so the check at line 361 returns False. _try_create_model_and_schema then falls into the wrapped-model branch and pydantic (which DOES unwrap TypeAliasType/NewType) happily builds the schema, so the tool publishes TextContent's model as output_schema and echoes every block into structured_content — exactly the v1 behavior the new docs/migration.md section ('mentions a content block... anywhere... now registers with no output_schema and returns no structured_content') says no longer happens. The same annotation spelled inline (-> list[TextContent]) is unstructured, so two spellings of the same type silently diverge and clients see duplicated block data in structured_content for the alias spelling. Fix: unwrap Typ

Verification: normal — the new content-block gate silently fails for typing indirections, so the exact behavior this PR exists to remove (publishing the block's own model as output_schema and echoing blocks into structured_content) still ships for alias spellings. Chain: (1) src/mcp/server/mcpserver/utilities/func_metadata.py:297 calls `inspect_annotation(sig.return_annotation, annotation_source=AnnotationSourc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced, but it is baseline behaviour rather than something this PR introduces, and the same indirection hole exists in the InputRequiredResult arm-stripping and the Annotated[CallToolResult, ...] detection (type R = int | InputRequiredResult publishes a schema with the IRR model in $defs today). Patching only this predicate would leave those two; the right fix is one alias-resolution step on the inspected return type (TypeAliasType.__value__ / NewType.__supertype__) feeding all three checks, which is listed as a follow-up in the PR body rather than growing this one. Parameterised generic aliases are already handled via get_origin.

AI Disclaimer

Comment thread docs/migration.md Outdated
Comment thread src/mcp/server/mcpserver/__init__.py
Comment thread src/mcp/server/mcpserver/prompts/base.py
add_tool takes a function while add_resource and add_prompt take built objects;
letting add_prompt accept both would be a third shape rather than consistency, and
changing the imperative registration API deserves its own design pass across all
three primitives. mcp.add_prompt(Prompt.from_function(fn, ...)) remains the spelling
for runtime registration.
@maxisbey maxisbey changed the title MCPServer: content-block returns are unstructured, prompt messages take Image/Audio, add_prompt() takes a function MCPServer: content-block returns are unstructured, prompt messages take Image/Audio Aug 16, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/mcp/server/mcpserver/server.py — [quality] nit: the structured_output docstring block is duplicated in three places, and only the func_metadata copy was updated with the new content-block rule — MCPServer.add_tool() (server.py:594-597) and MCPServer.tool() (server.py:644-647) still describe plain auto-detection with no mention that content-block/Image/Audio annotations now opt out of structured output.

    Extended reasoning...

    Concrete cost: divergent duplicated documentation on the public API surface. The diff changes what "auto-detects based on the function's return type annotation" means (a -> list[TextContent] tool now silently gets no outputSchema/structuredContent), and documents that only in the internal func_metadata() docstring (func_metadata.py:235-237). A user reading help(mcp.tool) or the IDE hover for add_tool/tool — the only docstrings users actually see — gets the pre-change semantics and has no pointer to the structured_output=True override; the three copies of this bullet list will keep drifting. Fix: extend the bullet in both public docstrings (or reference the one canonical description) in the same PR that changed the behavior.

    Verification: nit — the claim is factually true. The diff updates only the internal func_metadata() docstring (src/mcp/server/mcpserver/utilities/func_metadata.py, new bullet: "Content blocks (TextContent, EmbeddedResource, ...), Image and Audio, anywhere in the annotation - unstructured when auto-detecting; structured_output=True bypasses this rule"), while the two public copies of the same `structured_out

  • 🟣 src/mcp/server/mcpserver/prompts/base.py — Pre-existing, made more visible by this change: Prompt.render's fallback branch JSON-dumps a bare Image/Audio helper returned from a prompt function into a garbage text block, while the same helper is now converted properly everywhere else (Message content, tool returns).

    Extended reasoning...

    This PR teaches Message/UserMessage/AssistantMessage to convert Image/Audio helpers (base.py lines 35-42) and documents that 'prompt messages accept the same Image/Audio helpers tools return'. A user then naturally writes @ mcp.prompt()\ndef pic() -> Image: return Image(path) (or returns [Image(path), "caption"]). render() hits the else branch at lines 199-201: pydantic_core.to_json(Image_instance, fallback=str) produces the object's repr, so the client receives a text message containing '"<mcp.server.mcpserver.utilities.types.Image object at 0x7f...>"' instead of an ImageContent block (or an error). No exception is raised, so the broken prompt ships silently. The fix is one more dispatch arm (convert Image/Audio — and arguably bare ContentBlock — into a UserMessage) at the render level where str already gets special-cased; the PR applied the conversion only at Message.init depth. Author lists this as a follow-up in the PR description; filed so it is tracked against the code that merges.

    Verification: pre-existing — src/mcp/server/mcpserver/prompts/base.py:199-201: in Prompt.render(), a bare Image/Audio returned from a prompt function falls to the else branch content = pydantic_core.to_json(msg, fallback=str, indent=2).decode(); since Image/Audio are plain non-pydantic classes (src/mcp/server/mcpserver/utilities/types.py:9,57), fallback=str yields the object repr, which line 201 wraps as a us

Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py Outdated
Comment thread docs/servers/structured-output.md Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/mcp/server/mcpserver/prompts/base.py — Pre-existing, made far more likely by this PR: a prompt function that returns a bare Image/Audio helper (or a bare content block) — instead of wrapping it in UserMessage — hits Prompt.render's fallback else branch, which JSON-dumps the object with fallback=str, so the client silently receives a text message containing the helper's repr. The PR teaches prompts to accept Image/Audio (Message.init converts them) and exports Message classes publicly, but only when the helper is wrapped in a message; the symmetric spelling tools use (return Image(...)) still degrades to garbage text instead of being converted via the same to_image_content()/to_audio_content() path that Message.init now has three lines above.

    Extended reasoning...

    A user reads the new docs/exports showing prompts accept Image/Audio, and — mirroring the tool pattern def tool() -> Image: return Image(path) — writes @ mcp.prompt() def logo_prompt(): return Image("logo.png"). render() reaches the else branch at prompts/base.py:199-201: pydantic_core.to_json(Image_instance, fallback=str) yields '"<mcp.server.mcpserver.utilities.types.Image object at 0x7f...>"', which is wrapped in a user TextContent message. The client's get_prompt succeeds and the LLM is fed a Python object repr instead of the image — no error, no warning (the branch is # pragma: no cover, so no test would catch it either). Fix at the same depth as the tool path: convert Image/Audio (and pass through ContentBlock) in the render fallback, or raise a clear error.

    Verification: pre-existing — the defective fallback predates this PR, but the diff extends the adjacent conversion code and makes the trap likelier. At src/mcp/server/mcpserver/prompts/base.py:199-201, a prompt result that is not Message/dict/str hits content = pydantic_core.to_json(msg, fallback=str, indent=2).decode(); Image/Audio (plain classes, utilities/types.py:9/57, no serializer or str) theref

Comment thread src/mcp/server/mcpserver/prompts/base.py
# 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing: with structured_output=True, a return annotation like list[Image] or tuple[Audio, ...] makes _try_create_model_and_schema raise a raw PydanticSchemaGenerationError (wrapper model built outside the try) instead of the intended InvalidSignature; the new content-type rule only shields the structured_output=None path, and the new docstring at line 237 says such annotations "raise" without noting it is an unhandled pydantic internal error.

Extended reasoning...

A user who reads the new docs/docstring and passes structured_output=True to force a schema on def f() -> list[Image] gets an opaque PydanticSchemaGenerationError traceback at tool-registration time instead of the SDK's InvalidSignature error naming the function and return type, making the misconfiguration hard to diagnose.

Verification: pre-existing — the defective code (wrapper model built outside the try) predates this PR, but the diff interacts with it directly: the new guard at src/mcp/server/mcpserver/utilities/func_metadata.py:361 (if structured_output is None and _contains_content_type(return_type_expr):) shields only the auto-detect path, and the new docstring lines 235-237 explicitly advertise the override ("structure

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and pre-existing: the wrapper model is built outside the try in _try_create_model_and_schema, so containers of unschematizable types surface a raw PydanticSchemaGenerationError instead of InvalidSignature/fallback. Listed as a follow-up in the PR body; it deserves its own small PR since it changes which annotations register at all.

…ompts and templates out of it

- The predicate now recurses only where _convert_to_content renders blocks: through
  Annotated, unions, and list/tuple/Sequence/Iterable items. Mapping values, generic
  TypedDicts/dataclasses parameterised by a block, and type[...] are data again and
  keep their schema, so the docs sentence (now under its own heading, with tuple) and
  the code describe the same rule. Renamed to _returns_content(annotation).
- Prompt.from_function and ResourceTemplate.from_function only ever read arg_model, so
  they pass structured_output=False instead of running tool output-schema derivation;
  an unschematizable return annotation on a prompt or template no longer decides
  whether it registers.
- Tests: dict/model-field cases stay structured; prompt and template registration with
  an unschematizable return annotation; dict-form prompt message with an Image; the
  docs_src pin for the new structured-output section; prompt tests import the message
  classes from mcp.server.mcpserver.
render() special-cased str and JSON-dumped anything else that was not a Message or
dict, so a prompt returning Image(...) or a ready-made content block (or a list mixing
captions and images) reached the client as the object's repr or a JSON blob. Bare
content now becomes one user message via UserMessage(msg), making Message.__init__
the single place prompt content is coerced; the JSON-dump fallback for other values is
unchanged. SyncPromptResult is widened to match.


_CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio)
_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence, Iterable)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 _CONTENT_SEQUENCE_ORIGINS includes Sequence and Iterable, but the claimed mirror _convert_to_content only unrolls list|tuple, so -> Iterable[TextContent] (or a custom Sequence) is opted out of structured output while the runtime path JSON/repr-dumps the value instead of rendering blocks

Extended reasoning...

A tool annotated -> Iterable[TextContent] that returns a generator (or any non-list/tuple iterable) previously derived an output schema and delivered the real data in structured_content (content was already a repr/JSON dump since _convert_to_content at line 589 only handles list|tuple). After this change the annotation matches _returns_content, so the tool advertises no outputSchema and returns structured_content=None, and its content is still the dumped repr — the client loses the actual data entirely. Either restrict the origins to list/tuple (matching _convert_to_content) or teach _convert_to_content to unroll non-list/tuple iterables; the docstring's 'Keep the two in sync' contract is currently broken.

Verification: normal — src/mcp/server/mcpserver/utilities/func_metadata.py:37 _CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence, Iterable) (matched at line 47) opts -> Iterable[TextContent]/-> Sequence[TextContent] out of structured output (lines 367-372 return FuncMetadata(arg_model=...) with no schema), but the claimed runtime mirror _convert_to_content only unrolls `isinstance(result, list | tuple

Comment on lines +47 to +48
if origin is Annotated or is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS:
return any(_returns_content(arg) for arg in get_args(annotation))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] nit: in _returns_content, the origin is Annotated branch recurses over every element of get_args(annotation), which for Annotated[X, meta...] includes the metadata objects as well as the annotated type X, so a content-block class appearing as Annotated metadata counts as "returns content"; only get_args(annotation)[0] is the type. The clause is also completely untested: top-level Annotated is stripped by inspect_annotation before _returns_content is called (func_metadata.py:303-307, 350), so the branch is only reachable for Annotated nested inside a list/tuple/union, and none of the new tests in tests/server/mcpserver/test_func_metadata.py cover that shape (all five parametrized cases reach the union/list/tuple branches).

Extended reasoning...

Concrete cost: over-broad matching plus unpinned behavior in the predicate that now decides a tool's public outputSchema. A tool annotated with a content class used as marker metadata inside a container, e.g. def report() -> list[Annotated[dict[str, int], TextContent]], is data-returning (runtime _convert_to_content JSON-dumps the dicts), yet _returns_content matches the TextContent metadata element and silently drops the tool's output schema and structured_content under auto-detect. And because no test exercises the Annotated branch at all (the intended case list[Annotated[TextContent, Field(...)]] included), a future refactor could change or break its semantics without any test failing while CI coverage still reads 100% (condition-level coverage of the compound if is not measured). Fix at the right depth: recurse only into the first element of get_args() for the Annotated case and add a nested-Annotated test.

Verification: nit — src/mcp/server/mcpserver/utilities/func_metadata.py:47-48: if origin is Annotated or ... : return any(_returns_content(arg) for arg in get_args(annotation)). typing.get_args(Annotated[X, meta...]) returns (X, *metadata), so the Annotated branch recurses into metadata objects too; a content-block class appearing as metadata (e.g. -> list[Annotated[dict[str, int], TextContent]]) matche


The function can return:
- A string (converted to a message)
- A string, content block, `Image` or `Audio` (each becomes a user message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] nit: the prompt return-type widening (bare content blocks, Image, Audio become user messages) was documented in Prompt.from_function's docstring but not propagated to the public @ mcp.prompt() decorator docstring, which still enumerates the old, narrower contract

Extended reasoning...

src/mcp/server/mcpserver/server.py lines 921-924 (MCPServer.prompt) still read "The function returns the prompt messages (a string, Message, dict, or a sequence of these)" — the exact enumeration this PR made stale by commit 2251112, which also accepts bare ContentBlock/Image/Audio items (handled at src/mcp/server/mcpserver/prompts/base.py:196-197 and documented in from_function's docstring at base.py:102). The decorator docstring is the primary user-facing surface (IDE tooltip for @ mcp.prompt()), so users reading it will keep wrapping media in .to_image_content()/UserMessage or conclude the new forms are unsupported. Concrete cost: public-API documentation now contradicts shipped behavior, violating AGENTS.md's rule that public APIs have accurate docstrings and that user-visible behaviour changes update the relevant docs in the same PR (CLAUDE.md -> AGENTS.md). Distinct from the prior docs/servers/prompts.md finding: editing that docs page would not fix this docstring.

Verification: nit — the claim is factually true. This PR widened the prompt return contract (src/mcp/server/mcpserver/prompts/base.py:65 _PromptResultItem = str | ContentBlock | Image | Audio | Message | dict[str, Any]; base.py:196-197 elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message) and updated Prompt.from_function's docstring (base.py:102 "A string, content

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant