MCPServer: content-block returns are unstructured, prompt messages take Image/Audio (#3320)
This commit is contained in:
parent
37b3cb1ef7
commit
fb443cc4b2
6 changed files with 290 additions and 56 deletions
|
|
@ -946,5 +946,4 @@ class Client:
|
|||
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
||||
async def send_roots_list_changed(self) -> None:
|
||||
"""Send a notification that the roots list has changed."""
|
||||
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
|
||||
await self.session.send_roots_list_changed() # pyright: ignore[reportDeprecated]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from mcp.server.request_state import (
|
|||
)
|
||||
|
||||
from .context import Context
|
||||
from .prompts.base import AssistantMessage, Message, UserMessage
|
||||
from .resolve import (
|
||||
AcceptedElicitation,
|
||||
CancelledElicitation,
|
||||
|
|
@ -32,6 +33,9 @@ __all__ = [
|
|||
"Context",
|
||||
"Image",
|
||||
"Audio",
|
||||
"Message",
|
||||
"UserMessage",
|
||||
"AssistantMessage",
|
||||
"Icon",
|
||||
"Resolve",
|
||||
"Elicit",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import functools
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
||||
|
||||
import anyio.to_thread
|
||||
import pydantic_core
|
||||
|
|
@ -13,6 +13,7 @@ from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
|||
|
||||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
|
||||
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
||||
from mcp.server.mcpserver.utilities.types import Audio, Image
|
||||
from mcp.shared._callable_inspection import is_async_callable
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
|
|
@ -22,14 +23,26 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""Base class for all prompt messages."""
|
||||
"""Base class for all prompt messages.
|
||||
|
||||
`content` may be a plain string (wrapped in `TextContent`), an `Image` or `Audio`
|
||||
helper (converted to `ImageContent` / `AudioContent`, reading the file for path-backed
|
||||
helpers), or any ready-made content block.
|
||||
|
||||
Raises:
|
||||
OSError: If a path-backed `Image` or `Audio` cannot be read.
|
||||
"""
|
||||
|
||||
role: Literal["user", "assistant"]
|
||||
content: ContentBlock
|
||||
|
||||
def __init__(self, content: str | ContentBlock, **kwargs: Any):
|
||||
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
|
||||
if isinstance(content, str):
|
||||
content = TextContent(type="text", text=content)
|
||||
elif isinstance(content, Image):
|
||||
content = content.to_image_content()
|
||||
elif isinstance(content, Audio):
|
||||
content = content.to_audio_content()
|
||||
super().__init__(content=content, **kwargs)
|
||||
|
||||
|
||||
|
|
@ -38,7 +51,7 @@ class UserMessage(Message):
|
|||
|
||||
role: Literal["user", "assistant"] = "user"
|
||||
|
||||
def __init__(self, content: str | ContentBlock, **kwargs: Any):
|
||||
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
|
||||
super().__init__(content=content, **kwargs)
|
||||
|
||||
|
||||
|
|
@ -47,13 +60,18 @@ class AssistantMessage(Message):
|
|||
|
||||
role: Literal["user", "assistant"] = "assistant"
|
||||
|
||||
def __init__(self, content: str | ContentBlock, **kwargs: Any):
|
||||
def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
|
||||
super().__init__(content=content, **kwargs)
|
||||
|
||||
|
||||
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
|
||||
# Both classes accept either role, so the first arm always matches: validate left to right rather than
|
||||
# trying both (which converted - and for path-backed Image/Audio, read - the content twice).
|
||||
message_validator: TypeAdapter[UserMessage | AssistantMessage] = TypeAdapter(
|
||||
Annotated[UserMessage | AssistantMessage, Field(union_mode="left_to_right")]
|
||||
)
|
||||
|
||||
SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]]
|
||||
_PromptResultItem = str | ContentBlock | Image | Audio | Message | dict[str, Any]
|
||||
SyncPromptResult = _PromptResultItem | InputRequiredResult | Sequence[_PromptResultItem]
|
||||
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
|
||||
|
||||
|
||||
|
|
@ -89,7 +107,7 @@ class Prompt(BaseModel):
|
|||
"""Create a Prompt from a function.
|
||||
|
||||
The function can return:
|
||||
- A string (converted to a message)
|
||||
- A string, content block, `Image` or `Audio` (each becomes a user message)
|
||||
- A Message object
|
||||
- A dict (converted to a message)
|
||||
- A sequence of any of the above
|
||||
|
|
@ -105,10 +123,9 @@ class Prompt(BaseModel):
|
|||
if context_kwarg is None: # pragma: no branch
|
||||
context_kwarg = find_context_parameter(fn)
|
||||
|
||||
# Get schema from func_metadata, excluding context parameter
|
||||
# Only the argument model is needed; a prompt has no output schema to derive
|
||||
func_arg_metadata = func_metadata(
|
||||
fn,
|
||||
skip_names=[context_kwarg] if context_kwarg is not None else [],
|
||||
fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
|
||||
)
|
||||
parameters = func_arg_metadata.arg_model.model_json_schema()
|
||||
|
||||
|
|
@ -179,19 +196,15 @@ class Prompt(BaseModel):
|
|||
# Convert result to messages
|
||||
messages: list[Message] = []
|
||||
for msg in result: # type: ignore[reportUnknownVariableType]
|
||||
try:
|
||||
if isinstance(msg, Message):
|
||||
messages.append(msg)
|
||||
elif isinstance(msg, dict):
|
||||
messages.append(message_validator.validate_python(msg))
|
||||
elif isinstance(msg, str):
|
||||
content = TextContent(type="text", text=msg)
|
||||
messages.append(UserMessage(content=content))
|
||||
else: # pragma: no cover
|
||||
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
|
||||
messages.append(Message(role="user", content=content))
|
||||
except Exception: # pragma: no cover
|
||||
raise ValueError(f"Could not convert prompt result to message: {msg}")
|
||||
if isinstance(msg, Message):
|
||||
messages.append(msg)
|
||||
elif isinstance(msg, dict):
|
||||
messages.append(message_validator.validate_python(msg))
|
||||
elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
|
||||
messages.append(UserMessage(msg))
|
||||
else: # pragma: no cover
|
||||
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
|
||||
messages.append(Message(role="user", content=content))
|
||||
|
||||
return messages
|
||||
except MCPError:
|
||||
|
|
|
|||
|
|
@ -152,10 +152,9 @@ class ResourceTemplate(BaseModel):
|
|||
if context_kwarg is None: # pragma: no branch
|
||||
context_kwarg = find_context_parameter(fn)
|
||||
|
||||
# Get schema from func_metadata, excluding context parameter
|
||||
# Only the argument model is needed; a resource has no output schema to derive
|
||||
func_arg_metadata = func_metadata(
|
||||
fn,
|
||||
skip_names=[context_kwarg] if context_kwarg is not None else [],
|
||||
fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
|
||||
)
|
||||
parameters = func_arg_metadata.arg_model.model_json_schema()
|
||||
|
||||
|
|
|
|||
|
|
@ -918,8 +918,8 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
) -> Callable[[_CallableT], _CallableT]:
|
||||
"""Decorator to register a prompt.
|
||||
|
||||
The function returns the prompt messages (a string, `Message`, dict,
|
||||
or a sequence of these), or an `InputRequiredResult` to request
|
||||
The function returns the prompt messages (a string, content block, `Image`/`Audio`,
|
||||
`Message`, dict, or a sequence of these), or an `InputRequiredResult` to request
|
||||
client input first (the 2026-07-28 multi-round-trip flow — read
|
||||
`ctx.input_responses` on the retry).
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@ def _is_input_required_type(obj: Any) -> bool:
|
|||
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)
|
||||
|
||||
|
||||
_CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio)
|
||||
# `_convert_to_content` unrolls list/tuple values; a `Sequence[...]` annotation is one of those at runtime.
|
||||
_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence)
|
||||
|
||||
|
||||
def _returns_content(annotation: Any) -> bool:
|
||||
"""Whether a return annotation declares content blocks or the `Image`/`Audio` helpers, bare or as
|
||||
the items of a list/tuple or the arms of a union: the values `_convert_to_content` renders as blocks
|
||||
rather than dumping as data. Keep the two in sync."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is None:
|
||||
return isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES)
|
||||
if origin is Annotated:
|
||||
return _returns_content(get_args(annotation)[0])
|
||||
if is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS:
|
||||
return any(_returns_content(arg) for arg in get_args(annotation))
|
||||
return False
|
||||
|
||||
|
||||
class StrictJsonSchema(GenerateJsonSchema):
|
||||
"""A JSON schema generator that raises exceptions instead of emitting warnings.
|
||||
|
||||
|
|
@ -222,6 +241,9 @@ def func_metadata(
|
|||
- TypedDict - converted to a Pydantic model with same fields
|
||||
- Dataclasses and other annotated classes - converted to Pydantic models
|
||||
- Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field
|
||||
- Content blocks (TextContent, EmbeddedResource, ...), Image and Audio, bare or inside a
|
||||
list, tuple or union - unstructured when auto-detecting; structured_output=True bypasses
|
||||
this rule (a content block then publishes its own schema; Image/Audio have none and raise)
|
||||
|
||||
Returns:
|
||||
A FuncMetadata object containing:
|
||||
|
|
@ -345,6 +367,13 @@ def func_metadata(
|
|||
else:
|
||||
original_annotation = effective_annotation
|
||||
|
||||
if structured_output is None and _returns_content(return_type_expr):
|
||||
# Content blocks and the Image/Audio helpers are what the model reads, not data for the
|
||||
# application: a derived schema would advertise the block's own model as output_schema (and,
|
||||
# unless the tool builds its own CallToolResult, echo every block into structured_content).
|
||||
# structured_output=True still forces one.
|
||||
return FuncMetadata(arg_model=arguments_model)
|
||||
|
||||
output_model, output_schema, wrap_output = _try_create_model_and_schema(
|
||||
original_annotation, return_type_expr, func.__name__
|
||||
)
|
||||
|
|
@ -546,7 +575,7 @@ def _convert_to_content(result: Any) -> list[ContentBlock]:
|
|||
Note: This conversion logic comes from previous versions of MCPServer and is being
|
||||
retained for purposes of backwards compatibility. It produces different unstructured
|
||||
output than the lowlevel server tool call handler, which just serializes structured
|
||||
content verbatim.
|
||||
content verbatim. `_returns_content` is the annotation-level mirror of these branches.
|
||||
"""
|
||||
if result is None: # pragma: no cover
|
||||
return []
|
||||
|
|
|
|||
Loading…
Reference in a new issue