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

This commit is contained in:
Max 2026-08-17 14:12:31 +01:00 • committed by GitHub
commit fb443cc4b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 290 additions and 56 deletions

View file

@ -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]

View file

@ -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",

View file

@ -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:

View file

@ -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()

View file

@ -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).

View file

@ -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 []