Repository navigation
[BUG] McpStreamableServerSession does not close server-side socket when client disconnects, causing CLOSE-WAIT leak and thread pool exhaustion #1021
Description
Description
When using the MCP Java SDK's Streamable HTTP server transport (via spring-ai-starter-mcp-server-webmvc), the server-side socket is not properly closed after the client disconnects (sends TCP FIN). This causes connections to remain in CLOSE-WAIT state indefinitely, each holding a Tomcat worker thread. Under moderate load, the entire Tomcat thread pool is exhausted within seconds, making the server completely unresponsive to any new requests including health checks.
Environment
- Spring AI: 1.1.4
- MCP Java SDK: (bundled with Spring AI 1.1.4)
- Java: JDK 25
- Server: Tomcat 10.1.34 (WAR deployment via Spring Boot 3.4.1)
- Transport: Streamable HTTP (
spring.ai.mcp.server.protocol=STREAMABLE) - OS: Linux (Kubernetes pod, 2 CPU / 4GB RAM)
Configuration
spring:
ai:
mcp:
server:
type: SYNC
protocol: STREAMABLE
streamable-http:
mcp-endpoint: /mcp
keep-alive-interval: 0sSteps to Reproduce
- Deploy an MCP Server with Streamable HTTP transport (WebMVC, SYNC mode)
- Have an external MCP client send requests to
POST /mcp(initialize + tools/call) - Client receives the tool response and closes the TCP connection (sends FIN)
- Repeat with multiple clients (or a single client with retry logic)
- Observe server-side socket states with
ss -tnp | grep 8080
Observed Behavior
After the client closes the connection:
- Server-side socket enters
CLOSE-WAITand is never closed - The Tomcat worker thread handling that request is never released back to the pool
- Under load from a single upstream LB doing health-check retries, all 150 Tomcat threads are exhausted within ~30 seconds
- New connections (including K8s readiness probes) queue in the TCP backlog and time out
$ ss -tlnp | grep 8080
LISTEN 151 150 *:8080 *:*
$ ss -tnp | grep 8080 | head -5
CLOSE-WAIT 115 0 [::ffff:10.125.87.86]:8080 [::ffff:10.125.87.4]:47140
CLOSE-WAIT 115 0 [::ffff:10.125.87.86]:8080 [::ffff:10.125.87.4]:42756
CLOSE-WAIT 115 0 [::ffff:10.125.87.86]:8080 [::ffff:10.125.87.4]:47446
CLOSE-WAIT 115 0 [::ffff:10.125.87.86]:8080 [::ffff:10.125.87.4]:50138
CLOSE-WAIT 115 0 [::ffff:10.125.87.86]:8080 [::ffff:10.125.87.4]:43160
$ ss -tnp | grep 8080 | wc -l
150
$ curl --max-time 5 http://localhost:8080/health
curl: (28) Failed to connect to localhost port 8080: Connection timed out
All 150 connections are from the same upstream IP (load balancer), all in CLOSE-WAIT.
Expected Behavior
When the client closes the TCP connection (sends FIN), the server should:
- Detect the peer shutdown (e.g., via IOException on write, or checking
SocketChannel.read() == -1) - Close the SSE stream / Reactor Sink associated with that session
- Remove the session from the internal session map
- Close the server-side socket
- Release the Tomcat thread back to the pool
Root Cause Analysis
The MCP Streamable HTTP transport opens an SSE stream for each session. When the client disconnects:
- The server-side
Sinks.Manyhas no subscribers, but the stream is never terminated - The Servlet async context is never completed
- The socket remains open on the server side (only client sent FIN)
- Tomcat's NIO connector holds the thread waiting for the async context to complete
Impact
- Severity: Critical — renders the server completely unresponsive
- Makes rolling deployments impossible in production (new pods get flooded by retrying clients immediately after startup)
- K8s readiness probes fail → pod marked unhealthy → never enters service
- No automatic recovery — requires pod restart AND stopping upstream traffic simultaneously
Workaround
Set Tomcat connection timeout to force-close idle connections:
server:
tomcat:
connection-timeout: 30000
keep-alive-timeout: 30000
max-connections: 200
threads:
max: 200This allows Tomcat to reclaim CLOSE-WAIT connections after 30 seconds, but is not a proper fix — it just limits the damage window.
Suggested Fix
The Streamable HTTP transport provider should register a listener for client disconnect events. In the WebMVC integration:
// When setting up the async response for SSE:
asyncContext.addListener(new AsyncListener() {
@Override
public void onComplete(AsyncEvent event) {
cleanupSession(sessionId);
}
@Override
public void onTimeout(AsyncEvent event) {
cleanupSession(sessionId);
}
@Override
public void onError(AsyncEvent event) {
cleanupSession(sessionId);
}
// ...
});Or detect write failures when attempting to send data to the client and trigger session cleanup.
Activity
lxq19991111 commentedon Jun 11, 2026
@Ben-Home Thanks for the thorough diagnosis. I’ll go with session TTL and explicit tracking (#2) as suggested — it’s the lowest-risk fix and doesn’t change the wire protocol.
My plan is to add session-level TTL in
McpStreamableServerSession, schedule cleanup with aScheduledExecutorService, and make sure idle sessions release their CLOSE-WAIT sockets even when the SSE stream hasn’t errored. I’ll prepare a PR with tests and follow the contribution guide.Any preferences on default TTL or how it should be configured? Happy to adapt. Thanks!
lxq19991111 commentedon Jun 13, 2026
Quick update: I opened PR #1027 as a minimal first fix for the upstream MCP Java SDK side of this issue:
#1027
After looking more closely at the Streamable HTTP lifecycle, I adjusted the scope slightly from the original suggested
cleanupSession(sessionId)approach.The issue is triggered by a client disconnect leaving the servlet async response / SSE transport open, so the PR
focuses on closing the current HTTP/SSE transport and completing the associated
AsyncContextwhen:I intentionally do not remove the logical MCP session on a single stream write failure in this first PR. A TCP/SSE
stream lifecycle is not the same as an MCP session lifecycle, and a stream disconnect does not necessarily mean the
whole MCP session should be evicted. Session eviction should remain tied to DELETE, server shutdown, or a follow-up
keep-alive / TTL policy.
This first PR is scoped to
HttpServletStreamableServerTransportProviderin the MCP Java SDK core module. I kept thefollowing out of scope so the review stays focused:
If maintainers prefer the transport disconnect to also evict the logical session immediately, I can adjust the PR, but
I think closing the current transport first is the smaller and safer lifecycle fix.
amitdk commentedon Aug 20, 2026
Confirming this in production, with a measured slope — hopefully this addresses the
needs reprolabel.Environment
WebMvcStreamableServerTransportProviderkeep-alive-interval: 30sproxy_read_timeout 300s,proxy_buffering off)Measured accumulation on a single host
tomcat.connections.currentFD count tracks connections within a constant ~70 (the non-socket baseline), so
essentially all growth is sockets. Critically, it never falls back during idle
periods — it only ratchets up. An earlier instance reached 8290 FDs after ~4.5
days, of which 470 were in
CLOSE_WAITon the app's own listener (peer = thelocal reverse proxy) and ~7700 were TCPv6 sockets with no resolved peer. Heap,
metaspace, native memory and platform-thread counts were all normal throughout.
Mechanism (0.18.3
WebMvcStreamableServerTransportProvider)sessionsis added to on initialize, and the onlysessions.remove(...)isinside
handleDelete— i.e. it requires the client to send an explicitDELETEwith a validmcp-session-id.idleTimeout|sessionTimeout|staleSession|reap|evictreturns nothing, so there is no TTL or reaper.
KeepAliveSchedulerpings dead sessions forever; failures are swallowed by.onErrorComplete()and the session is never removed. Log signature matchesthe one in [BUG] KeepAliveScheduler does not evict sessions after ping failure, causing unbounded CPU growth #1022:
Failed to send keep-alive ping to session ...: Stream unavailable for session <id>.close()often fails withFailed to complete SSE builder for session {}: The response object has been recycled and is no longer associated with this facade.Essentially, we're seeing any client that goes away without a clean
DELETE(network drop, or possibly laptop in clamshell)leaks a session and socket permanently.
Repro
DELETE(SIGKILL, or drop the network).warning above appears once per interval per dead session.
One request on scope
#1027, #1028 and #1087 all target
HttpServletStreamableServerTransportProvider.Spring AI publishes only
spring-ai-starter-mcp-server-webmvc— there is noservlet-based Spring AI server starter — so a servlet-only fix will not reach any
Spring Boot MCP server using the official starter. Would it be possible to apply
the same lifecycle/TTL handling to
WebMvcStreamableServerTransportProvider, oris migrating to the servlet provider the intended path?
lxq19991111 commentedon Aug 20, 2026
@amitdk Thank you very much for reproducing the issue and for the additional production observations.
I also reviewed the related Spring AI issue, spring-projects/spring-ai#6384. I noticed that tapstate/tapstate#18 adopts a local stdio MCP sidecar model. This is a useful architectural alternative for deployments that can use a sidecar, since it avoids exposing the MCP server through the Streamable HTTP/WebMVC transport path.
@Kehrlann , could you please advise on the expected follow-up for the legacy WebMVC path? PR #1027 improves the servlet Streamable HTTP provider lifecycle, while the affected Spring AI WebMVC deployment uses the legacy
WebMvcStreamableServerTransportProvider.Would the SDK accept a maintenance or backport fix for that legacy WebMVC transport? If not, should it be tracked by Spring AI's current WebMVC transport, or is there a recommended migration path?