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.)SalyyS1 commentedon Nov 11, 2026
While
streamable_http_client(..., max_sse_event_size=...)landed onmain(andsse_clientstill needs the same parameter threaded throughmcp/client/sse.py), it is worth noting what happens one layer up when atools/callresponse actually exceeds1 MiB:A
1.19 MBJSON-RPCtools/callpayload corresponds to roughly 280,000 to 320,000 tokens, which will immediately overflow the context window of most LLM providers (128k to 200k tokens) right after the SSE transport delivers it. In practice, payloads over 1 MiB almost always come from pretty-printed JSON arrays with repetitivenull/""/[]fields, noisy hypermedia URLs, or un-truncated log output.If you are connecting to an MCP server whose tool responses occasionally spike above
1 MiB(or if you are stuck onmcp <= 2.2.0/ legacy SSE), you can placemcp-slimin front of the server command:mcp-slim'sOutputSlimmerinterceptstools/callresponses on the wire, strips empty/null keys and hypermedia noise, minifies JSON/JSONL/Markdown tables, flattens verbose resource wrappers, and applies head+tail elision on oversized string fields—typically shrinking multi-megabyte JSON tool outputs well below the1048576-byte SSE event cap while cutting LLM context token consumption by 43% to 75%.