MCPServer: content-block returns are unstructured, prompt messages take Image/Audio (#3320)
This commit is contained in:
parent
37b3cb1ef7
commit
fb443cc4b2
4 changed files with 290 additions and 56 deletions
|
|
@ -1,18 +1,21 @@
|
|||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
AudioContent,
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
EmbeddedResource,
|
||||
ImageContent,
|
||||
InputRequiredResult,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
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:
|
||||
|
|
@ -243,3 +246,86 @@ async def test_render_passes_input_required_result_through_unchanged():
|
|||
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
|
||||
|
||||
|
||||
@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"]
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
@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())
|
||||
|
|
|
|||
|
|
@ -505,3 +505,18 @@ async def test_create_resource_passes_input_required_result_through_unchanged():
|
|||
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}"
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ from typing import Annotated, Any, Final, NamedTuple, TypedDict
|
|||
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,73 @@ def test_unstructured_output_unannotated_class():
|
|||
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_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
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool",
|
||||
[
|
||||
_returns_block,
|
||||
_returns_blocks,
|
||||
_returns_strings_and_images,
|
||||
_returns_audio_clips,
|
||||
_returns_described_blocks,
|
||||
_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
|
||||
|
||||
|
||||
class _Report(BaseModel):
|
||||
summary: str
|
||||
attachment: EmbeddedResource
|
||||
|
||||
|
||||
def _returns_report() -> _Report:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _returns_blocks_by_key() -> dict[str, TextContent]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@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():
|
||||
def func_returning_call_tool_result() -> CallToolResult:
|
||||
return CallToolResult(content=[])
|
||||
|
|
|
|||
|
|
@ -437,20 +437,8 @@ class TestServerTools:
|
|||
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 @@ class TestServerTools:
|
|||
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 @@ class TestServerTools:
|
|||
]
|
||||
|
||||
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 @@ class TestServerTools:
|
|||
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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue