Skip to content

Commit 105d84c

Browse files
fix: serialize Image and Audio helpers as ImageContent/AudioContent
The Image and Audio helpers in fastmcp.utilities.types are not Pydantic models, so handing one to a Pydantic-driven serializer (for example, model_dump_json on an enclosing model) raises PydanticSerializationError with "Unable to serialize unknown type". Inside the normal FastMCP tool result flow this never happens because _convert_to_content unwraps the helpers into ImageContent / AudioContent first, but anywhere the helpers reach Pydantic directly, serialization fails. Give both helpers a __get_pydantic_core_schema__ that serializes them through their existing to_image_content / to_audio_content methods. The wire shape is unchanged, validation still accepts an Image/Audio instance, and the regular _convert_to_content path is untouched. Add a regression test that returns an Image from a FastMCP tool over a real stateless HTTP transport (json_response=True) and asserts the response carries a valid ImageContent payload. Fixes #2376
1 parent 9773a3f commit 105d84c

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

src/mcp/server/fastmcp/utilities/types.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import base64
44
from pathlib import Path
5+
from typing import Any
6+
7+
from pydantic import GetCoreSchemaHandler
8+
from pydantic_core import CoreSchema, core_schema
59

610
from mcp.types import AudioContent, ImageContent
711

@@ -53,6 +57,23 @@ def to_image_content(self) -> ImageContent:
5357

5458
return ImageContent(type="image", data=data, mimeType=self._mime_type)
5559

60+
@classmethod
61+
def __get_pydantic_core_schema__(
62+
cls,
63+
source_type: Any,
64+
handler: GetCoreSchemaHandler,
65+
) -> CoreSchema:
66+
# Serialize Image as ImageContent so it round-trips through any
67+
# Pydantic-driven JSON encoder (e.g. CallToolResult.model_dump_json).
68+
# Validation accepts an existing Image instance unchanged; new instances
69+
# are constructed through the regular __init__, not via this schema.
70+
return core_schema.no_info_plain_validator_function(
71+
function=lambda value: value,
72+
serialization=core_schema.plain_serializer_function_ser_schema(
73+
lambda instance: instance.to_image_content().model_dump(mode="json", by_alias=True),
74+
),
75+
)
76+
5677

5778
class Audio:
5879
"""Helper class for returning audio from tools."""
@@ -99,3 +120,19 @@ def to_audio_content(self) -> AudioContent:
99120
raise ValueError("No audio data available")
100121

101122
return AudioContent(type="audio", data=data, mimeType=self._mime_type)
123+
124+
@classmethod
125+
def __get_pydantic_core_schema__(
126+
cls,
127+
source_type: Any,
128+
handler: GetCoreSchemaHandler,
129+
) -> CoreSchema:
130+
# Serialize Audio as AudioContent so it round-trips through any
131+
# Pydantic-driven JSON encoder. See ``Image.__get_pydantic_core_schema__``
132+
# for the rationale.
133+
return core_schema.no_info_plain_validator_function(
134+
function=lambda value: value,
135+
serialization=core_schema.plain_serializer_function_ser_schema(
136+
lambda instance: instance.to_audio_content().model_dump(mode="json", by_alias=True),
137+
),
138+
)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Regression test for issue #2376.
2+
3+
Image (and Audio) helpers returned from FastMCP tools must serialize to the
4+
``ImageContent``/``AudioContent`` wire shape, including when stateless HTTP
5+
mode is used by remote MCP clients.
6+
7+
The original report described
8+
``Unable to serialize unknown type: mcp.server.fastmcp.utilities.types.Image``
9+
from ``pydantic_core`` when the helper bypassed ``_convert_to_content`` and
10+
was handed straight to Pydantic's JSON encoder. The fix gives ``Image`` and
11+
``Audio`` a Pydantic core schema so any Pydantic-driven serializer produces
12+
the right shape.
13+
"""
14+
15+
import base64
16+
17+
import httpx
18+
import pytest
19+
from pydantic import BaseModel
20+
21+
from mcp.server.fastmcp import FastMCP
22+
from mcp.server.fastmcp.utilities.types import Audio, Image
23+
24+
25+
class _Holder(BaseModel):
26+
"""Pydantic model used to round-trip helper instances through serialization."""
27+
28+
model_config = {"arbitrary_types_allowed": True}
29+
30+
image: Image | None = None
31+
audio: Audio | None = None
32+
33+
34+
def test_image_serializes_as_image_content_via_pydantic() -> None:
35+
"""Image must serialize as ImageContent when handed to a Pydantic encoder."""
36+
holder = _Holder(image=Image(data=b"hello", format="png"))
37+
dumped = holder.model_dump(mode="json", by_alias=True)["image"]
38+
assert dumped["type"] == "image"
39+
assert dumped["mimeType"] == "image/png"
40+
assert base64.b64decode(dumped["data"]) == b"hello"
41+
42+
43+
def test_audio_serializes_as_audio_content_via_pydantic() -> None:
44+
"""Audio must serialize as AudioContent when handed to a Pydantic encoder."""
45+
holder = _Holder(audio=Audio(data=b"world", format="wav"))
46+
dumped = holder.model_dump(mode="json", by_alias=True)["audio"]
47+
assert dumped["type"] == "audio"
48+
assert dumped["mimeType"] == "audio/wav"
49+
assert base64.b64decode(dumped["data"]) == b"world"
50+
51+
52+
@pytest.mark.anyio
53+
async def test_image_round_trips_through_stateless_http() -> None:
54+
"""Returning Image from a FastMCP tool must produce ImageContent on the wire,
55+
end-to-end, in stateless HTTP mode with JSON responses (the configuration
56+
required by remote MCP clients that cannot maintain session state)."""
57+
mcp = FastMCP("test", host="0.0.0.0", stateless_http=True, json_response=True)
58+
59+
@mcp.tool()
60+
def image_tool() -> Image:
61+
return Image(data=b"hello", format="png")
62+
63+
app = mcp.streamable_http_app()
64+
headers = {
65+
"Accept": "application/json, text/event-stream",
66+
"Content-Type": "application/json",
67+
}
68+
69+
async with app.router.lifespan_context(app):
70+
async with httpx.AsyncClient(
71+
transport=httpx.ASGITransport(app=app),
72+
base_url="http://127.0.0.1",
73+
timeout=10.0,
74+
) as client:
75+
initialize = await client.post(
76+
"/mcp",
77+
json={
78+
"jsonrpc": "2.0",
79+
"id": 1,
80+
"method": "initialize",
81+
"params": {
82+
"protocolVersion": "2025-06-18",
83+
"capabilities": {},
84+
"clientInfo": {"name": "test", "version": "1.0"},
85+
},
86+
},
87+
headers=headers,
88+
)
89+
assert initialize.status_code == 200
90+
91+
call = await client.post(
92+
"/mcp",
93+
json={
94+
"jsonrpc": "2.0",
95+
"id": 2,
96+
"method": "tools/call",
97+
"params": {"name": "image_tool", "arguments": {}},
98+
},
99+
headers=headers,
100+
)
101+
102+
assert call.status_code == 200
103+
body = call.json()
104+
content = body["result"]["content"]
105+
assert len(content) == 1
106+
assert content[0]["type"] == "image"
107+
assert content[0]["mimeType"] == "image/png"
108+
assert base64.b64decode(content[0]["data"]) == b"hello"
109+
assert body["result"]["isError"] is False

0 commit comments

Comments
 (0)