Repository navigation
Description
Summary
A tool result larger than 1 MiB can never reach a client on an HTTP transport. The client opens every SSE stream with httpx2's default DEFAULT_MAX_EVENT_SIZE_BYTES = 1024 * 1024:
- in
mcp/client/sse.py, throughclient.sse(url); - in
mcp/client/streamable_http.py, throughEventSource(response)in_handle_sse_response, the path a POST's response (and so atools/callresult) takes.
Neither sse_client nor streamable_http_client takes a size parameter. A custom http_client cannot change it either, because the size is a per-call argument of client.sse(...) / EventSource(...), not client configuration. The same result is delivered intact over stdio.
The failures also differ by transport:
- SSE: the call fails with
MCPError(-32000, 'Connection closed'), and the transport's task group then dies withSSEError('Server-sent event exceeded the 1048576 byte limit.'), so the whole session is lost. - Streamable HTTP: the call fails with
MCPError(-32000, 'SSE stream ended without a response'), which does not mention the size limit; the session survives.
Reproducer (self-contained: an MCPServer on loopback and the SDK clients)
import asyncio
import socket
import threading
import time
import uvicorn
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from mcp.server.mcpserver import MCPServer
server = MCPServer(name="big-result")
@server.tool()
async def big(megabytes: int) -> str:
"""Return `megabytes` MiB of text."""
return "x" * (megabytes * 1024 * 1024)
def serve(app) -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
uv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
threading.Thread(target=uv.run, daemon=True).start()
while not uv.started:
time.sleep(0.05)
return port
async def call(label: str, transport_cm) -> None:
try:
async with transport_cm as streams, ClientSession(streams[0], streams[1]) as session:
await session.initialize()
for mb in (0, 2):
try:
result = await asyncio.wait_for(session.call_tool("big", {"megabytes": mb}), 30)
print(f" {label} {mb} MiB: ok, is_error={result.is_error}")
except BaseException as exc:
print(f" {label} {mb} MiB: {type(exc).__name__}: {exc!r:.160}")
except BaseException as exc:
print(f" {label} session exit: {type(exc).__name__}: {exc!r:.200}")
async def main() -> None:
sse_port = serve(server.sse_app())
http_port = serve(server.streamable_http_app())
await call("sse", sse_client(f"http://127.0.0.1:{sse_port}/sse"))
await call("streamable-http", streamable_http_client(f"http://127.0.0.1:{http_port}/mcp"))
asyncio.run(main())Output:
sse 0 MiB: ok, is_error=False
sse 2 MiB: MCPError: MCPError(-32000, 'Connection closed', None)
sse session exit: ExceptionGroup: ExceptionGroup('unhandled errors in a TaskGroup', [SSEError('Server-sent event exceeded the 1048576 byte limit.')])
streamable-http 0 MiB: ok, is_error=False
streamable-http 2 MiB: MCPError: MCPError(-32000, 'SSE stream ended without a response')
Expected vs actual
| Expected | Actual | |
|---|---|---|
| Large results | delivered up to a size the caller can configure (for example max_event_size on sse_client / streamable_http_client, passed through to client.sse(...) and EventSource(...)) |
hard 1 MiB cap per SSE event, with no way to change it |
| When the cap is hit | the call fails with an error that names the limit; the session survives | SSE: the session dies; streamable HTTP: a generic "stream ended" error with no mention of size |
Servers that return documents, graph results or large search results routinely exceed 1 MiB.
Environment
mcp2.0.0,httpx22.13.0,anyio4.14.2,uvicorn(from the SDK's dependency set)- CPython 3.14.10
- Windows 11. Linux not tried; the limit comes from
httpx2's default and is platform-independent.
Workaround
None at the transport level. Downstream, the result-size limit is now an explicit host setting: oversized or failed calls are turned into a structured tool error naming the failure class, and each session is isolated so a transport failure cannot cancel the caller.
Activity
Shanmukh-l33t commentedon Nov 4, 2026
Half of this is already fixed upstream — the remaining gap is the legacy SSE transport, and I have a tested fix ready.
What landed since this report: the Streamable HTTP half shipped in #3600 (merged 2026-10-01, released in v2.3.0 on 2026-10-02):
streamable_http_client(url, max_sse_event_size=...)andStreamableHttpParameters.max_sse_event_size, default unchanged at 1 MiB,Nonedisables. The tested version above (2.0.0, 2026-07-28) predates it. On currentmain(af4c6f4) the reproducer's streamable-http leg passes 2 MiB fine with the limit raised, and with the default the one oversized call fails without killing the session.What is still broken (the SSE leg of the repro):
mcp/client/sse.pyhas no size parameter at all —sse_within_originhardcodes the 1 MiB default (mcp/shared/_httpx_utils.py), so a >1 MiB result still fails withMCPError(-32000, 'Connection closed')and takes the whole session with it. Prior art for the shape: #3338 covered every client SSE reader but was closed by its author after #3600 landed (it wasn't a maintainer rejection — the legacy path was just left out of scope of #3600).Fix, tested, on my fork:
fix/sse-client-max-event-size(99e6d2d4):sse_client(..., max_sse_event_size: int | None = DEFAULT_MAX_SSE_EVENT_SIZE)— same name, default andNonesemantics asstreamable_http_client; nonpositive values raiseValueError("max_sse_event_size must be positive or None")before any request, matchingStreamableHTTPTransport. Passed throughsse_within_originto theEventSource.DEFAULT_MAX_SSE_EVENT_SIZEmoves tomcp.shared._httpx_utilsas the single definition both transports share (streamable_httpkeeps re-exporting it).docs/client/transports.mdnotes the option onsse_client.Testing evidence:
TypeError: sse_client() got an unexpected keyword argument 'max_sse_event_size') and delivers intact with the limit raised (sabotage run without the src change: 3 failed / 3 passed — the 3 seam guards pass by design). Seam tests atsse_within_originpin the unchanged default (oversized event raiseshttpx2.SSEError, never truncated), a raised limit, andNone, plus theValueErrorcases.test_permission_error, which fails identically on pristinemainin a root environment (unrelated).coverage report: 100.00%, 0 partial branches (fail_under = 100passes).strict-no-coverclean.ruff check/ruff format --checkandpyrightclean.One observation from the repro: on the SSE death path the transport leaks its internal memory streams (unclosed
MemoryObjectSendStream/MemoryObjectReceiveStreamwarnings at GC time) — consistent with "the whole session is lost" being unclean teardown. Worth folding into whoever takes the error-semantics half (the report's ask that the error name the limit is still open; the streamable side already prefixesSSE stream failed:and lets httpx2's message name the limit).I'm not opening a PR here since unassigned-contributor PRs get auto-closed by the repo policy — the branch is ready for a maintainer to take as-is, or I'm happy to open a PR if invited.
AI disclosure: investigation, fix and tests were produced with AI assistance and reviewed and run by me.
media-ctrl commentedon Nov 4, 2026
Part of this is fixed on main already, so it is worth splitting the report.
Streamable HTTP has the knob now:
streamable_http_client(...)takesmax_sse_event_size(src/mcp/client/streamable_http.py:718, defaultDEFAULT_MAX_SSE_EVENT_SIZE), the transport stores it and threads it into everyclient.sse(..., max_event_size=...)andEventSource(response, max_event_size=...)call.Nonedisables the limit, so a largetools/callresult does get through on that transport.The legacy SSE client does not have it.
sse_client(src/mcp/client/sse.py:36) takes url, headers, the two timeouts, a client factory, auth and a session callback, and nothing for event size. A result over 1 MiB still fails there withSSEError('Server-sent event exceeded the 1048576 byte limit.')and takes the session with it, which is the failure you describe.Two things would close the rest:
sse_client, threaded intoclient.sse(...), so both transports accept the same event sizes;SSE stream ended without a response, and neither names the cause. A size rejection is a client-side limit, not a server fault, and a named error makes it diagnosable from the traceback instead of the transport log.On the limit itself: leaving the default at 1 MiB is fine as long as it is overridable, but it is worth one sentence in the client docs, because a tool that returns a screenshot or a CSV will cross it and the two failure modes above are hard to attribute to the transport.
lennardm commentedon Nov 4, 2026
Same here on mcp 2.2.0 / httpx2 2.12.0 (Windows 11, Python 3.13): a ~1.19 MB
tools/callresult from a remote streamable-HTTP MCP server failed deterministically withSSE stream ended without a response. A raw HTTP capture showed the server sent one complete, valid event; theSSEErroronly shows up withmcpdebug logging on.We work around it by wrapping
mcp.client.streamable_http.EventSourceso it defaults tomax_event_size=32 * 1024 * 1024. Amax_sse_event_sizeoption onstreamable_http_client(and surfacing theSSEErrortext in the synthesized error) would let us drop that monkeypatch.Shanmukh-l33t commentedon Nov 4, 2026
@lennardm both of your asks are already on
mainsince 2.2.0 — I diffedv2.2.0againstmainto be sure:streamable_http_client(..., max_sse_event_size=DEFAULT_MAX_SSE_EVENT_SIZE)(src/mcp/client/streamable_http.py:718onmain;Nonedisables the limit) is threaded into everyclient.sse(...)/EventSource(...)call in the transport, so theEventSourcemonkeypatch can go as soon as you're past 2.2.0.mainhas anexcept httpx2.SSEError as exc:branch synthesizing"SSE stream failed: {exc}"(:503-506), so the waiter learns "Server-sent event exceeded the 1048576 byte limit." Inv2.2.0there is no SSEError branch at all — the stream falls through to the generic handler and the waiter gets the bare"SSE stream ended without a response"(:499there), which is the confusing failure your capture shows. That matches why it only appeared with debug logging on.What's genuinely still open matches @media-ctrl's split:
sse_client(src/mcp/client/sse.py:36) still has no event-size knob onmain— that's what my branch adds (fix/sse-client-max-event-sizeat99e6d2d4933697942796bdf83118fd9e09eaea2a, compare: main...Shanmukh-l33t:python-sdk:fix/sse-client-max-event-size): the same parameter threaded intoclient.sse(...), sharingDEFAULT_MAX_SSE_EVENT_SIZE, so both transports accept the same event sizes. Thesse_clientside kills the session on overflow rather than failing one call, which is the asymmetry worth closing.SSEErrortext, and the same overflow manifests differently per transport.(Written with AI assistance; the version diff and line references were re-verified against
mainandv2.2.0today.)