Skip to content

fix(server): base64-encode bytes in structured tool output - #3555

Closed
siye566 wants to merge 1 commit into
modelcontextprotocol:mainfrom
siye566:fix/bytes-structured-output-base64
Closed

siye566 wants to merge 1 commit into
modelcontextprotocol:mainfrom
siye566:fix/bytes-structured-output-base64

Conversation

@siye566

@siye566 siye566 commented Sep 21, 2026

Copy link
Copy Markdown

Fixes #3554

Motivation and Context

A tool annotated -> bytes publishes output_schema {"result": {"type": "string", "format": "binary"}}, but real binary data crashed the conversion instead of reaching the client: pydantic decodes bytes as UTF-8 when serializing to JSON, so the first non-UTF-8 byte raised PydanticSerializationError inside _convert_to_content (and UnicodeDecodeError from model_dump(mode="json")), and the client only saw a generic Error executing tool <name> with the payload lost. UTF-8-decodable bytes came back as a raw string rather than an encoding, and a bytes field inside an output model (e.g. class Thumb(BaseModel): data: bytes) hit the same crash.

This encodes bytes leaves as base64 — the encoding every other bytes path in this package already uses (BlobResourceContents at server.py:475, and 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.

How Has This Been Tested?

  • uv run --frozen pytest tests/server/mcpserver/test_func_metadata.py tests/server/mcpserver/test_integration.py: 74 passed, including 6 new tests (binary and UTF-8 -> bytes in both channels, a bytes field in an output model, list[bytes] / dict[str, bytes], and a no-bytes regression guard) plus an end-to-end in-memory Client(server) call of a -> bytes tool.
  • Full suite (uv run --frozen pytest): 5966 passed, 2 failed. Both failures reproduce on unpatched main on Windows (verified with git stash): test_sse_client_closes_all_streams_on_connection_error and test_safe_join_rejects_symlink_escape (WinError 1314, symlink privilege) — unrelated to this change.
  • ruff check / ruff format --check: clean. pyright (strict): no new errors (34 before, 34 after; all pre-existing Windows-platform diagnostics).

Breaking Changes

None for payloads without bytes. Tools returning bytes now deliver base64 in structuredContent and in their text content, where binary data previously failed the call and UTF-8 bytes previously arrived as a raw string.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I am assigned to the linked issue (or it is labeled help wanted, or I'm a maintainer)
  • I have disclosed any AI assistance and can explain the change in my own words

AI assistance disclosure: the diff was written with the ZCode coding agent; I verified the reproduction on Windows (both failure paths), ran the test suites and linters listed above, and reviewed the serializer behaviour against server.py:475 / Image/Audio before submitting.

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 modelcontextprotocol#3554
@github-actions github-actions Bot added the missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md) label Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been closed automatically. This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue, and you aren't currently assigned to #3554.

If a maintainer assigns you to #3554, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take.

You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way.

CONTRIBUTING.md has the full reasoning, but in short:

  • We're a small team with very little capacity to review community PRs right now.
  • Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.

Maintainers: reopen, remove missing-issue-link, or add bypass-issue-check to override.

@github-actions github-actions Bot closed this Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Structured tool output: -> bytes return crashes with a generic error on non-UTF-8 payloads instead of returning the advertised base64/binary string

1 participant