From 02a0c5e68fd56d3f0a15344fb505eba6223c390f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:08:09 +0000 Subject: [PATCH 01/11] Remove stale TODO from Client.send_roots_list_changed 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. --- src/mcp/client/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index ed7c40f123..b4ceefab24 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -946,5 +946,4 @@ async def list_tools( @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] From 523418995d04f74d215da6c9af0a9b3c38d82b4e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:08:28 +0000 Subject: [PATCH 02/11] Accept Image and Audio helpers as prompt message content 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. --- src/mcp/server/mcpserver/prompts/base.py | 17 +++++++++++++---- tests/server/mcpserver/prompts/test_base.py | 19 ++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 0a010de7d2..abe7502048 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -13,6 +13,7 @@ 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,22 @@ 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`), or any ready-made content block. + """ 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 +47,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,7 +56,7 @@ 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) diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index e88a096ba8..0d20700efd 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -3,15 +3,17 @@ import pytest from mcp_types import ( + AudioContent, ElicitRequest, ElicitRequestFormParams, EmbeddedResource, + ImageContent, InputRequiredResult, TextContent, TextResourceContents, ) -from mcp.server.mcpserver import Context +from mcp.server.mcpserver import Audio, Context, Image from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage @@ -243,3 +245,18 @@ def asking_prompt() -> InputRequiredResult: prompt = Prompt.from_function(asking_prompt) result = await prompt.render(None, Context()) assert result is sentinel + + +@pytest.mark.parametrize( + ("helper", "expected"), + [ + (Image(data=b"img", format="png"), ImageContent(type="image", data="aW1n", mime_type="image/png")), + (Audio(data=b"snd", format="wav"), AudioContent(type="audio", data="c25k", mime_type="audio/wav")), + ], +) +def test_message_converts_image_and_audio_helpers_to_content_blocks( + helper: Image | Audio, expected: ImageContent | AudioContent +) -> None: + """SDK-defined: prompt messages accept the same `Image`/`Audio` helpers tools return.""" + assert UserMessage(helper).content == expected + assert AssistantMessage(content=helper).content == expected From 0be83f5e4f82fd60d66697274209804b72df1c47 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:09:23 +0000 Subject: [PATCH 03/11] 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. --- docs/migration.md | 18 +++++- docs/servers/structured-output.md | 4 +- .../mcpserver/utilities/func_metadata.py | 19 ++++++ tests/server/mcpserver/test_func_metadata.py | 58 ++++++++++++++++++- tests/server/mcpserver/test_server.py | 26 ++------- 5 files changed, 101 insertions(+), 24 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index b094d79f84..7ffa9b4d2b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -677,7 +677,7 @@ All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver. Beyond the changes covered in this section, the everyday `FastMCP` surface carries over to `MCPServer` as-is: - **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)`. -- **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. +- **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. - **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. - **Helpers.** `Image.to_image_content()`, `Audio.to_audio_content()`, and the prompt `Message`, `UserMessage`, and `AssistantMessage` classes. - **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: Declare the handler `async def` to keep it on the event loop. +### Content-block, `Image`, and `Audio` return annotations are unstructured + +A tool whose return annotation mentions a content block (`TextContent`, +`ImageContent`, `AudioContent`, `ResourceLink`, `EmbeddedResource`), `Image`, or +`Audio` anywhere (alone, or inside a `list`, `tuple`, or union) now registers +with no `output_schema` and returns no `structured_content`; `content` is built +exactly as before. v1 (and 2.0) published the block type's own Pydantic schema as +the tool's `output_schema` and echoed the serialized blocks into +`structured_content` a second time, while an annotation with `Image` or `Audio` +inside a generic (`-> list[Image]`) raised at registration (a bare `-> Image` was +already unstructured). + +If a client reads `structured_content` from such a tool, return a model, +`TypedDict`, or `dict` instead, or pass `structured_output=True` to keep +publishing a content block's schema. + ### `MCPServer.call_tool()` returns `CallToolResult` `MCPServer.call_tool()` now returns a `CallToolResult` (or an diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index a146e01442..28dcc3d6b2 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -210,7 +210,7 @@ The opposite, `structured_output=True`, turns the automatic detection into a req ## 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**. +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**. ```python title="server.py" hl_lines="6-9" --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 * 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. * Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application). * What you return is validated against the schema. A mismatch is a tool error, not a corrupt result. -* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it. +* `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. You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**. diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..a5ed0dba30 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -33,6 +33,16 @@ def _is_input_required_type(obj: Any) -> bool: return isinstance(obj, type) and issubclass(obj, InputRequiredResult) +_CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio) + + +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) + + class StrictJsonSchema(GenerateJsonSchema): """A JSON schema generator that raises exceptions instead of emitting warnings. @@ -222,6 +232,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, anywhere in the + annotation - 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 +358,12 @@ def func_metadata( else: original_annotation = effective_annotation + if structured_output is None and _contains_content_type(return_type_expr): + # Content blocks and the Image/Audio helpers are what the model reads, not data for the + # application: deriving a schema would publish the block's own model as output_schema and + # 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__ ) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 62a9612b95..badc7d84e0 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -10,9 +10,10 @@ import annotated_types import pytest from dirty_equals import IsPartialDict -from mcp_types import CallToolResult, InputRequiredResult +from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent from pydantic import BaseModel, Field +from mcp.server.mcpserver import Audio, Image from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.func_metadata import func_metadata @@ -854,6 +855,61 @@ def func_returning_unannotated() -> UnannotatedClass: # pragma: no cover assert meta.output_schema is None +def _returns_block() -> EmbeddedResource: + raise NotImplementedError + + +def _returns_blocks() -> list[ContentBlock]: + raise NotImplementedError + + +def _returns_strings_and_images() -> list[str | Image]: + raise NotImplementedError + + +def _returns_audio_clips() -> tuple[Audio, ...]: + raise NotImplementedError + + +def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResult, list[TextContent]]: + raise NotImplementedError + + +@pytest.mark.parametrize( + "tool", + [ + _returns_block, + _returns_blocks, + _returns_strings_and_images, + _returns_audio_clips, + _returns_call_tool_result_annotated_with_blocks, + ], +) +def test_content_block_return_annotation_yields_no_output_schema(tool: Callable[..., Any]): + """SDK-defined: content blocks and the Image/Audio helpers anywhere in the return annotation are + presentation, not data, so auto-detection derives no output schema (and `list[str | Image]` / + `tuple[Audio, ...]`, which pydantic cannot build a schema for, register instead of raising).""" + assert func_metadata(tool).output_schema is None + + +def test_structured_output_true_overrides_the_content_block_rule(): + """SDK-defined: the explicit flag still publishes the block's own schema for callers who want it.""" + assert func_metadata(_returns_block, structured_output=True).output_model is EmbeddedResource + + +def test_model_with_a_content_block_field_stays_structured(): + """SDK-defined: only the return annotation is inspected for content types, never a model's fields.""" + + class Report(BaseModel): + summary: str + attachment: EmbeddedResource + + def tool() -> Report: + raise NotImplementedError + + assert func_metadata(tool).output_model is Report + + def test_tool_call_result_is_unstructured_and_not_converted(): def func_returning_call_tool_result() -> CallToolResult: return CallToolResult(content=[]) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index bc3fb14918..81b490c544 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -437,20 +437,8 @@ async def test_tool_mixed_content(self): assert isinstance(content3, AudioContent) assert content3.mime_type == "audio/wav" assert content3.data == "def" - assert result.structured_content is not None - assert "result" in result.structured_content - structured_result = result.structured_content["result"] - assert len(structured_result) == 3 - - expected_content = [ - {"type": "text", "text": "Hello"}, - {"type": "image", "data": "abc", "mimeType": "image/png"}, - {"type": "audio", "data": "def", "mimeType": "audio/wav"}, - ] - - for i, expected in enumerate(expected_content): - for key, value in expected.items(): - assert structured_result[i][key] == value + # Content blocks are for the model, not data: no output schema, nothing echoed as structured + assert result.structured_content is None async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): """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): audio_path = tmp_path / "test.wav" audio_path.write_bytes(b"test audio data") - # TODO(Marcelo): It seems if we add the proper type hint, it generates an invalid JSON schema. - # We need to fix this. - def mixed_list_fn() -> list: # type: ignore - return [ # type: ignore + def mixed_list_fn() -> list[str | Image | Audio | dict[str, str] | TextContent]: + return [ "text message", Image(image_path), Audio(audio_path), @@ -475,7 +461,7 @@ def mixed_list_fn() -> list: # type: ignore ] mcp = MCPServer() - mcp.add_tool(mixed_list_fn) # type: ignore + mcp.add_tool(mixed_list_fn) async with Client(mcp) as client: result = await client.call_tool("mixed_list_fn", {}) assert len(result.content) == 5 @@ -501,7 +487,7 @@ def mixed_list_fn() -> list: # type: ignore content5 = result.content[4] assert isinstance(content5, TextContent) assert content5.text == "direct content" - # Check structured content - untyped list with Image objects should NOT have structured output + # Image/Audio/TextContent in the annotation: no output schema, so nothing echoed as structured assert result.structured_content is None async def test_tool_structured_output_basemodel(self): From d26de07100428444cec66fc1da648703c6ca35fc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:09:51 +0000 Subject: [PATCH 04/11] Let MCPServer.add_prompt take a function; export prompt Message classes 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. --- docs/migration.md | 4 +-- src/mcp/server/mcpserver/__init__.py | 4 +++ src/mcp/server/mcpserver/server.py | 39 +++++++++++++++++++++++-- tests/server/mcpserver/test_server.py | 41 +++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 7ffa9b4d2b..6e0718a708 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -668,7 +668,7 @@ All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver. - `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) - `Icon` — from `mcp.server.mcpserver` or `mcp.types` (not a top-level `mcp` export); its `mimeType` field is now `mime_type` per the [snake_case renames](#field-names-changed-from-camelcase-to-snake_case), though the `mimeType=` kwarg still constructs -- `Message`, `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` +- `Message`, `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver` (or `.prompts.base`) - `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` - `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions` @@ -678,7 +678,7 @@ Beyond the changes covered in this section, the everyday `FastMCP` surface carri - **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)`. - **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. -- **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. +- **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 (`add_prompt()` additionally accepts the plain function, like `add_tool()`). - **Helpers.** `Image.to_image_content()`, `Audio.to_audio_content()`, and the prompt `Message`, `UserMessage`, and `AssistantMessage` classes. - **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).) - **Tool internals.** `Tool`, `Tool.from_function()`, `FuncMetadata`, `ArgModelBase`, and `func_metadata()` keep their v1 shapes; the one change is the now-required `context` argument to `Tool.run()`, described [below](#mcpservercall_tool-read_resource-get_prompt-now-accept-a-context-parameter). diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 56d1c23cba..7c9b67e990 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -13,6 +13,7 @@ ) from .context import Context +from .prompts.base import AssistantMessage, Message, UserMessage from .resolve import ( AcceptedElicitation, CancelledElicitation, @@ -32,6 +33,9 @@ "Context", "Image", "Audio", + "Message", + "UserMessage", + "AssistantMessage", "Icon", "Resolve", "Elicit", diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index bc79c44a36..7ff2391eb5 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -890,12 +890,47 @@ def decorator(fn: _CallableT) -> _CallableT: return decorator - def add_prompt(self, prompt: Prompt) -> None: + @overload + def add_prompt(self, prompt: Prompt) -> None: ... + + @overload + def add_prompt( + self, + fn: Callable[..., Any], + /, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + icons: list[Icon] | None = None, + ) -> None: ... + + def add_prompt( + self, + prompt: Prompt | Callable[..., Any], + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + icons: list[Icon] | None = None, + ) -> None: """Add a prompt to the server. + Pass the function that renders the prompt: its name, docstring and parameters become + the prompt's name, description and arguments, exactly as with `@prompt()`. A ready-made + `Prompt` instance is registered as-is. + Args: - prompt: A Prompt instance to add + prompt: The function to register as a prompt, or a `Prompt` instance + name: Optional name for the prompt (defaults to the function name) + title: Optional human-readable title for the prompt + description: Optional description (defaults to the function's docstring) + icons: Optional list of icons for the prompt """ + if not isinstance(prompt, Prompt): + prompt = Prompt.from_function(prompt, name=name, title=title, description=description, icons=icons) + elif any(arg is not None for arg in (name, title, description, icons)): + raise TypeError("name, title, description and icons can only be set when registering a function") self._prompt_manager.add_prompt(prompt) def remove_prompt(self, name: str) -> None: diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 81b490c544..99dd4cd3eb 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -50,6 +50,7 @@ from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage +from mcp.server.mcpserver.prompts.base import Prompt as PromptTemplate # `Prompt` here is the mcp_types wire model from mcp.server.mcpserver.resources import FileResource, FunctionResource from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.subscriptions import ( @@ -2320,6 +2321,46 @@ def test_context_exposes_its_mcp_server() -> None: assert Context(mcp_server=mcp).mcp_server is mcp +def _greet(who: str) -> str: + """Say hi.""" + return f"hi {who}" + + +async def test_add_prompt_registers_a_function_like_the_decorator() -> None: + """SDK-defined: `add_prompt(fn, ...)` derives name, description and arguments as `@prompt()` does.""" + mcp = MCPServer() + mcp.add_prompt(_greet, title="Greeter") + + async with Client(mcp) as client: + [listed] = (await client.list_prompts()).prompts + result = await client.get_prompt("_greet", {"who": "max"}) + + assert (listed.name, listed.title, listed.description) == ("_greet", "Greeter", "Say hi.") + assert [arg.name for arg in listed.arguments or []] == ["who"] + assert result.messages[0].content == TextContent(type="text", text="hi max") + + +async def test_add_prompt_registers_a_prompt_instance_as_is() -> None: + """SDK-defined: a ready-made prompt handed to `add_prompt` (the 2.0 form) is registered exactly as built.""" + mcp = MCPServer() + mcp.add_prompt(prompt=PromptTemplate.from_function(_greet, name="custom")) + [listed] = await mcp.list_prompts() + assert listed.name == "custom" + + +async def test_add_prompt_rejects_overrides_alongside_a_prompt_instance() -> None: + """SDK-defined: the keyword overrides only apply to the function form; passing them alongside a + ready-made prompt is rejected rather than silently ignored.""" + mcp = MCPServer() + prompt: Any = PromptTemplate.from_function(_greet) # Any: the overloads already reject this call statically + with pytest.raises(TypeError) as exc_info: + mcp.add_prompt(prompt, name="renamed") + assert str(exc_info.value) == snapshot( + "name, title, description and icons can only be set when registering a function" + ) + assert await mcp.list_prompts() == [] + + def test_remove_prompt_removes_and_unknown_name_raises() -> None: mcp = MCPServer() From 988fbbf711de39f4ac3d8476c376a70194b60cec Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:17:14 +0000 Subject: [PATCH 05/11] Leave the migration guide alone; these are 2.x fixes, not v1->v2 breaks 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 --- docs/migration.md | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 6e0718a708..b094d79f84 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -668,7 +668,7 @@ All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver. - `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) - `Icon` — from `mcp.server.mcpserver` or `mcp.types` (not a top-level `mcp` export); its `mimeType` field is now `mime_type` per the [snake_case renames](#field-names-changed-from-camelcase-to-snake_case), though the `mimeType=` kwarg still constructs -- `Message`, `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver` (or `.prompts.base`) +- `Message`, `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` - `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` - `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions` @@ -677,8 +677,8 @@ All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver. Beyond the changes covered in this section, the everyday `FastMCP` surface carries over to `MCPServer` as-is: - **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)`. -- **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. -- **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 (`add_prompt()` additionally accepts the plain function, like `add_tool()`). +- **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. +- **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. - **Helpers.** `Image.to_image_content()`, `Audio.to_audio_content()`, and the prompt `Message`, `UserMessage`, and `AssistantMessage` classes. - **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).) - **Tool internals.** `Tool`, `Tool.from_function()`, `FuncMetadata`, `ArgModelBase`, and `func_metadata()` keep their v1 shapes; the one change is the now-required `context` argument to `Tool.run()`, described [below](#mcpservercall_tool-read_resource-get_prompt-now-accept-a-context-parameter). @@ -924,22 +924,6 @@ running on the event-loop thread: Declare the handler `async def` to keep it on the event loop. -### Content-block, `Image`, and `Audio` return annotations are unstructured - -A tool whose return annotation mentions a content block (`TextContent`, -`ImageContent`, `AudioContent`, `ResourceLink`, `EmbeddedResource`), `Image`, or -`Audio` anywhere (alone, or inside a `list`, `tuple`, or union) now registers -with no `output_schema` and returns no `structured_content`; `content` is built -exactly as before. v1 (and 2.0) published the block type's own Pydantic schema as -the tool's `output_schema` and echoed the serialized blocks into -`structured_content` a second time, while an annotation with `Image` or `Audio` -inside a generic (`-> list[Image]`) raised at registration (a bare `-> Image` was -already unstructured). - -If a client reads `structured_content` from such a tool, return a model, -`TypedDict`, or `dict` instead, or pass `structured_output=True` to keep -publishing a content block's schema. - ### `MCPServer.call_tool()` returns `CallToolResult` `MCPServer.call_tool()` now returns a `CallToolResult` (or an From b1f7a298dbcf905addb18bd4ab2d0c69ba28f436 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:33:19 +0000 Subject: [PATCH 06/11] Drop the add_prompt(fn) overload; keep only the Message re-exports 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. --- src/mcp/server/mcpserver/server.py | 39 ++----------------------- tests/server/mcpserver/test_server.py | 41 --------------------------- 2 files changed, 2 insertions(+), 78 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7ff2391eb5..bc79c44a36 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -890,47 +890,12 @@ def decorator(fn: _CallableT) -> _CallableT: return decorator - @overload - def add_prompt(self, prompt: Prompt) -> None: ... - - @overload - def add_prompt( - self, - fn: Callable[..., Any], - /, - *, - name: str | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - ) -> None: ... - - def add_prompt( - self, - prompt: Prompt | Callable[..., Any], - *, - name: str | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - ) -> None: + def add_prompt(self, prompt: Prompt) -> None: """Add a prompt to the server. - Pass the function that renders the prompt: its name, docstring and parameters become - the prompt's name, description and arguments, exactly as with `@prompt()`. A ready-made - `Prompt` instance is registered as-is. - Args: - prompt: The function to register as a prompt, or a `Prompt` instance - name: Optional name for the prompt (defaults to the function name) - title: Optional human-readable title for the prompt - description: Optional description (defaults to the function's docstring) - icons: Optional list of icons for the prompt + prompt: A Prompt instance to add """ - if not isinstance(prompt, Prompt): - prompt = Prompt.from_function(prompt, name=name, title=title, description=description, icons=icons) - elif any(arg is not None for arg in (name, title, description, icons)): - raise TypeError("name, title, description and icons can only be set when registering a function") self._prompt_manager.add_prompt(prompt) def remove_prompt(self, name: str) -> None: diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 99dd4cd3eb..81b490c544 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -50,7 +50,6 @@ from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage -from mcp.server.mcpserver.prompts.base import Prompt as PromptTemplate # `Prompt` here is the mcp_types wire model from mcp.server.mcpserver.resources import FileResource, FunctionResource from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.subscriptions import ( @@ -2321,46 +2320,6 @@ def test_context_exposes_its_mcp_server() -> None: assert Context(mcp_server=mcp).mcp_server is mcp -def _greet(who: str) -> str: - """Say hi.""" - return f"hi {who}" - - -async def test_add_prompt_registers_a_function_like_the_decorator() -> None: - """SDK-defined: `add_prompt(fn, ...)` derives name, description and arguments as `@prompt()` does.""" - mcp = MCPServer() - mcp.add_prompt(_greet, title="Greeter") - - async with Client(mcp) as client: - [listed] = (await client.list_prompts()).prompts - result = await client.get_prompt("_greet", {"who": "max"}) - - assert (listed.name, listed.title, listed.description) == ("_greet", "Greeter", "Say hi.") - assert [arg.name for arg in listed.arguments or []] == ["who"] - assert result.messages[0].content == TextContent(type="text", text="hi max") - - -async def test_add_prompt_registers_a_prompt_instance_as_is() -> None: - """SDK-defined: a ready-made prompt handed to `add_prompt` (the 2.0 form) is registered exactly as built.""" - mcp = MCPServer() - mcp.add_prompt(prompt=PromptTemplate.from_function(_greet, name="custom")) - [listed] = await mcp.list_prompts() - assert listed.name == "custom" - - -async def test_add_prompt_rejects_overrides_alongside_a_prompt_instance() -> None: - """SDK-defined: the keyword overrides only apply to the function form; passing them alongside a - ready-made prompt is rejected rather than silently ignored.""" - mcp = MCPServer() - prompt: Any = PromptTemplate.from_function(_greet) # Any: the overloads already reject this call statically - with pytest.raises(TypeError) as exc_info: - mcp.add_prompt(prompt, name="renamed") - assert str(exc_info.value) == snapshot( - "name, title, description and icons can only be set when registering a function" - ) - assert await mcp.list_prompts() == [] - - def test_remove_prompt_removes_and_unknown_name_raises() -> None: mcp = MCPServer() From 9cc83c931640f7f32f6844679d9863ec29af5726 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:34:11 +0000 Subject: [PATCH 07/11] Review round: mirror _convert_to_content in the content rule; keep prompts 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. --- docs/servers/structured-output.md | 6 +++- src/mcp/server/mcpserver/prompts/base.py | 5 ++- .../server/mcpserver/resources/templates.py | 5 ++- .../mcpserver/utilities/func_metadata.py | 33 +++++++++++------- tests/docs_src/test_structured_output.py | 33 +++++++++++++++++- tests/server/mcpserver/prompts/test_base.py | 34 +++++++++++++++++-- .../resources/test_resource_template.py | 15 ++++++++ tests/server/mcpserver/test_func_metadata.py | 23 ++++++++----- 8 files changed, 123 insertions(+), 31 deletions(-) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index 28dcc3d6b2..ae2987db53 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -208,9 +208,13 @@ No `output_schema`, no wrapping, no validation. `structured_content` is `None` a The opposite, `structured_output=True`, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text. +## Content blocks and media + +Content blocks and media (`TextContent`, `EmbeddedResource`, `Image`, `Audio` and friends, on their own or as the items of a `list` or `tuple` or the arms of a union) are opted out for you: they are for the model to read, so auto-detection derives no schema from them (**[Images, audio & icons](media.md)** covers `Image` and `Audio`). `structured_output=True` still forces one for the content-block classes. + ## A class without type hints -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**. +There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**. ```python title="server.py" hl_lines="6-9" --8<-- "docs_src/structured_output/tutorial009.py" diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index abe7502048..f7efbfa9e9 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -114,10 +114,9 @@ def from_function( 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() diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 096e821d81..2ea99c19b6 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -152,10 +152,9 @@ def from_function( 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() diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index a5ed0dba30..20e2c9be4a 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -1,7 +1,7 @@ import functools import inspect import json -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Iterable, Sequence from itertools import chain from types import GenericAlias from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints @@ -34,13 +34,19 @@ def _is_input_required_type(obj: Any) -> bool: _CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio) +_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence, Iterable) -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) +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 or 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): @@ -232,9 +238,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, anywhere in the - annotation - unstructured when auto-detecting; structured_output=True bypasses this rule - (a content block then publishes its own schema; Image/Audio have none and raise) + - 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: @@ -358,10 +364,11 @@ def func_metadata( else: original_annotation = effective_annotation - if structured_output is None and _contains_content_type(return_type_expr): + 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: deriving a schema would publish the block's own model as output_schema and - # echo every block into structured_content. structured_output=True still forces one. + # 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( @@ -565,7 +572,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 [] diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py index c0b900d2d3..a12e6d2e7d 100644 --- a/tests/docs_src/test_structured_output.py +++ b/tests/docs_src/test_structured_output.py @@ -2,7 +2,7 @@ import pytest from inline_snapshot import snapshot -from mcp_types import TextContent +from mcp_types import EmbeddedResource, ImageContent, TextContent, TextResourceContents from docs_src.structured_output import ( tutorial001, @@ -17,6 +17,7 @@ ) from mcp import Client from mcp.server import MCPServer +from mcp.server.mcpserver import Image from mcp.server.mcpserver.exceptions import InvalidSignature # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -173,6 +174,36 @@ async def test_structured_output_false_opts_out() -> None: ] +async def test_content_blocks_and_media_are_opted_out_of_structured_output() -> None: + """The "Content blocks and media" section: a content-block or `Image`/`Audio` return annotation, bare or + as list items, derives no output schema and no structured content; the blocks are the result.""" + mcp = MCPServer("Reports") + document = EmbeddedResource( + type="resource", resource=TextResourceContents(uri="report://q3", mime_type="text/markdown", text="# Q3") + ) + + @mcp.tool() + def report() -> EmbeddedResource: + return document + + @mcp.tool() + def chart() -> list[str | Image]: + return ["Sales by region:", Image(data=b"png", format="png")] + + async with Client(mcp) as client: + tools = {tool.name: tool for tool in (await client.list_tools()).tools} + assert tools["report"].output_schema is None + assert tools["chart"].output_schema is None + report_result = await client.call_tool("report", {}) + assert (report_result.content, report_result.structured_content) == ([document], None) + chart_result = await client.call_tool("chart", {}) + assert chart_result.structured_content is None + assert chart_result.content == [ + TextContent(type="text", text="Sales by region:"), + ImageContent(type="image", data="cG5n", mime_type="image/png"), + ] + + async def test_class_without_type_hints_is_silently_unstructured() -> None: """tutorial009: a class with no annotations on its body gets no schema, and the model gets a `repr`.""" async with Client(tutorial009.mcp) as client: diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index 0d20700efd..db1ebd64df 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -13,8 +13,8 @@ TextResourceContents, ) -from mcp.server.mcpserver import Audio, Context, Image -from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage +from mcp.server.mcpserver import AssistantMessage, Audio, Context, Image, MCPServer, Message, UserMessage +from mcp.server.mcpserver.prompts.base import Prompt class TestRenderPrompt: @@ -260,3 +260,33 @@ def test_message_converts_image_and_audio_helpers_to_content_blocks( """SDK-defined: prompt messages accept the same `Image`/`Audio` helpers tools return.""" assert UserMessage(helper).content == expected assert AssistantMessage(content=helper).content == expected + + +@pytest.mark.anyio +async def test_prompt_dict_result_accepts_image_helper_as_content() -> None: + """SDK-defined: the dict form is validated through `Message.__init__`, so helpers convert there too.""" + + def fn() -> dict[str, Any]: + return {"role": "user", "content": Image(data=b"img", format="png")} + + assert await Prompt.from_function(fn).render(None, Context()) == [ + UserMessage(ImageContent(type="image", data="aW1n", mime_type="image/png")) + ] + + +class _Slide: + """A plain class pydantic cannot build a schema for.""" + + +@pytest.mark.anyio +async def test_prompt_return_annotation_is_not_run_through_tool_output_schema_derivation() -> None: + """SDK-defined: a prompt only needs its argument model, so an unschematizable return annotation + registers (it used to raise from the tool structured-output machinery).""" + mcp = MCPServer() + + @mcp.prompt() + def deck(topic: str) -> list[_Slide]: + raise NotImplementedError + + [listed] = await mcp.list_prompts() + assert [arg.name for arg in listed.arguments or []] == ["topic"] diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 42a1099537..b27c77a585 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -505,3 +505,18 @@ def ask(topic: str) -> InputRequiredResult: template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}") result = await template.create_resource("ask://databases", {"topic": "databases"}, Context()) assert result is sentinel + + +class _Chart: + """A plain class pydantic cannot build a schema for.""" + + +def test_template_return_annotation_is_not_run_through_tool_output_schema_derivation() -> None: + """SDK-defined: a resource template only needs its argument model, so an unschematizable return + annotation registers (it used to raise from the tool structured-output machinery).""" + + def charts(year: str) -> list[_Chart]: + raise NotImplementedError + + template = ResourceTemplate.from_function(charts, uri_template="charts://{year}") + assert template.uri_template == "charts://{year}" diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index badc7d84e0..6b134594b9 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -897,17 +897,24 @@ def test_structured_output_true_overrides_the_content_block_rule(): assert func_metadata(_returns_block, structured_output=True).output_model is EmbeddedResource -def test_model_with_a_content_block_field_stays_structured(): - """SDK-defined: only the return annotation is inspected for content types, never a model's fields.""" +class _Report(BaseModel): + summary: str + attachment: EmbeddedResource - class Report(BaseModel): - summary: str - attachment: EmbeddedResource - def tool() -> Report: - raise NotImplementedError +def _returns_report() -> _Report: + raise NotImplementedError + + +def _returns_blocks_by_key() -> dict[str, TextContent]: + raise NotImplementedError + - assert func_metadata(tool).output_model is Report +@pytest.mark.parametrize("tool", [_returns_report, _returns_blocks_by_key]) +def test_content_blocks_as_model_fields_or_mapping_values_stay_structured(tool: Callable[..., Any]): + """SDK-defined: the rule mirrors `_convert_to_content`, which renders blocks only when they are the + value itself or list/tuple items; a model field or a mapping value is data and keeps its schema.""" + assert func_metadata(tool).output_schema is not None def test_tool_call_result_is_unstructured_and_not_converted(): From 2251112243f6521b662b9021d438e9514197609c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:34:30 +0000 Subject: [PATCH 08/11] Prompt functions may return bare content blocks, Image or Audio 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. --- src/mcp/server/mcpserver/prompts/base.py | 10 ++++---- tests/server/mcpserver/prompts/test_base.py | 26 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index f7efbfa9e9..7170249ed5 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -62,7 +62,8 @@ def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any): message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage) -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] @@ -98,7 +99,7 @@ def from_function( """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 @@ -192,9 +193,8 @@ async def render( 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)) + 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)) diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index db1ebd64df..bc23086f21 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -290,3 +290,29 @@ def deck(topic: str) -> list[_Slide]: [listed] = await mcp.list_prompts() assert [arg.name for arg in listed.arguments or []] == ["topic"] + + +_PNG = Image(data=b"img", format="png") +_PNG_BLOCK = ImageContent(type="image", data="aW1n", mime_type="image/png") +_DOC = EmbeddedResource( + type="resource", resource=TextResourceContents(uri="file://notes.md", text="notes", mime_type="text/markdown") +) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("returned", "expected"), + [ + (_PNG, [UserMessage(_PNG_BLOCK)]), + (_DOC, [UserMessage(_DOC)]), + (["Look at this:", _PNG], [UserMessage("Look at this:"), UserMessage(_PNG_BLOCK)]), + ], +) +async def test_bare_content_returned_from_a_prompt_becomes_user_messages(returned: Any, expected: list[Message]): + """SDK-defined: what a tool may return bare (a content block, `Image`, `Audio`), a prompt may too; + each item becomes one user message instead of being JSON-dumped into text.""" + + def fn() -> Any: + return returned + + assert await Prompt.from_function(fn).render(None, Context()) == expected From 6f95028f181c682fb26be72219de57302780cb09 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:44:39 +0000 Subject: [PATCH 09/11] Review nits: Annotated recursion looks at the type only; drop Iterable 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. --- src/mcp/server/mcpserver/server.py | 4 ++-- src/mcp/server/mcpserver/utilities/func_metadata.py | 9 ++++++--- tests/server/mcpserver/test_func_metadata.py | 5 +++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index bc79c44a36..70e45329c5 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -918,8 +918,8 @@ def prompt( ) -> 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). diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 20e2c9be4a..2037b860a1 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -1,7 +1,7 @@ import functools import inspect import json -from collections.abc import Awaitable, Callable, Iterable, Sequence +from collections.abc import Awaitable, Callable, Sequence from itertools import chain from types import GenericAlias from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints @@ -34,7 +34,8 @@ def _is_input_required_type(obj: Any) -> bool: _CONTENT_TYPES = (*get_args(ContentBlock), Image, Audio) -_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence, Iterable) +# `_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: @@ -44,7 +45,9 @@ def _returns_content(annotation: Any) -> bool: origin = get_origin(annotation) if origin is None: return isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES) - if origin is Annotated or is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS: + 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 diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 6b134594b9..2dfe5d389d 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -871,6 +871,10 @@ def _returns_audio_clips() -> tuple[Audio, ...]: raise NotImplementedError +def _returns_described_blocks() -> list[Annotated[TextContent, Field(description="one line each")]]: + raise NotImplementedError + + def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResult, list[TextContent]]: raise NotImplementedError @@ -882,6 +886,7 @@ def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResul _returns_blocks, _returns_strings_and_images, _returns_audio_clips, + _returns_described_blocks, _returns_call_tool_result_annotated_with_blocks, ], ) From 6015f97b3150fe9cbab544657a4f55de4f9358bd Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:00:59 +0000 Subject: [PATCH 10/11] Prompt render: let conversion errors surface; validate dict messages once - Drop the per-item try/except in render(): it only re-raised as 'Could not convert prompt result to message: ' 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. --- docs/servers/structured-output.md | 2 +- src/mcp/server/mcpserver/prompts/base.py | 29 +++++++++++---------- tests/server/mcpserver/prompts/test_base.py | 13 +++++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index ae2987db53..510e750faa 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -210,7 +210,7 @@ The opposite, `structured_output=True`, turns the automatic detection into a req ## Content blocks and media -Content blocks and media (`TextContent`, `EmbeddedResource`, `Image`, `Audio` and friends, on their own or as the items of a `list` or `tuple` or the arms of a union) are opted out for you: they are for the model to read, so auto-detection derives no schema from them (**[Images, audio & icons](media.md)** covers `Image` and `Audio`). `structured_output=True` still forces one for the content-block classes. +Content blocks and media (`TextContent`, `EmbeddedResource`, `Image`, `Audio` and friends, on their own, as the items of a `list`, `tuple` or `Sequence`, or as the arms of a union) are opted out for you: they are for the model to read, so auto-detection derives no schema from them (**[Images, audio & icons](media.md)** covers `Image` and `Audio`). `structured_output=True` still forces one for the content-block classes. ## A class without type hints diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 7170249ed5..484ffd0237 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -4,7 +4,7 @@ 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 @@ -60,7 +60,11 @@ 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")] +) _PromptResultItem = str | ContentBlock | Image | Audio | Message | dict[str, Any] SyncPromptResult = _PromptResultItem | InputRequiredResult | Sequence[_PromptResultItem] @@ -188,18 +192,15 @@ async def render( # 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 | 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)) - 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: diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index bc23086f21..9cae30526e 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -1,4 +1,5 @@ import threading +from pathlib import Path from typing import Any import pytest @@ -316,3 +317,15 @@ def fn() -> Any: return returned assert await Prompt.from_function(fn).render(None, Context()) == expected + + +@pytest.mark.anyio +async def test_prompt_returning_media_with_an_unreadable_file_fails_to_render(tmp_path: Path) -> None: + """SDK-defined: a bare `Image` whose file cannot be read fails the render (an error for the client) + instead of degrading to a text message.""" + + def fn() -> Image: + return Image(path=tmp_path / "missing.png") + + with pytest.raises(ValueError): + await Prompt.from_function(fn).render(None, Context()) From 4fadfddab167951ebd9b3762eda415ff53da67f9 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:30:39 +0000 Subject: [PATCH 11/11] Document that path-backed Image/Audio message content reads the file (Raises: OSError) No-Verification-Needed: docstring-only change --- src/mcp/server/mcpserver/prompts/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 484ffd0237..d30c0b3c60 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -26,7 +26,11 @@ class Message(BaseModel): """Base class for all prompt messages. `content` may be a plain string (wrapped in `TextContent`), an `Image` or `Audio` - helper (converted to `ImageContent` / `AudioContent`), or any ready-made content block. + 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"]