Skip to content

A lone surrogate in an outgoing message silently kills the stdio server's writer or the client session #3707

Open

Description

@Yatsuiii

Initial Checks

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) raises PydanticSerializationError: ... 'utf-8' codec can't encode character '\udcff' ... surrogates not allowed and 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 with Connection 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 (CancelledError or Connection closed), and leaving the Client block raises ExceptionGroup.

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

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 14, 2026
  2. naveecode commented on Oct 14, 2026

    @naveecode

    I reproduced this on current main: model_dump(mode="json") accepts a lone surrogate, then model_dump_json() raises PydanticSerializationError inside the transport writer and ends the task group (stdio server writer / client writer). Escaping as \udcff is 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 error higher up). Shared helper serialize_outbound_message:

    • response/error with an id → substitute Internal server error and keep writing
    • request (client) → do not tear down the session; surface a local JSON-RPC error for that id
    • notification / id-less → drop and continue

    Covering 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions