You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[v2] Expose the SSE max_event_size setting in Streamable HTTP clients #3332
MCP Python SDK v2.0.0 constructs httpx2.EventSource(response) directly when parsing a Streamable HTTP POST response. Since HTTPX2 2.10, a single SSE event is limited to 1 MiB by default.
When a valid tools/call result is larger than 1 MiB and is returned as one SSE event, HTTPX2 raises SSEError. The SDK catches that transport error and the caller receives the generic MCP error:
SSE stream ended without a response
There is currently no public Streamable HTTP setting that lets callers raise the SSE event-size limit. The transport also creates SSE readers in multiple places: the POST response path constructs EventSource(response) directly, while the GET and reconnection paths call AsyncClient.sse() without a transport-level event-size setting.
What did you expect?
I expected the Streamable HTTP client/transport to expose an SSE event-size setting and apply it consistently to:
POST SSE responses;
the GET stream; and
reconnection streams.
One possible API shape would be a max_event_size argument on streamable_http_client() and StreamableHTTPTransport, matching HTTPX2 terminology. The exact public API is open for maintainer direction.
If an event exceeds the configured limit, the original JSON-RPC request should receive a clear request-scoped error. The already-sent POST should not be replayed, and sibling requests sharing the session should remain usable.
Reproduction
Use an MCP Streamable HTTP server whose tool returns more than 1 MiB of text in a single SSE event, then call that tool with the v2 client:
result=awaitclient.call_tool("large_result", {})
With a 2 MiB single-event response, the call fails with SSE stream ended without a response. The same response succeeds when the underlying EventSource is constructed with a larger max_event_size.
I can contribute an implementation and exact boundary tests if maintainers agree with exposing this setting.
We hit exactly this in production, and it is not limited to tools/call: a tools/list response above 1 MiB is enough to make a whole upstream server disappear behind a Streamable HTTP client.
Real-world occurrence
Our MCP gateway (FastMCP 4.0.0, which pulls mcp==2.1.1 and httpx2==2.12.0) proxies a hosted MCP server (Composio, backend.composio.dev) that exposes 1030 tools. That server answers tools/list with HTTP 200 text/event-stream and delivers the whole result as ONE data: event of 2,140,787 bytes. Verified with a raw replay of the identical request bodies the SDK sends (with and without params._meta, protocol version 2025-11-25): the server returns the complete, valid response every time.
With mcp 1.x (FastMCP 3.4.5, httpx 0.28 + httpx-sse) the client lists all 1030 tools. With mcp 2.1.1 the same request ends in
MCPError: SSE stream ended without a response
and a proxy built on top of it reports 0 tools for that upstream, without any hint that a size limit was involved. The actual SSEError("Server-sent event exceeded the 1048576 byte limit.") is only visible at DEBUG level.
Where it happens (mcp 2.1.1)
src/mcp/client/streamable_http.py:426: event_source = EventSource(response) - no max_event_size, so httpx2._config.DEFAULT_MAX_EVENT_SIZE_BYTES = 1024 * 1024 applies.
src/mcp/client/streamable_http.py:411-460 (_handle_sse_response): the SSEError raised by the httpx2 decoder is caught by except Exception: logger.debug("SSE stream ended", exc_info=True) and the request is resolved via _resolve_abandoned_request(..., "SSE stream ended without a response"), so the caller gets a generic CONNECTION_CLOSED error that points in the wrong direction (network) instead of the real cause (client-side size cap).
Neither streamable_http_client() nor StreamableHTTPTransport expose the setting, and FastMCP (checked 4.0.0 and 4.0.1, package fastmcp-slim) has no way to pass it through either.
Minimal reproduction (no external services)
A plain Starlette app that answers every JSON-RPC request with text/event-stream containing exactly one data: event, like several hosted servers do (stateless, no mcp-session-id). Run with:
tools/list as ONE SSE event of ~ 512 KiB -> OK, 10 tools
tools/list as ONE SSE event of ~ 1000 KiB -> OK, 10 tools
tools/list as ONE SSE event of ~ 1023 KiB -> OK, 10 tools
tools/list as ONE SSE event of ~ 1030 KiB -> FAIL: MCPError: SSE stream ended without a response
tools/list as ONE SSE event of ~ 1100 KiB -> FAIL: MCPError: SSE stream ended without a response
tools/list as ONE SSE event of ~ 2200 KiB -> FAIL: MCPError: SSE stream ended without a response
The cut is exactly at the 1 MiB default. Note that a FastMCP 2.x/4.x test server does NOT reproduce this because it answers with application/json; the trigger is a server that streams the result as a single SSE event, which the spec allows.
What would help
Expose the limit: a max_event_size parameter on streamable_http_client() and StreamableHTTPTransport (httpx2 terminology), applied consistently to the POST response path (EventSource(response, max_event_size=...)), the GET stream and the reconnection path (AsyncClient.sse(...) currently has no transport-level setting either).
Surface the cause: when the decoder raises SSEError, resolve the pending request with an error that carries the SSEError text (and log at WARNING, not DEBUG). "SSE stream ended without a response" sends people hunting for network problems; it took a wire trace to find the size cap.
Consider a larger default than 1 MiB for the POST response path, since tools/list results of large hosted servers routinely exceed it. The bound itself is valuable (it protects the client from a misbehaving server), it just needs to be configurable.
Workaround we use in the meantime
Process-local, at import time, before any client connects:
That keeps a bound in place (memory protection against a broken upstream) while letting the real-world tools/list through. Patching httpx2 itself does not work because the default is bound at function definition time.
Happy to test a PR against the real server that triggered this.
Reacted by Antoine Balliet, Thomas Delayen and Charles Francis
The idea of the limit in HTTPX2 was to prevent an attack, but we can change that limit if proven that a bigger value doesn't bring back a vulnerability.
Thanks — I agree that the safe default should remain unchanged.
My proposal is not to globally increase or remove the limit, but to expose max_event_size as an explicit client option for applications that legitimately receive larger SSE events. The existing value would remain the default, so current users retain the same protection.
We can also document that increasing it raises memory/DoS risk and should only be done when the server and payload sizes are trusted or otherwise controlled. Would that address the security concern?
The limit protects a client from a hostile or broken server exhausting its memory, and that protection should stay the default. Exposing max_event_size as an explicit, opt-in parameter on streamable_http_client() and StreamableHTTPTransport does not reintroduce the risk: the default path is untouched, and only an operator who trusts one specific server raises the bound for that one client, the same trust decision they already make with timeout. In our gateway we run exactly that today via a workaround: a 32 MiB bound for one hosted upstream whose tools/list is a single 2.1 MB event (1030 tools), everything else keeps the 1 MiB default.
Independent of the limit itself: when the decoder raises SSEError, resolving the pending request with that message instead of the generic "SSE stream ended without a response" has no security cost and would have saved us a wire trace to find the cause.
We hit this exact failure in production through FastMCP 4 / MCP SDK 2 and httpx2 2.12.0. Two successful HTTP 200 tool responses were approximately 1.83 MB and 2.15 MB, so each deterministically exceeded httpx2's 1,048,576-byte per-event limit. The caller only received SSE stream ended without a response.
Our downstream compatibility fix deliberately keeps the 1 MiB safety cap. It catches SSEError before the SDK's catch-all, logs the exception type/message with exc_info, resolves only the affected request with an actionable message, and raises the caller-facing error from the original SSEError. A separate regression confirms a plain premature EOF still receives the existing generic error.
The key upstream requirement for us is therefore request-scoped exception propagation, not max_event_size=None: the original parser exception (or an equivalent structured error retaining its details) should reach the call boundary, siblings should remain usable, and the default cap should stay intact.
I would be happy to contribute the smaller upstream change once maintainers confirm the preferred exception-plumbing shape, especially how the original cause should cross the stream/dispatcher boundary.
Activity
mguttmann commentedon Sep 2, 2026
We hit exactly this in production, and it is not limited to
tools/call: atools/listresponse above 1 MiB is enough to make a whole upstream server disappear behind a Streamable HTTP client.Real-world occurrence
Our MCP gateway (FastMCP 4.0.0, which pulls
mcp==2.1.1andhttpx2==2.12.0) proxies a hosted MCP server (Composio,backend.composio.dev) that exposes 1030 tools. That server answerstools/listwith HTTP 200text/event-streamand delivers the whole result as ONEdata:event of 2,140,787 bytes. Verified with a raw replay of the identical request bodies the SDK sends (with and withoutparams._meta, protocol version 2025-11-25): the server returns the complete, valid response every time.With
mcp1.x (FastMCP 3.4.5,httpx0.28 +httpx-sse) the client lists all 1030 tools. Withmcp2.1.1 the same request ends inand a proxy built on top of it reports 0 tools for that upstream, without any hint that a size limit was involved. The actual
SSEError("Server-sent event exceeded the 1048576 byte limit.")is only visible at DEBUG level.Where it happens (mcp 2.1.1)
src/mcp/client/streamable_http.py:426:event_source = EventSource(response)- nomax_event_size, sohttpx2._config.DEFAULT_MAX_EVENT_SIZE_BYTES = 1024 * 1024applies.src/mcp/client/streamable_http.py:411-460(_handle_sse_response): theSSEErrorraised by the httpx2 decoder is caught byexcept Exception: logger.debug("SSE stream ended", exc_info=True)and the request is resolved via_resolve_abandoned_request(..., "SSE stream ended without a response"), so the caller gets a genericCONNECTION_CLOSEDerror that points in the wrong direction (network) instead of the real cause (client-side size cap).streamable_http_client()norStreamableHTTPTransportexpose the setting, and FastMCP (checked 4.0.0 and 4.0.1, packagefastmcp-slim) has no way to pass it through either.Minimal reproduction (no external services)
A plain Starlette app that answers every JSON-RPC request with
text/event-streamcontaining exactly onedata:event, like several hosted servers do (stateless, nomcp-session-id). Run with:uv run --no-project --with "mcp==2.1.1" --with uvicorn --with starlette python repro_sse_event_limit.pyOutput (macOS, Python 3.12,
mcp2.1.1,httpx22.12.0):The cut is exactly at the 1 MiB default. Note that a FastMCP 2.x/4.x test server does NOT reproduce this because it answers with
application/json; the trigger is a server that streams the result as a single SSE event, which the spec allows.What would help
max_event_sizeparameter onstreamable_http_client()andStreamableHTTPTransport(httpx2 terminology), applied consistently to the POST response path (EventSource(response, max_event_size=...)), the GET stream and the reconnection path (AsyncClient.sse(...)currently has no transport-level setting either).SSEError, resolve the pending request with an error that carries theSSEErrortext (and log at WARNING, not DEBUG). "SSE stream ended without a response" sends people hunting for network problems; it took a wire trace to find the size cap.tools/listresults of large hosted servers routinely exceed it. The bound itself is valuable (it protects the client from a misbehaving server), it just needs to be configurable.Workaround we use in the meantime
Process-local, at import time, before any client connects:
That keeps a bound in place (memory protection against a broken upstream) while letting the real-world
tools/listthrough. Patchinghttpx2itself does not work because the default is bound at function definition time.Happy to test a PR against the real server that triggered this.
Kludex commentedon Sep 8, 2026
The idea of the limit in HTTPX2 was to prevent an attack, but we can change that limit if proven that a bigger value doesn't bring back a vulnerability.
Zhangs-11 commentedon Sep 8, 2026
Thanks — I agree that the safe default should remain unchanged.
My proposal is not to globally increase or remove the limit, but to expose
max_event_sizeas an explicit client option for applications that legitimately receive larger SSE events. The existing value would remain the default, so current users retain the same protection.We can also document that increasing it raises memory/DoS risk and should only be done when the server and payload sizes are trusted or otherwise controlled. Would that address the security concern?
Kludex commentedon Sep 8, 2026
@Zhangs-11 are you openclaw or hermes?
Zhangs-11 commentedon Sep 8, 2026
Neither — I’m using OpenAI Codex to help investigate and communicate the issue.
mguttmann commentedon Sep 9, 2026
The limit protects a client from a hostile or broken server exhausting its memory, and that protection should stay the default. Exposing
max_event_sizeas an explicit, opt-in parameter onstreamable_http_client()andStreamableHTTPTransportdoes not reintroduce the risk: the default path is untouched, and only an operator who trusts one specific server raises the bound for that one client, the same trust decision they already make withtimeout. In our gateway we run exactly that today via a workaround: a 32 MiB bound for one hosted upstream whosetools/listis a single 2.1 MB event (1030 tools), everything else keeps the 1 MiB default.Independent of the limit itself: when the decoder raises
SSEError, resolving the pending request with that message instead of the generic "SSE stream ended without a response" has no security cost and would have saved us a wire trace to find the cause.aballiet commentedon Sep 11, 2026
We hit this exact failure in production through FastMCP 4 / MCP SDK 2 and httpx2 2.12.0. Two successful HTTP 200 tool responses were approximately 1.83 MB and 2.15 MB, so each deterministically exceeded httpx2's 1,048,576-byte per-event limit. The caller only received
SSE stream ended without a response.Our downstream compatibility fix deliberately keeps the 1 MiB safety cap. It catches
SSEErrorbefore the SDK's catch-all, logs the exception type/message withexc_info, resolves only the affected request with an actionable message, and raises the caller-facing error from the originalSSEError. A separate regression confirms a plain premature EOF still receives the existing generic error.The key upstream requirement for us is therefore request-scoped exception propagation, not
max_event_size=None: the original parser exception (or an equivalent structured error retaining its details) should reach the call boundary, siblings should remain usable, and the default cap should stay intact.I would be happy to contribute the smaller upstream change once maintainers confirm the preferred exception-plumbing shape, especially how the original cause should cross the stream/dispatcher boundary.