Skip to content

Commit 255bbec

Browse files
committed
fix(server): base64-encode bytes in structured tool output
A tool annotated `-> bytes` publishes output_schema {"result": {"type": "string", "format": "binary"}}, but real binary data crashed the conversion: pydantic decodes bytes as UTF-8 when serializing to JSON, so the first non-UTF-8 byte raised PydanticSerializationError and the client only saw a generic "Error executing tool" with the payload lost. UTF-8-decodable bytes came back as a raw string, and a bytes field inside an output model hit the same crash. Encode bytes as base64 — the encoding every other bytes path in this package already uses (BlobResourceContents, Image/Audio content) — in both channels: the structured dump and the unstructured content. A payload without bytes takes the plain JSON dump, byte for byte. Fixes #3554
1 parent 6affe5c commit 255bbec

3 files changed

Lines changed: 193 additions & 6 deletions

File tree

‎src/mcp/server/mcpserver/utilities/func_metadata.py‎

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import functools
23
import inspect
34
import json
@@ -217,11 +218,7 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
217218
# The tool hands back Python-side names; the wire (and outputSchema) use aliases.
218219
adapter = self._output_adapter(output_model)
219220
validated = adapter.validate_python(result, by_alias=True, by_name=True)
220-
if isinstance(validated, BaseModel):
221-
# Dump via the instance so a returned subclass keeps its own fields.
222-
structured_content = validated.model_dump(mode="json", by_alias=True)
223-
else:
224-
structured_content = adapter.dump_python(validated, mode="json", by_alias=True)
221+
structured_content = _dump_structured(validated, adapter)
225222

226223
return CallToolResult(content=unstructured_content, structured_content=structured_content)
227224

@@ -621,6 +618,69 @@ def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]:
621618
return create_model(model_name, result=annotation)
622619

623620

621+
def _base64_encode(data: bytes | bytearray | memoryview) -> str:
622+
return base64.b64encode(bytes(data)).decode("ascii")
623+
624+
625+
def _has_bytes(value: Any) -> bool:
626+
"""Whether a python-mode dump (or a raw value) carries bytes anywhere."""
627+
if isinstance(value, bytes | bytearray | memoryview):
628+
return True
629+
if isinstance(value, dict):
630+
return any(_has_bytes(item) for item in cast("dict[Any, Any]", value).values())
631+
if isinstance(value, list | tuple | set | frozenset):
632+
return any(_has_bytes(item) for item in cast("list[Any]", value))
633+
if isinstance(value, BaseModel):
634+
return _has_bytes(value.model_dump(mode="python", by_alias=True))
635+
return False
636+
637+
638+
def _json_safe(value: Any) -> Any:
639+
"""Base64-encode bytes leaves; JSON-encode every other leaf as mode="json" would."""
640+
if isinstance(value, bytes | bytearray | memoryview):
641+
return _base64_encode(cast("bytes | bytearray | memoryview", value))
642+
if isinstance(value, dict):
643+
return {key: _json_safe(item) for key, item in cast("dict[Any, Any]", value).items()}
644+
if isinstance(value, list | tuple | set | frozenset):
645+
return [_json_safe(item) for item in cast("list[Any]", value)]
646+
if isinstance(value, BaseModel):
647+
return _json_safe(value.model_dump(mode="python", by_alias=True))
648+
if value is None or isinstance(value, str | int | float | bool):
649+
return value
650+
return json.loads(pydantic_core.to_json(value, fallback=str))
651+
652+
653+
def _result_json_text(value: Any) -> str:
654+
"""JSON text for an unstructured result, base64-encoding bytes leaves."""
655+
if _has_bytes(value):
656+
value = _json_safe(value)
657+
return pydantic_core.to_json(value, fallback=str, indent=2).decode()
658+
659+
660+
def _dump_structured(validated: Any, adapter: TypeAdapter[Any]) -> Any:
661+
"""Dump validated structured output as JSON, base64-encoding bytes leaves.
662+
663+
``mode="json"`` decodes bytes as UTF-8 and raises on binary data, so a
664+
payload carrying bytes is dumped in Python mode with those leaves
665+
base64-encoded first — the encoding every other bytes path in this package
666+
uses (BlobResourceContents, Image/Audio content). A payload without bytes
667+
takes the plain JSON dump, byte for byte.
668+
"""
669+
dumped = (
670+
# Dump via the instance so a returned subclass keeps its own fields.
671+
validated.model_dump(mode="python", by_alias=True)
672+
if isinstance(validated, BaseModel)
673+
else adapter.dump_python(validated, mode="python", by_alias=True)
674+
)
675+
if _has_bytes(dumped):
676+
return _json_safe(dumped)
677+
return (
678+
validated.model_dump(mode="json", by_alias=True)
679+
if isinstance(validated, BaseModel)
680+
else adapter.dump_python(validated, mode="json", by_alias=True)
681+
)
682+
683+
624684
def _convert_to_content(result: Any) -> list[ContentBlock]:
625685
"""Convert a result to a sequence of content objects.
626686
@@ -649,7 +709,11 @@ def _convert_to_content(result: Any) -> list[ContentBlock]:
649709
)
650710
)
651711

712+
if isinstance(result, bytes | bytearray | memoryview):
713+
# Binary payloads cannot survive JSON verbatim; publish them base64-encoded.
714+
return [TextContent(type="text", text=_base64_encode(cast("bytes | bytearray | memoryview", result)))]
715+
652716
if not isinstance(result, str):
653-
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
717+
result = _result_json_text(result)
654718

655719
return [TextContent(type="text", text=result)]

‎tests/server/mcpserver/test_func_metadata.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# pyright: reportMissingParameterType=false
44
# pyright: reportUnknownArgumentType=false
55
# pyright: reportUnknownLambdaType=false
6+
import base64
67
from collections.abc import Callable
78
from dataclasses import dataclass
89
from typing import TYPE_CHECKING, Annotated, Any, Final, NamedTuple, TypedDict
@@ -724,6 +725,102 @@ def func_bytes() -> bytes: # pragma: no cover
724725
}
725726

726727

728+
PNG_MAGIC = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
729+
730+
731+
def test_bytes_result_is_base64_encoded_in_both_channels():
732+
"""Binary tool results are delivered base64-encoded in both channels.
733+
734+
Pins the #3554 fix: mode="json" decodes bytes as UTF-8 and used to raise on
735+
the first non-UTF-8 byte, failing the whole tool call.
736+
"""
737+
738+
def read_thumbnail() -> bytes: # pragma: no cover
739+
return PNG_MAGIC
740+
741+
meta = func_metadata(read_thumbnail)
742+
encoded = base64.b64encode(PNG_MAGIC).decode()
743+
744+
result = meta.convert_result(PNG_MAGIC)
745+
746+
assert isinstance(result, CallToolResult)
747+
assert not result.is_error
748+
assert result.structured_content == {"result": encoded}
749+
assert len(result.content) == 1
750+
assert isinstance(result.content[0], TextContent)
751+
assert result.content[0].text == encoded
752+
753+
754+
def test_utf8_bytes_result_is_base64_encoded():
755+
"""UTF-8-decodable bytes take the same base64 encoding as binary bytes."""
756+
757+
def read_note() -> bytes: # pragma: no cover
758+
return b"hello"
759+
760+
meta = func_metadata(read_note)
761+
762+
result = meta.convert_result(b"hello")
763+
764+
assert isinstance(result, CallToolResult)
765+
assert result.structured_content == {"result": base64.b64encode(b"hello").decode()}
766+
767+
768+
def test_bytes_field_in_output_model_is_base64_encoded():
769+
"""A bytes field inside an output model is base64-encoded, not a crash."""
770+
771+
class Thumb(BaseModel):
772+
data: bytes
773+
774+
def get_thumbnail() -> Thumb: # pragma: no cover
775+
return Thumb(data=PNG_MAGIC)
776+
777+
meta = func_metadata(get_thumbnail)
778+
779+
result = meta.convert_result(Thumb(data=PNG_MAGIC))
780+
781+
assert isinstance(result, CallToolResult)
782+
assert not result.is_error
783+
assert result.structured_content == {"data": base64.b64encode(PNG_MAGIC).decode()}
784+
785+
786+
def test_bytes_inside_generic_result_is_base64_encoded():
787+
"""bytes nested in a generic result are base64-encoded leaf by leaf."""
788+
789+
def two_blobs() -> list[bytes]: # pragma: no cover
790+
return [PNG_MAGIC, b"hello"]
791+
792+
def blob_map() -> dict[str, bytes]: # pragma: no cover
793+
return {"a": PNG_MAGIC}
794+
795+
listed = func_metadata(two_blobs).convert_result([PNG_MAGIC, b"hello"])
796+
mapped = func_metadata(blob_map).convert_result({"a": PNG_MAGIC})
797+
798+
assert isinstance(listed, CallToolResult)
799+
assert listed.structured_content == {
800+
"result": [base64.b64encode(PNG_MAGIC).decode(), base64.b64encode(b"hello").decode()]
801+
}
802+
assert isinstance(mapped, CallToolResult)
803+
assert mapped.structured_content == {"a": base64.b64encode(PNG_MAGIC).decode()}
804+
805+
806+
def test_result_without_bytes_is_serialized_unchanged():
807+
"""Payloads without bytes keep the plain JSON serialization, byte for byte."""
808+
809+
def get_note() -> str: # pragma: no cover
810+
return "hello"
811+
812+
def get_count() -> int: # pragma: no cover
813+
return 7
814+
815+
noted = func_metadata(get_note).convert_result("hello")
816+
counted = func_metadata(get_count).convert_result(7)
817+
818+
assert isinstance(noted, CallToolResult)
819+
assert noted.structured_content == {"result": "hello"}
820+
assert isinstance(counted, CallToolResult)
821+
assert counted.structured_content == {"result": 7}
822+
823+
727824
def test_structured_output_generic_types():
728825
"""Test structured output with generic types (list, dict, Union, etc.)"""
729826

‎tests/server/mcpserver/test_integration.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# pyright: reportUnknownVariableType=false
1010
# pyright: reportUnknownArgumentType=false
1111

12+
import base64
1213
import json
1314

1415
import pytest
@@ -47,6 +48,7 @@
4748
tool_progress,
4849
)
4950
from mcp.client import Client, ClientRequestContext, IncomingMessage
51+
from mcp.server.mcpserver import MCPServer
5052

5153
pytestmark = pytest.mark.anyio
5254

@@ -342,3 +344,27 @@ async def test_structured_output() -> None:
342344
assert "sunny" in result_text # condition
343345
assert "45" in result_text # humidity
344346
assert "5.2" in result_text # wind_speed
347+
348+
349+
async def test_binary_tool_result_is_base64_encoded() -> None:
350+
"""A tool returning binary bytes delivers base64 in both channels, not an error.
351+
352+
Pins the #3554 fix: mode="json" used to raise on non-UTF-8 bytes, so the
353+
tool call failed before the payload reached the client.
354+
"""
355+
server = MCPServer("Binary Result")
356+
357+
@server.tool()
358+
def read_thumbnail() -> bytes:
359+
"""Return PNG magic bytes."""
360+
return b"\x89PNG\r\n\x1a\n\x00\x00"
361+
362+
async with Client(server) as client:
363+
result = await client.call_tool("read_thumbnail", {})
364+
365+
encoded = base64.b64encode(b"\x89PNG\r\n\x1a\n\x00\x00").decode()
366+
assert not result.is_error
367+
assert result.structured_content == {"result": encoded}
368+
assert len(result.content) == 1
369+
assert isinstance(result.content[0], TextContent)
370+
assert result.content[0].text == encoded

0 commit comments

Comments
 (0)