Repository navigation
[BUG] McpStreamableServerSession does not close server-side socket when client disconnects, causing CLOSE-WAIT leak and thread pool exhaustion #1021
Description
Activity
@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!
Quick update: I opened PR #1027 as a minimal first fix for the upstream MCP Java SDK side of this issue:
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 associatedAsyncContextwhen:- the servlet async context completes, times out, or errors
- replay GET stream handling fails
- POST streaming response handling fails
- an SSE write fails
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 the
following out of scope so the review stays focused:- keep-alive failed-ping eviction
- explicit session TTL
- Spring AI WebMVC-specific follow-up
- broader production load-test tooling
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.- addedbugSomething isn't workingSomething isn't workingP1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested featureneeds reproWe need a way to reproduce the problemWe need a way to reproduce the problem
on Aug 17, 2026 Confirming this in production, with a measured slope — hopefully this addresses the
needs reprolabel.Environment
- MCP SDK 0.18.3 (via Spring AI 1.1.8),
WebMvcStreamableServerTransportProvider - Streamable HTTP,
keep-alive-interval: 30s - Spring Boot 3.5.0, JDK 21 with virtual threads enabled, embedded Tomcat
- Behind nginx (
proxy_read_timeout 300s,proxy_buffering off)
Measured accumulation on a single host
elapsed tomcat.connections.currentopen FDs baseline 2 72 +~2h 20 91 +~48h 6373 6443 FD 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 inCLOSE_WAITon the app's own listener (peer = the
local 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(...)is
insidehandleDelete— i.e. it requires the client to send an explicit
DELETEwith a validmcp-session-id.- Grepping the SDK for
idleTimeout|sessionTimeout|staleSession|reap|evict
returns 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 matches
the 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>.- When teardown does run,
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
- Connect an MCP client over streamable HTTP.
- Terminate it without sending
DELETE(SIGKILL, or drop the network). - Repeat. Session count and socket count only ever increase; the keep-alive
warning above appears once per interval per dead session.
One request on scope
#1027, #1028 and #1087 all target
HttpServletStreamableServerTransportProvider.
Spring AI publishes onlyspring-ai-starter-mcp-server-webmvc— there is no
servlet-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 toWebMvcStreamableServerTransportProvider, or
is migrating to the servlet provider the intended path?- MCP SDK 0.18.3 (via Spring AI 1.1.8),
@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?
- added and removedneeds reproWe need a way to reproduce the problemWe need a way to reproduce the problem
on Sep 4, 2026
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 inCLOSE-WAITstate 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.mcp.server.protocol=STREAMABLE)Configuration
Steps to Reproduce
POST /mcp(initialize + tools/call)ss -tnp | grep 8080Observed Behavior
After the client closes the connection:
CLOSE-WAITand is never closedAll 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:
SocketChannel.read() == -1)Root Cause Analysis
The MCP Streamable HTTP transport opens an SSE stream for each session. When the client disconnects:
Sinks.Manyhas no subscribers, but the stream is never terminatedImpact
Workaround
Set Tomcat connection timeout to force-close idle connections:
This 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:
Or detect write failures when attempting to send data to the client and trigger session cleanup.