Skip to content

Commit 6015f97

Browse files
committed
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: <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.
1 parent 6f95028 commit 6015f97

3 files changed

Lines changed: 29 additions & 15 deletions

File tree

docs/servers/structured-output.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ The opposite, `structured_output=True`, turns the automatic detection into a req
210210

211211
## Content blocks and media
212212

213-
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.
213+
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.
214214

215215
## A class without type hints
216216

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import functools
66
from collections.abc import Awaitable, Callable, Sequence
7-
from typing import TYPE_CHECKING, Any, Literal
7+
from typing import TYPE_CHECKING, Annotated, Any, Literal
88

99
import anyio.to_thread
1010
import pydantic_core
@@ -60,7 +60,11 @@ def __init__(self, content: str | ContentBlock | Image | Audio, **kwargs: Any):
6060
super().__init__(content=content, **kwargs)
6161

6262

63-
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
63+
# Both classes accept either role, so the first arm always matches: validate left to right rather than
64+
# trying both (which converted - and for path-backed Image/Audio, read - the content twice).
65+
message_validator: TypeAdapter[UserMessage | AssistantMessage] = TypeAdapter(
66+
Annotated[UserMessage | AssistantMessage, Field(union_mode="left_to_right")]
67+
)
6468

6569
_PromptResultItem = str | ContentBlock | Image | Audio | Message | dict[str, Any]
6670
SyncPromptResult = _PromptResultItem | InputRequiredResult | Sequence[_PromptResultItem]
@@ -188,18 +192,15 @@ async def render(
188192
# Convert result to messages
189193
messages: list[Message] = []
190194
for msg in result: # type: ignore[reportUnknownVariableType]
191-
try:
192-
if isinstance(msg, Message):
193-
messages.append(msg)
194-
elif isinstance(msg, dict):
195-
messages.append(message_validator.validate_python(msg))
196-
elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
197-
messages.append(UserMessage(msg))
198-
else: # pragma: no cover
199-
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
200-
messages.append(Message(role="user", content=content))
201-
except Exception: # pragma: no cover
202-
raise ValueError(f"Could not convert prompt result to message: {msg}")
195+
if isinstance(msg, Message):
196+
messages.append(msg)
197+
elif isinstance(msg, dict):
198+
messages.append(message_validator.validate_python(msg))
199+
elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
200+
messages.append(UserMessage(msg))
201+
else: # pragma: no cover
202+
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
203+
messages.append(Message(role="user", content=content))
203204

204205
return messages
205206
except MCPError:

tests/server/mcpserver/prompts/test_base.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import threading
2+
from pathlib import Path
23
from typing import Any
34

45
import pytest
@@ -316,3 +317,15 @@ def fn() -> Any:
316317
return returned
317318

318319
assert await Prompt.from_function(fn).render(None, Context()) == expected
320+
321+
322+
@pytest.mark.anyio
323+
async def test_prompt_returning_media_with_an_unreadable_file_fails_to_render(tmp_path: Path) -> None:
324+
"""SDK-defined: a bare `Image` whose file cannot be read fails the render (an error for the client)
325+
instead of degrading to a text message."""
326+
327+
def fn() -> Image:
328+
return Image(path=tmp_path / "missing.png")
329+
330+
with pytest.raises(ValueError):
331+
await Prompt.from_function(fn).render(None, Context())

0 commit comments

Comments
 (0)