Repository navigation
Description
Summary
When a tool returns a large response over the Streamable HTTP client transport, the client takes ~5s to receive a ~4 MB body that curl/HttpClient.ofString() reads in ~0.4s. The bottleneck is the client-side SSE body reading in ResponseSubscribers.sseToBodySubscriber, not the server, network, or JSON parsing.
Environment
io.modelcontextprotocol.sdk:mcp-core/mcp2.0.0 (latest)- JDK 25, Reactor (via SDK)
- Server: Spring AI 2.0.0
mcp-spring-webmvc(HttpServletStreamableServerTransportProvider) returning a single large SSEmessageevent — one compact-JSONdata:line (~4.17 MB) - Client:
HttpClientStreamableHttpTransport(McpSyncClient.callTool)
Measurements (steady-state, 3 runs)
| Path | Time to receive ~4.17 MB |
|---|---|
McpSyncClient.callTool (this SDK) |
~5,300 ms |
Same payload via curl / HttpClient BodyHandlers.ofString() |
~0.4–0.75 s |
| Jackson parse of the received JSON | < 70 ms |
→ ~10–13× slower than a plain one-shot read of the identical bytes.
Root cause analysis
The response body is effectively a single huge data: line (compact JSON has no newlines). ResponseSubscribers.sseToBodySubscriber uses HttpResponse.BodySubscribers.fromLineSubscriber(...); assembling that one ~4 MB line through the JDK line subscriber is the cost.
Things I tried:
- Changing
SseLineSubscriberdemand fromupstream().request(1)torequest(Long.MAX_VALUE)→ no improvement (so it isn't per-line backpressure). - Replacing the body subscriber with a streaming byte-level SSE parser (
BodySubscribers.fromSubscriber, unbounded demand, accumulateByteBuffers, split on\n\nevent boundaries, decode each event once) → ~0.4 s (≈13×).
Important constraint (must stay streaming)
A whole-body ofString read fixes the speed but breaks progress: the server interleaves notifications/progress on the same POST response SSE stream before the final result. So the fix must remain a streaming parser that emits each SSE event as its boundary arrives (a byte-level parser does this while still avoiding the line-assembly cost).
Questions
- Is there a recommended approach/workaround for large tool responses on the client that we're missing?
- Would you accept a PR replacing
fromLineSubscriberwith a streaming byte-level SSE parser inResponseSubscribers.sseToBodySubscriber(preserving incremental event emission)? - Is this related to Support application/json responses in Streamable HTTP transport (opt-in JSON response mode) #844 (opt-in
application/jsonresponse mode)? That would avoid SSE framing on the server side, but clients receiving SSE responses would still benefit from this fix.
Happy to open a PR with the streaming parser + a benchmark if that's welcome.
Activity
Kehrlann commentedon Jul 1, 2026
Hey @TaJoal !
Thanks for the report. The subscribers we rely on have big underlying issues. I know @chemicL has an open loop on reworking the HttpClient integration in this branch: https://github.com/modelcontextprotocol/java-sdk/tree/issue-620-refactor-http-client-chains ; unfortunately he won't be available for a few weeks. I don't know how far he has gone with this work, and if it's fully functional (I believe it should work, although not necessarily be production-ready).
Have a reproducer project we can use to benchmark would be very valuable. Maybe you could test the work I shared with said benchmark.
Kehrlann commentedon Jul 3, 2026
Assigning @chemicL to this, as he's worked on something similar. He's on leave for a little while, he'll take a look when he comes back.
TaJoal commentedon Jul 4, 2026
Hey @Kehrlann — thanks! I tested
issue-620-refactor-http-client-chainsagainst a benchmark. Summary: the refactor fixes the large-response bottleneck (~20× faster steady state), with one binary-compat break that stops MCP servers from serving, plus a JIT warm-up effect on the first few large responses (separate table below).Setup
HttpClientStreamableHttpTransport+McpSyncClient) → MCP server returning a single ~4.17 MB tool response (4,374,849 chars).callTool()wall time (RECV), split into server-side (server-reported elapsed, ~130 ms, stable) and client-side receive+parse =RECV − server.mcp-core2.0.0(release) vs2.0.0-SNAPSHOT(this branch).1. The fix works ✅
Client-side receive+parse, same 4.17 MB payload (steady state):
2.0.0(release)issue-620branch~20× improvement, matching the original curl-vs-SDK gap in this issue. Moving
sseToBodySubscriberofffromLineSubscriberto the incrementalUtf8LineDecoder+SseEventParser(ByteBuffer streaming) is clearly the win.2.⚠️ Blocker:
McpTransport.loggerremoved → breaksmcp-spring-webmvcat runtimeThe branch removes the
public static final Logger loggerfield fromio.modelcontextprotocol.spec.McpTransport(present in2.0.0). Spring AI'smcp-spring-webmvc:2.0.0WebMvcStreamableServerTransportProvider$WebMvcStreamableMcpSessionTransportinheritsMcpTransportand reads that static field (getstatic) in its constructor, so any MCP server onmcp-spring-webmvc(GA) throws on the first request:I re-added the field as a shim to get servers running for the benchmark. Flagging it for binary compat with already-released
mcp-spring-*.3. JIT warm-up: first few large responses spike, then settle
The new byte-level parser is a very hot tight loop and extremely JIT-sensitive at this size. On a fresh JVM the first large call is fast (OSR-compiled mid-call), then it deoptimizes on re-entry and spikes for a few calls before settling.
Per-call, reused client, branch, default JIT (client-side parse, 4.17 MB):
GC is negligible even on the slow calls, so it isn't GC. Isolating by compilation tier (same 4.17 MB payload):
-Xint)-XX:TieredStopAtLevel=1)C1-only is uniform with no spike, which rules out connection reuse; the spike only shows up with C2/OSR and is actually slower than plain C1 — i.e. it's the deopt→recompile transition of the parser hot loop, not GC and not the transport. Pure JVM warm-up: it self-heals to ~270 ms, and a startup warm-up call avoids it. Not a correctness issue — just flagging, since the first large response after boot can be noticeably slower.
Improve ResponseSubscribers performance
Improve ResponseSubscribers performance
Improve ResponseSubscribers performance