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
Streamable HTTP client: slow reception of large SSE tool responses (fromLineSubscriber line-assembly bottleneck) #1042
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.
Server: Spring AI 2.0.0 mcp-spring-webmvc (HttpServletStreamableServerTransportProvider) returning a single large SSE message event — one compact-JSON data: line (~4.17 MB)
Same payload via curl / HttpClientBodyHandlers.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 SseLineSubscriber demand from upstream().request(1) to request(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, accumulate ByteBuffers, split on \n\n event 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 fromLineSubscriber with a streaming byte-level SSE parser in ResponseSubscribers.sseToBodySubscriber (preserving incremental event emission)?
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.
Hey @Kehrlann — thanks! I tested issue-620-refactor-http-client-chains against 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
Agent→agent call: MCP client (HttpClientStreamableHttpTransport + McpSyncClient) → MCP server returning a single ~4.17 MB tool response (4,374,849 chars).
Measured callTool() wall time (RECV), split into server-side (server-reported elapsed, ~130 ms, stable) and client-side receive+parse = RECV − server.
JDK 25. mcp-core2.0.0 (release) vs 2.0.0-SNAPSHOT (this branch).
1. The fix works ✅
Client-side receive+parse, same 4.17 MB payload (steady state):
build
client-side parse
2.0.0 (release)
~5,300–5,450 ms
issue-620 branch
~270 ms
~20× improvement, matching the original curl-vs-SDK gap in this issue. Moving sseToBodySubscriber off fromLineSubscriber to the incremental Utf8LineDecoder + SseEventParser (ByteBuffer streaming) is clearly the win.
2. ⚠️ Blocker: McpTransport.logger removed → breaks mcp-spring-webmvc at runtime
The branch removes the public static final Logger logger field from io.modelcontextprotocol.spec.McpTransport (present in 2.0.0). Spring AI's mcp-spring-webmvc:2.0.0WebMvcStreamableServerTransportProvider$WebMvcStreamableMcpSessionTransport inherits McpTransport and reads that static field (getstatic) in its constructor, so any MCP server on mcp-spring-webmvc (GA) throws on the first request:
java.lang.NoSuchFieldError: Class ...WebMvcStreamableMcpSessionTransport does not have member field 'org.slf4j.Logger logger'
at ...WebMvcStreamableMcpSessionTransport.<init>(WebMvcStreamableServerTransportProvider.java:608) [mcp-spring-webmvc-2.0.0.jar]
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.
GC is negligible even on the slow calls, so it isn't GC. Isolating by compilation tier (same 4.17 MB payload):
mode
client parse
pattern
interpreter (-Xint)
~27,000 ms
uniform
C1 only (-XX:TieredStopAtLevel=1)
~2,000 ms
uniform, no spike
C2 steady (default)
~270 ms
—
default tiered
379 → 3,200 → 1,200 → 270
OSR fast → deopt spike → C2 stable
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.
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