Skip to content

Tool results over 1 MiB cannot be received over SSE or streamable HTTP, and the limit is not configurable #3803

Open

Description

@gowthamrao

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, through client.sse(url);
  • in mcp/client/streamable_http.py, through EventSource(response) in _handle_sse_response, the path a POST's response (and so a tools/call result) 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 with SSEError('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

  • mcp 2.0.0, httpx2 2.13.0, anyio 4.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

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    on Nov 3, 2026
  2. Shanmukh-l33t commented on Nov 4, 2026

    @Shanmukh-l33t

    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=...) and StreamableHttpParameters.max_sse_event_size, default unchanged at 1 MiB, None disables. The tested version above (2.0.0, 2026-07-28) predates it. On current main (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.py has no size parameter at all — sse_within_origin hardcodes the 1 MiB default (mcp/shared/_httpx_utils.py), so a >1 MiB result still fails with MCPError(-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 and None semantics as streamable_http_client; nonpositive values raise ValueError("max_sse_event_size must be positive or None") before any request, matching StreamableHTTPTransport. Passed through sse_within_origin to the EventSource.
    • DEFAULT_MAX_SSE_EVENT_SIZE moves to mcp.shared._httpx_utils as the single definition both transports share (streamable_http keeps re-exporting it).
    • docs/client/transports.md notes the option on sse_client.

    Testing evidence:

    • Regression tests: a 2 MiB tool-result round trip over the in-process SSE app fails pre-fix (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 at sse_within_origin pin the unchanged default (oversized event raises httpx2.SSEError, never truncated), a raised limit, and None, plus the ValueError cases.
    • Full suite: 6231 passed, 8 skipped, 1 xfailed — the sole failure is test_permission_error, which fails identically on pristine main in a root environment (unrelated). coverage report: 100.00%, 0 partial branches (fail_under = 100 passes). strict-no-cover clean. ruff check/ruff format --check and pyright clean.

    One observation from the repro: on the SSE death path the transport leaks its internal memory streams (unclosed MemoryObjectSendStream/MemoryObjectReceiveStream warnings 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 prefixes SSE 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.

  3. media-ctrl commented on Nov 4, 2026

    @media-ctrl

    Part of this is fixed on main already, so it is worth splitting the report.

    Streamable HTTP has the knob now: streamable_http_client(...) takes max_sse_event_size (src/mcp/client/streamable_http.py:718, default DEFAULT_MAX_SSE_EVENT_SIZE), the transport stores it and threads it into every client.sse(..., max_event_size=...) and EventSource(response, max_event_size=...) call. None disables the limit, so a large tools/call result 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 with SSEError('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:

    1. the same parameter on sse_client, threaded into client.sse(...), so both transports accept the same event sizes;
    2. the asymmetric error is worth a distinct code rather than a message. On SSE the session dies, on streamable HTTP the call fails with 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.

  4. lennardm commented on Nov 4, 2026

    @lennardm

    Same here on mcp 2.2.0 / httpx2 2.12.0 (Windows 11, Python 3.13): a ~1.19 MB tools/call result from a remote streamable-HTTP MCP server failed deterministically with SSE stream ended without a response. A raw HTTP capture showed the server sent one complete, valid event; the SSEError only shows up with mcp debug logging on.

    We work around it by wrapping mcp.client.streamable_http.EventSource so it defaults to max_event_size=32 * 1024 * 1024. A max_sse_event_size option on streamable_http_client (and surfacing the SSEError text in the synthesized error) would let us drop that monkeypatch.

  5. Shanmukh-l33t commented on Nov 4, 2026

    @Shanmukh-l33t

    @lennardm both of your asks are already on main since 2.2.0 — I diffed v2.2.0 against main to be sure:

    1. streamable_http_client(..., max_sse_event_size=DEFAULT_MAX_SSE_EVENT_SIZE) (src/mcp/client/streamable_http.py:718 on main; None disables the limit) is threaded into every client.sse(...) / EventSource(...) call in the transport, so the EventSource monkeypatch can go as soon as you're past 2.2.0.
    2. The size rejection is surfaced: main has an except httpx2.SSEError as exc: branch synthesizing "SSE stream failed: {exc}" (:503-506), so the waiter learns "Server-sent event exceeded the 1048576 byte limit." In v2.2.0 there 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" (:499 there), 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:

    • the legacy sse_client (src/mcp/client/sse.py:36) still has no event-size knob on main — that's what my branch adds (fix/sse-client-max-event-size at 99e6d2d4933697942796bdf83118fd9e09eaea2a, compare: main...Shanmukh-l33t:python-sdk:fix/sse-client-max-event-size): the same parameter threaded into client.sse(...), sharing DEFAULT_MAX_SSE_EVENT_SIZE, so both transports accept the same event sizes. The sse_client side kills the session on overflow rather than failing one call, which is the asymmetry worth closing.
    • a named error for size rejection — agreed that a client-side limit deserves more than message text; today catching it means string-matching SSEError text, and the same overflow manifests differently per transport.
    • one docs sentence on the 1 MiB default and the knob. A tool returning a screenshot or a CSV crosses it, and both failure modes read like transport faults.

    (Written with AI assistance; the version diff and line references were re-verified against main and v2.2.0 today.)

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 workingv2Affects 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