Skip to content

stdio_client: receiving a large response takes quadratic time (buffer + chunk is re-copied for every chunk) #3699

Open

Description

@youprint

Summary

stdio_client takes quadratic time to receive a large response. A reply is one JSON line, and the stdout reader copies its whole buffer again for every chunk it reads, so the cost grows with the square of the message size.

Reproduction

Only the SDK's own server and client are used. The server has one tool that returns a string of kb kilobytes; the client calls it with 1, 4, 8 and 16 MB.

# sdk_repro.py  (python sdk_repro.py)
import asyncio, sys, time

if len(sys.argv) > 1 and sys.argv[1] == "server":
    from mcp.server.mcpserver import MCPServer
    app = MCPServer("big")

    @app.tool()
    def big(kb: int) -> str:
        return "x" * (kb * 1024)

    app.run("stdio")
    sys.exit(0)

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def client():
    params = StdioServerParameters(command=sys.executable, args=[__file__, "server"])
    async with stdio_client(params) as (r, w):
        async with ClientSession(r, w) as s:
            await s.initialize()
            for mb in (1, 4, 8, 16):
                t = time.perf_counter()
                res = await s.call_tool("big", {"kb": mb * 1024})
                print(f"reply {mb:>2} MB: {time.perf_counter() - t:7.2f} s")

asyncio.run(client())

Result (mcp 2.3.0, anyio 4.15.1, Python 3.14.7, Windows 11)

Reply size Time
1 MB 0.53 s (the first call)
4 MB 0.41 s
8 MB 1.35 s
16 MB 5.39 s

Going from 8 to 16 MB multiplies the time by 4. With a non-Python server (a Rust MCP server writing one 32 MB reply) the same client needed about 20 s, while reading the same bytes from the pipe with plain readline() took 0.23 s, so the server and the pipe are not the cause.

Cause

src/mcp/client/stdio.py, in stdout_reader (lines 145 to 153 in v2.3.0):

buffer = ""
async for chunk in stdout:
    lines = (buffer + chunk).split("\n")
    buffer = lines.pop()

A response without a line break in it arrives in many chunks. Each iteration builds buffer + chunk (a copy of everything received so far) and splits it, so receiving N bytes in fixed-size chunks costs O(N²).

Proposed change

Keep the pieces of an unfinished line in a list and join them once, when a chunk contains a line break:

parts: list[str] = []
async for chunk in stdout:
    if "\n" not in chunk:
        parts.append(chunk)  # no line end yet: keep the piece, do not copy the buffer again
        continue
    lines = ("".join(parts) + chunk).split("\n")
    parts = [lines.pop()]
    # ... the rest of the loop unchanged

I applied exactly this change to a copy of stdio.py and ran the same script against it:

Reply size Time with the change
1 MB 0.18 s
4 MB 0.08 s
8 MB 0.14 s
16 MB 0.27 s

(16 MB: 5.39 s before, 0.27 s after.) I have not run the SDK's test suite with the change.

Notes

  • I only checked v2.3.0 (the latest release at the time of writing) and the current main source of stdio.py. I did not check 1.x.
  • Other transports were not examined.
  • I'm happy to open a pull request if you want one.

Activity

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

    @maxiaochaos

    Taking this — will PR a list-buffer join for stdio_client so large responses stay linear. Aiming for a PR in a few days.

  3. BruceWayne733 commented on Oct 14, 2026

    @BruceWayne733

    Reproduced on main (754254c), Windows 11, Python 3.12.12, anyio 4.14.2, using the same server/client shape as above with a 32 MB step added:

    Reply size main with the proposed parts change
    1 MB 1.30 s 0.21 s
    4 MB 0.77 s 0.09 s
    8 MB 3.05 s 0.16 s
    16 MB 12.95 s 0.36 s
    32 MB 45.72 s 0.79 s

    A few things the report left open:

    • Tests: with the change, tests/client/test_stdio.py passes (27 passed, 8 skipped on Windows) and src/mcp/client/stdio.py stays at 100% line and branch coverage, so the existing tests already exercise the new no-newline branch.
    • 1.x: v1.x has the same loop (src/mcp/client/stdio/__init__.py, lines 150-151), so this looks like a backport candidate if it counts as critical there.
    • Server side: src/mcp/server/stdio.py reads with async for line in stdin over a text wrapper, i.e. readline, so large requests to a server don't hit this.

    @youprint you offered to open the PR, so it's yours. If you'd rather not, I'm happy to put it up with a regression test.

    (Disclosure: I used an AI assistant for the investigation and benchmark. The numbers come from my machine, and I've reviewed the change and can explain it.)

  4. maxiaochaos commented on Oct 14, 2026

    @maxiaochaos

    Confirmed the O(N²) path on current main — same (buffer + chunk).split loop.

    Hit this with large tool replies over stdio (multi‑MB single JSON line). List-buffer join keeps framing the same and drops the quadratic copies; I already have a branch + regression test ready if maintainers want it.

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