Repository navigation
stdio_client: receiving a large response takes quadratic time (buffer + chunk is re-copied for every chunk) #3699
Description
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 unchangedI 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
maxiaochaos commentedon Oct 14, 2026
Taking this — will PR a list-buffer join for stdio_client so large responses stay linear. Aiming for a PR in a few days.
BruceWayne733 commentedon Oct 14, 2026
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:
partschangeA few things the report left open:
tests/client/test_stdio.pypasses (27 passed, 8 skipped on Windows) andsrc/mcp/client/stdio.pystays at 100% line and branch coverage, so the existing tests already exercise the new no-newline branch.v1.xhas 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.src/mcp/server/stdio.pyreads withasync for line in stdinover 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.)
maxiaochaos commentedon Oct 14, 2026
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.