Repository navigation
A lone surrogate in an outgoing message silently kills the stdio server's writer or the client session #3707
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
A Python str that contains a lone surrogate cannot be encoded as UTF-8 JSON. Such strings are easy to get: os.fsdecode() on a file name that is not valid UTF-8 returns one, and so do open(..., errors="surrogateescape") and decoded argv or environment values. The string passes validation and model_dump(mode="json") and only fails in the last step, model_dump_json() inside the transport's writer task. That failure is not contained, so one bad string breaks the connection.
- Server, stdio.
stdout_writer(src/mcp/server/stdio.py) raisesPydanticSerializationError: ... 'utf-8' codec can't encode character '\udcff' ... surrogates not allowedand ends. The process stays alive and never answers anything again (I watched it for 30 seconds). The call times out, or never returns if no read timeout is set, and later calls fail withConnection closed. - Client, every transport.
stdin_writer(src/mcp/client/stdio.py, and the HTTP post path) fails the same way. The call and all later calls fail (CancelledErrororConnection closed), and leaving theClientblock raisesExceptionGroup.
Expected: the failing message costs only itself, for example an error for that request, or the character escaped as JSON allows (JavaScript's JSON.stringify("\udcff") returns "\udcff").
Where it happens, measured at protocol 2026-07-28 on main. The stdio results are the same at 2025-11-25, and on PyPI mcp 2.3.0 and 1.30.0:
| A surrogate in | stdio | Streamable HTTP |
|---|---|---|
tool result, resource text, prompt text, resources/list name, tools/list description, completion/complete values, log or progress message, elicitation request message (all server to client) |
the server stops answering, the next call fails with Connection closed |
that call fails, the session survives (after a tools/list description the next call fails too) |
| tool argument, prompt argument, completion argument, elicitation reply (all client to server) | client session torn down | client session torn down |
resource URI passed to read_resource |
that call raises UnicodeEncodeError |
same |
tool result -> dict[str, str], a tool's exception message |
fine | fine |
Other serialization failures I tried on the low-level server (an object() value, non-UTF-8 bytes) come back as Internal server error and the session stays alive. The lone surrogate is the value that gets through every earlier stage and fails only at the final encode.
Related but not the same: #3522 and #3525 (the HTTP client dies on one failed POST, same failure shape), #3097 (this stdout_writer, for BrokenPipeError), #2454 (decode direction), #3554 (-> bytes structured output). I found no issue or PR that mentions surrogates, and no open PR that changes how the writers handle a failed serialization.
Example Code
import os
import sys
import anyio
from mcp import Client
from mcp.client.stdio import StdioServerParameters
if len(sys.argv) > 1 and sys.argv[1] == "server":
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("files")
@mcp.tool()
def list_dir() -> list[str]:
# stand-in for a directory listing with one file name that is not valid UTF-8
return [os.fsdecode(b"ok.txt"), os.fsdecode(b"report-\xff.txt")]
@mcp.tool()
def ping() -> str:
return "pong"
@mcp.tool()
def echo(text: str) -> str:
return text
mcp.run("stdio")
sys.exit(0)
SERVER = StdioServerParameters(command=sys.executable, args=[__file__, "server"])
async def attempt(label: str, client: Client, name: str, args: dict | None = None) -> None:
try:
result = await client.call_tool(name, args or {}, read_timeout_seconds=5)
print(f" {label:34} ok: {result.content[0].text[:30]!r}")
except BaseException as exc:
print(f" {label:34} {type(exc).__name__}: {str(exc)[:60]}")
async def main() -> None:
print("1. the tool RESULT contains a lone surrogate")
async with Client(SERVER) as client:
await attempt("list_dir", client, "list_dir")
await attempt("ping (healthy tool)", client, "ping")
print("2. a tool ARGUMENT contains a lone surrogate")
try:
async with Client(SERVER) as client:
await attempt("echo(os.fsdecode(b'a\\xffb'))", client, "echo", {"text": os.fsdecode(b"a\xffb")})
await attempt("ping (healthy tool)", client, "ping")
except BaseException as exc:
print(f" leaving the Client block raised {type(exc).__name__}")
anyio.run(main)Output (the server's traceback goes to stderr and is omitted):
1. the tool RESULT contains a lone surrogate
list_dir MCPError: Request 'tools/call' timed out
ping (healthy tool) MCPError: Connection closed
2. a tool ARGUMENT contains a lone surrogate
echo(os.fsdecode(b'a\xffb')) CancelledError: Cancelled via cancel scope 7f04c6665db0
ping (healthy tool) MCPError: Connection closed
leaving the Client block raised ExceptionGroup
Python & MCP Python SDK
Linux. main@754254c0 (mcp 2.3.1.dev9) on Python 3.14.8. Also PyPI mcp 2.3.0 on Python 3.12.
AI assistance: I found and reproduced this with Claude Code, which wrote and ran the scripts, ran the comparison against the TypeScript and JavaScript behavior, and drafted this text. I read the output and checked the claims above before filing.
Activity
naveecode commentedon Oct 14, 2026
I reproduced this on current main:
model_dump(mode="json")accepts a lone surrogate, thenmodel_dump_json()raisesPydanticSerializationErrorinside the transport writer and ends the task group (stdio server writer / client writer). Escaping as\udcffis not viable on the wire here — pydantic’s JSON parser rejects lone-surrogate escapes on the far side.Approach: contain the failure in the writers (same idea as other unserializable values becoming
Internal server errorhigher up). Shared helperserialize_outbound_message:Internal server errorand keep writingCovering stdio server + client, SSE, and streamable HTTP event/JSON framing. Opening a PR shortly with regression tests.
AI-assisted drafting; I verified the repro, the pydantic parse limitation, and the tests.