MCPServer: content-block returns are unstructured, prompt messages take Image/Audio - #3320
Conversation
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.
📚 Documentation preview
|
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 thePrompt.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 bareImage/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 butPrompt.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)](orreturn Image(path)). render() hits theelsebranch 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 ("contentmay be ... anImageor
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.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/mcp/server/mcpserver/server.py— [quality] nit: thestructured_outputdocstring block is duplicated in three places, and only thefunc_metadatacopy was updated with the new content-block rule —MCPServer.add_tool()(server.py:594-597) andMCPServer.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 internalfunc_metadata()docstring (func_metadata.py:235-237). A user readinghelp(mcp.tool)or the IDE hover foradd_tool/tool— the only docstrings users actually see — gets the pre-change semantics and has no pointer to thestructured_output=Trueoverride; 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 theelsebranch 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
There was a problem hiding this comment.
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 fallbackelsebranch, which JSON-dumps the object withfallback=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 theelsebranch 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
…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.
|
Dispositions for the summary-level findings: the |
…e from content origins; prompt() docstring - Annotated[X, meta...]: only X is a type, so recurse into it alone (and cover the nested-Annotated shape with a test). - Iterable[...] values are typically generators, which _convert_to_content does not unroll, so the annotation no longer counts as content; Sequence stays because its runtime value is a list or tuple. - @mcp.prompt() docstring lists the bare content forms render() now accepts.
…once - Drop the per-item try/except in render(): it only re-raised as 'Could not convert prompt result to message: <repr>' and hid the real error (e.g. a missing media file) one level deeper; the outer handler already reports 'Error rendering prompt X: ...'. - message_validator validates the UserMessage | AssistantMessage union left to right. Both classes accept either role, so smart mode always landed on the first arm anyway, after converting (and, for path-backed Image/Audio, reading) the content for both. - Docs: the content rule sentence names Sequence alongside list/tuple.
…(Raises: OSError) No-Verification-Needed: docstring-only change
| 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 |
There was a problem hiding this comment.
🟡 [quality] Rewritten render() conversion loop re-adds else: # pragma: no cover on the JSON-dump fallback — the only pragma line the AGENTS.md diff audit flags — even though the branch is trivially testable and the PR presents this fallback as retained, documented behavior.
Extended reasoning...
Concrete cost: a documented library-code behavior (a prompt returning a non-content value such as an int or a BaseModel is JSON-dumped via pydantic_core.to_json into a user text message, src/mcp/server/mcpserver/prompts/base.py:205-207) stays permanently excluded from the repo's 100%-coverage gate. CLAUDE.md -> AGENTS.md states 'Avoid adding new # pragma: no cover ... comments' and 'In library code (src/), a # pragma: no cover needs very good reasoning — it usually means a test is missing', and its audit command git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' flags exactly this added line. The diff already deleted the sibling except Exception: # pragma: no cover and added tests for every other branch of the loop (Message, dict, str, ContentBlock, Image, Audio); a one-line test returning e.g. 42 from a prompt would cover this branch and let the pragma be removed, keeping the fallback's behavior (and any future regression in it) actually pinned.
Verification: nit — src/mcp/server/mcpserver/prompts/base.py:205 in the rewritten loop reads else: # pragma: no cover followed by the JSON-dump fallback (pydantic_core.to_json(msg, fallback=str, indent=2)), and running the AGENTS.md audit (git diff <base>..HEAD | grep -E '^\+.*(pragma|type: ignore|noqa)') flags exactly this line as an added pragma. The branch is trivially testable: validate_call (line
| - 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) |
There was a problem hiding this comment.
🟡 [quality] nit: in-code docs for the new content rule omit Sequence: the structured_output docstring bullet (lines 244-246, "bare or inside a list, tuple or union") and _returns_content's own docstring (lines 42-43, "items of a list/tuple") both understate _CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence) on line 38. The review round fixed only docs/servers/structured-output.md (which now correctly says "list, tuple or Sequence"); the docstrings shipped in this same diff were not swept.
Extended reasoning...
Concrete cost: the func_metadata docstring is the reference for the public structured_output parameter, and _returns_content's docstring explicitly instructs "Keep the two in sync" while itself being out of sync with the constant one line above it. A maintainer or user reading either docstring concludes -> Sequence[TextContent] derives a schema when it is actually opted out, and a future edit guided by the docstring (e.g. tightening the origins to list/tuple to "match the docs") would silently change advertised outputSchema for existing Sequence-annotated tools. Fix: mention Sequence in both docstrings, matching the already-corrected wording in docs/servers/structured-output.md.
Verification: nit — the claim is factually accurate. In /home/claude/python-sdk/src/mcp/server/mcpserver/utilities/func_metadata.py, line 38 defines _CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence) and line 50 uses it, so -> Sequence[TextContent] is opted out of structured output. Yet both in-code docs added by this diff omit Sequence: the _returns_content docstring (lines 42-44) says "bare or as th
|
|
||
| ## 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 was a problem hiding this comment.
🟡 [quality] nit: stale claim "There is one way to end up unstructured without asking for it" now contradicts the new "Content blocks and media" section added two paragraphs above on the same page, which introduces a second default opt-out path (content-block/Image/Audio return annotations derive no schema). The PR updated the Recap bullet at line 247 to list both opt-outs ("Content blocks, Image and Audio opt out by default; a class without type hints opts out silently") but left the section opener asserting the annotation-less class is the only such path.
Extended reasoning...
Concrete cost: the published structured-output page contradicts itself. A reader who lands on the "A class without type hints" section (or skims from its heading) is told the only way to get an unstructured tool without passing structured_output=False is an annotation-less class, and will not suspect that their -> EmbeddedResource / -> list[TextContent] tool also silently stopped advertising outputSchema after this release — exactly the behaviour change the PR calls out for release notes. Fix is one sentence: reword the opener (e.g. "Besides content blocks, there is one more way...") so the two sections on the same page agree.
Verification: nit — the factual basis checks out. docs/servers/structured-output.md line 217 still reads "There is one way to end up unstructured without asking for it: return a class that has no annotations on its body." — pre-existing text the PR did not touch — while the new "Content blocks and media" section added two paragraphs above (lines 211–213) introduces a second default path to an unstructur
| else: # pragma: no cover | ||
| content = pydantic_core.to_json(msg, fallback=str, indent=2).decode() | ||
| messages.append(Message(role="user", content=content)) |
There was a problem hiding this comment.
🟣 Pre-existing, surfaced by this rewrite: a prompt function returning the wire type mcp_types.PromptMessage (the type GetPromptResult actually carries, and what lowlevel-server prompt handlers return) falls through the rewritten conversion loop to the JSON-dump fallback: it is not a mcpserver Message, not a dict, and not in the new str | ContentBlock | Image | Audio arm, so pydantic_core.to_json(msg) stringifies it and Message(role="user", content=...) hardcodes the role. The loop was just widened to accept "bare content the way a tool does" (content blocks, Image, Audio) but omits PromptMessage, whose .role/.content map 1:1 onto Message.
Extended reasoning...
A user migrating a lowlevel-server prompt handler (or following the spec's vocabulary) writes @ mcp.prompt()\ndef p(): return PromptMessage(role="assistant", content=TextContent(type="text", text="hi")). Instead of an assistant message with a text block, the client silently receives a USER-role message whose text is the JSON dump '{"role": "assistant", "content": {"type": "text", ...}}' — the declared assistant role is dropped and the content arrives as a JSON string blob. No error is raised, so the corruption goes unnoticed until the LLM output looks wrong. One elif isinstance(msg, PromptMessage): messages.append(Message(role=msg.role, content=msg.content)) arm (or including PromptMessage in the widened union) fixes it.
Verification: pre-existing — behavior predates this PR (the old loop also JSON-dumped anything that wasn't str/Message/dict), but the PR rewrites this exact conversion loop and widens the accepted types, so the gap is squarely in reviewed code. The claim checks out line by line in /home/claude/python-sdk/src/mcp/server/mcpserver/prompts/base.py: the loop at lines 198-207 handles isinstance(msg, Message) (the
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 hasImage/Audioas its list/tuple items no longer advertisesoutputSchemaand no longer returnsstructuredContent; itscontentis unchanged. Passstructured_output=Trueto keep the old shape.Motivation and Context
1. Content-block,
ImageandAudioreturn annotations are unstructured (func_metadata)@mcp.tool() def f() -> EmbeddedResourcepublished the pydantic schema of theEmbeddedResourceclass itself (~2 KB) as the tool'soutputSchemaand echoed the block intostructuredContenta second time. Same for-> ResourceLink,-> TextContent,-> list[ContentBlock],tuple[...], unions.-> Imageescaped only becauseImageis a plain class, and-> list[Image]/-> list[str | Image]/-> Image | Audiodidn't register at all (PydanticSchemaGenerationErrorfromcreate_model, outside the existingtry).In auto-detect mode (
structured_output=None), a return annotation that declares content blocks or theImage/Audiohelpers — bare, as the items of alist/tuple/Sequence, or as the arms of a union (throughAnnotated/Optional) — now derives no output schema. That is the annotation-level mirror of what_convert_to_contentalready does with those values at runtime; mapping values and model fields are data and keep their schema exactly as before.structured_output=Truestill 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 (onmainthat spelling failed every call unlessstructured_contentwas hand-built).Prompt.from_functionandResourceTemplate.from_functiononly 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 passstructured_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 writeImage(...).to_image_content().Message.__init__now does the same conversionstralready 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 astr/Message/dict was JSON-dumped (anImagearrived as itsrepr). 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, and conversion errors (e.g. an unreadable media file) now reach the existingError rendering prompt ...handler instead of being re-wrapped asCould not convert prompt result to message: <repr>.3. Prompt message classes exported from
mcp.server.mcpserver(__init__.py)Message,UserMessage,AssistantMessageare re-exported next toImage/Audio, so the prompt examples import everything from one place. (An earlier revision also letadd_prompt()take a function likeadd_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_changedThe comment claimed the server can't handle the notification; the lowlevel
Serverhason_roots_list_changedandtests/interaction/lowlevel/test_roots.pydrives it. (The runtime deprecation warning currently fires once per decorated layer for theClient->ClientSessiondelegations and forctx.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?
func_metadatacases (bare block,list[ContentBlock],list[str | Image],tuple[Audio, ...],Annotated[CallToolResult, list[TextContent]]), thestructured_output=Trueoverride, a model with a content-block field staying structured;Image/AudioinUserMessage/AssistantMessage.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 anImage; bareImage/EmbeddedResource/[str, Image]prompt returns;tests/docs_src/test_structured_output.pyproves the new page section.test_tool_mixed_contentflips tostructured_content is None;test_tool_mixed_list_with_audio_and_imagegets its real annotation back and loses a TODO plus threetype: ignores.mainthe module fails to import (-> list[str | Image]),report/blocksadvertiseoutputSchema, and the image prompt is an internal error; on this branchtools/listshows nooutputSchemafor the content tools (and still one forstructured_output=Trueand adict[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
outputSchemaand stop returningstructuredContent(theircontentis 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'stest_image_content/test_audio_content/test_embedded_resource/test_multiple_content_types; the conformance scenarios only assert oncontent, so they are unaffected. Anyone who wants the old shape passesstructured_output=True.docs/servers/structured-output.mdgets two sentences so the published page stays accurate.Types of changes
Checklist
Additional context
Not done here, noted as follow-ups:
MCPDeprecationWarningonce per layer (Client.set_logging_level/subscribe_resource/unsubscribe_resource/send_progress_notification/send_roots_list_changed, andContext.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.)type X = ...(PEP 695) andNewTypereturn annotations are not unwrapped by the content rule, by the existingInputRequiredResultstripping, or byAnnotated[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_schemabuilds its wrapper models outside thetry, so-> list[SomePlainClass]on a tool (andstructured_output=Truewith-> list[Image], ordict[str, Image]) still raises a rawPydanticSchemaGenerationErrorinstead of falling back / raisingInvalidSignature.Image/Audioare read and base64-encoded on the event loop wherever they are converted:Tool.run->convert_result->_convert_to_content(since v1),Message.__init__inside an async prompt function, and now render's bare-content arm. Fix once, in the helpers or inside the existing worker-thread hop, for tools and prompts together.UserMessageandAssistantMessageboth declarerole: Literal["user", "assistant"](as in v1), so a dict-form{"role": "assistant", ...}materialises asUserMessage(role="assistant")— wire output is right,isinstance(msg, AssistantMessage)is not. The validator is now left-to-right so content is converted once. Narrowing each subclass to its own literal and discriminating onroleworks at runtime but trips pyright strict (reportIncompatibleVariableOverride), so the real fix is a small hierarchy reshape plus a decision on whetherUserMessage(x, role="assistant")should keep constructing.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