Repository navigation
[BUG] MCP Streamable HTTP Server: CLOSE-WAIT socket leak exhausts Tomcat thread pool when clients disconnect #6384
Description
Description
When using spring-ai-starter-mcp-server-webmvc with Streamable HTTP transport, the server does not properly release sockets and Tomcat threads after an MCP client disconnects. Connections accumulate in TCP CLOSE-WAIT state indefinitely, eventually exhausting the Tomcat thread pool and making the server completely unresponsive — including to health checks.
Environment
- Spring AI: 1.1.4
- Spring Boot: 3.4.1
- Java: JDK 25
- Server: Tomcat 10.1.34 (WAR deployment)
- OS: Linux (Kubernetes, 2 CPU / 4 GB)
Configuration
spring:
ai:
mcp:
server:
type: SYNC
protocol: STREAMABLE
streamable-http:
mcp-endpoint: /mcp
keep-alive-interval: 0sSteps to Reproduce
- Deploy an MCP Server using
spring-ai-starter-mcp-server-webmvcwith Streamable HTTP transport - Have an external MCP client send
POST /mcprequests (initialize → tools/call) - Client receives the response and closes the TCP connection (sends FIN)
- Repeat at moderate frequency (e.g., 2-3 requests/second from a load balancer)
- After ~60 seconds, observe socket states on the server
Observed Behavior
$ ss -tlnp | grep 8080
LISTEN 151 150 *:8080 *:* # backlog full
$ 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 # all threads consumed
$ curl --max-time 5 http://localhost:8080/health
curl: (28) Failed to connect to localhost port 8080: Connection timed out- All 150 connections are in
CLOSE-WAIT(client sent FIN, server never called close) - All connections come from the same upstream load balancer IP
- Tomcat thread pool is fully exhausted — no new requests can be processed
- The application itself started successfully (
Started in 12.6 seconds)
Expected Behavior
When a client closes the TCP connection, the MCP server transport should:
- Detect the peer shutdown (IOException on write or read returning -1)
- Complete or cancel the Servlet async context
- Close the server-side socket
- Release the Tomcat thread back to the pool
- Remove the session from the internal session map
Root Cause Analysis
The Streamable HTTP transport appears to hold open an SSE-style async response for each MCP session. When the client disconnects:
- The
AsyncContextis never completed or timed out - The server-side
OutputStream/Sinkhas no subscriber check - The Servlet container keeps the thread allocated to the async response
- The OS socket enters CLOSE-WAIT because the server process never calls
close()
Impact
- Severity: Critical in production with active MCP clients
- Server becomes completely unresponsive within seconds under moderate load
- K8s rolling deployments fail because new pods are immediately flooded by retrying clients
- No automatic recovery without restart + stopping upstream traffic
Workaround
Configure Tomcat to force-close idle connections:
server:
tomcat:
connection-timeout: 30000
keep-alive-timeout: 30000
max-connections: 200
threads:
max: 200This allows Tomcat to reclaim stuck connections after 30 seconds, but is not a proper fix.
Suggested Fix
Register an AsyncListener on the servlet async context when opening the SSE stream:
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); }
@Override
public void onStartAsync(AsyncEvent event) { }
});Additionally, set a reasonable asyncTimeout on the context (e.g., 5 minutes) so that abandoned sessions are eventually reclaimed even without explicit client disconnect detection.
Related
This bug is related to (but distinct from) a KeepAliveScheduler issue in the MCP Java SDK where dead sessions are never evicted after ping failures. I have filed that separately at modelcontextprotocol/java-sdk.
Activity
wolf8334 commentedon Jun 11, 2026
Thanks for the detailed report. The persistent
CLOSE-WAITsockets do suggest that the peer has closed its side of the connection while the server-side socket is still being retained, so this is unlikely to be fixed primarily through Linux TCP tuning.However, the current evidence does not yet establish where the resource is being retained, and the proposed
AsyncListenerfix may be premature.Could you please provide:
ss -tanporlsof -nP -p <java-pid>output confirming that the affected sockets are owned by the Tomcat/Java processCLOSE-WAITsocketsIn particular, Servlet async processing should normally release the request thread after
startAsync(), even while the response remains open. Therefore, the thread dump is important to determine whether the threads are retained by the Spring AI transport, the MCP Java SDK, Tomcat async processing, or another blocking operation.The socket state is consistent with an application/container lifecycle issue, but more diagnostic evidence is needed before concluding that registering an
AsyncListeneris the correct fix.lxq19991111 commentedon Jun 11, 2026
Thank you for the detailed guidance. Here is the diagnostic information I can provide:
1.
ss -tanpoutput confirming sockets owned by Java process (PID 88)Captured while Tomcat pool was exhausted and health checks were timing out:
Sample entries (2 distinct upstream LB IPs):
The LISTEN backlog is full (151/150), confirming no new connections can be accepted.
2. Thread dump
Unable to capture due to container security restrictions (process owner mismatch). However, the connectionTimeout=60000 ineffectiveness over 70+ minutes already confirms these are async-held connections, which narrows the retention point to the startAsync() call chain in the transport layer.
3. Tomcat access logs
Not currently enabled. Will add and reproduce.
4. Minimal reproducer
I attempted local reproduction on macOS with embedded Tomcat using concurrent
curlrequests (both POST initialize and GET SSE with forced abort). CLOSE-WAIT was not observed locally. The production environment differs in:I will next attempt reproduction in a Linux Docker container with an nginx reverse proxy.
5. Critical finding:
connectionTimeoutdoes not reclaim these socketsThe production
server.xmlConnector configuration:Despite
connectionTimeout=60000(60 seconds), the 138 CLOSE-WAIT sockets have persisted for over 70 minutes without being reclaimed. This strongly suggests the transport is using Servlet async processing (startAsync()). Once async is active, Tomcat'sconnectionTimeoutno longer governs the connection lifecycle — onlyAsyncContext.setTimeout()applies.The CLOSE-WAIT count decreased from 146 to 138 over time (8 connections likely hit
connectionTimeoutbeforestartAsync()was called), but the remaining 138 are permanently stuck because the async context is never completed.6. Conclusion
Based on this evidence, the MCP Streamable HTTP WebMVC transport appears to:
startAsync()on the Servlet request for SSE responsesAsyncContext.setTimeout()(or set it to unlimited/0)AsyncListener.onErroror write-failure detection)7. Version confirmation
8. Workaround status
server.tomcat.connection-timeoutinapplication.yml: Does not apply to WAR deployments on external TomcatconnectionTimeoutinserver.xml: Does not apply to async requests (confirmed above)spring.mvc.async.request-timeout=30000as potential workaround to force-complete abandoned async contextswolf8334 commentedon Jun 11, 2026
Thanks for the additional details. This narrows the problem down, but I am not sure the current evidence is sufficient to conclude that the root cause is specifically in the Spring AI transport.
At this point, it looks more like an async request lifecycle or container configuration issue: the peer has already closed the connection, but the server-side request or async context is still being retained. The fact that
connectionTimeoutdoes not reclaim the sockets is consistent with async processing, but it does not by itself prove thatstartAsync()is the exact retention point or that the missing cleanup is in Spring AI rather than Tomcat, the MCP Java SDK, or the external WAR deployment configuration.The most useful diagnostic information would still be a thread dump taken while the server is exhausted. That would show whether the Tomcat threads are actually blocked, and where they are blocked. Without that, the relationship between the
CLOSE-WAITsockets and the reported Tomcat thread exhaustion is still mostly inferred from the connection counts.If
jstackcannot attach because of the container user mismatch, it may still be possible to use one of the following approaches:jcmd <pid> Thread.printas the same user as the Java processSIGQUITwithkill -3 <pid>and collect the thread dump from the container logscurrentThreadsBusy,currentThreadCount, and connection countIt would also be useful to verify the effective Servlet async timeout and Tomcat
asyncTimeout, not only the ConnectorconnectionTimeout.My current impression is that this may eventually turn out to be a timeout or lifecycle configuration issue rather than a defect in Spring AI itself, but the thread dump is the key missing evidence.
lxq19991111 commentedon Jun 11, 2026
Thanks for your patience and the helpful suggestions on capturing the thread dump.
One more data point that may be useful: I observed two consecutive deployments where the pod was stuck with CLOSE-WAIT exhausting the pool, and both recovered at almost exactly the 2-hour mark:
This matches Linux's default
tcp_keepalive_time = 7200s. So it appears the kernel keepalive probe is the only mechanism reclaiming these sockets — no application-layer timeout (TomcatconnectionTimeout, Spring async timeout, etc.) seems to be effective.After recovery the state is clean:
For the thread dump — I've deployed a scheduled monitor that will automatically capture it via
ThreadMXBeannext time the CLOSE-WAIT count crosses the threshold. I'll post it here as soon as it triggers.The issue is currently not reproducible on demand (only happens during deployment when accumulated upstream retry traffic hits the fresh pod), but I'm working on triggering it in a controlled way.
I appreciate your time looking into this — please let me know if there's anything else I can provide in the meantime.
wolf8334 commentedon Jun 12, 2026
Could you provide a minimal reproducible project or exact reproduction steps, including the MCP client behavior?
Please also attach a
jstackcaptured when the Tomcat thread pool is exhausted. Without the reproduction setup and thread dump, it is difficult to identify where the connections and request threads are being retained.lxq19991111 commentedon Jun 12, 2026
Thanks for the response. After further investigation, I've identified this as a robustness issue in the MCP Java SDK's WebMvcStreamableServerTransportProvider — specifically, it does not track client connection state or implement session TTL, so when clients disconnect without proper protocol-level close, sessions and their associated sockets leak indefinitely.
Under normal CPU conditions the impact is minimal (sockets close fast enough), but under resource-constrained environments (e.g. small containers with concurrent CPU-heavy tasks), the server can't process MCP requests fast enough → clients time out and drop TCP → CLOSE-WAIT accumulates → connection pool exhausted.
I'm working on a fix upstream in the MCP Java SDK repository — adding session-level TTL and periodic cleanup to McpStreamableServerSession. Once merged, Spring AI will benefit automatically without changes on your side.
I'll update this issue when the PR is ready. Thank you very much for your guidance, @wolf8334 .
wolf8334 commentedon Jun 12, 2026
Sounds good. Looking forward to the upstream fix. Thanks for the update!
sdeleuze commentedon Jun 12, 2026
@wolf8334 @lxq19991111 Thanks to both of you for moving forward on this issue, since the issue is on MCP SDK side, I will close this issue, but feel free to add a comment with the reference of the MCP SDK PR once created.
lxq19991111 commentedon Jun 13, 2026
Quick update: I opened an upstream MCP Java SDK PR for the servlet Streamable HTTP lifecycle cleanup:
modelcontextprotocol/java-sdk#1027
The PR focuses on completing/closing the current Streamable HTTP transport when servlet async lifecycle events fire
(
onComplete,onTimeout,onError) or when an SSE write fails. This directly targets theAsyncContext/ CLOSE-WAIT part of the issue described here.
I’m treating that as the first upstream step. After it lands, I’ll verify whether Spring AI’s WebMVC integration still
needs any additional Spring-specific follow-up on top of the MCP SDK change.