Skip to content

[BUG] MCP Streamable HTTP Server: CLOSE-WAIT socket leak exhausts Tomcat thread pool when clients disconnect #6384

Closed as not planned

Description

@lxq19991111

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: 0s

Steps to Reproduce

  1. Deploy an MCP Server using spring-ai-starter-mcp-server-webmvc with Streamable HTTP transport
  2. Have an external MCP client send POST /mcp requests (initialize → tools/call)
  3. Client receives the response and closes the TCP connection (sends FIN)
  4. Repeat at moderate frequency (e.g., 2-3 requests/second from a load balancer)
  5. 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:

  1. Detect the peer shutdown (IOException on write or read returning -1)
  2. Complete or cancel the Servlet async context
  3. Close the server-side socket
  4. Release the Tomcat thread back to the pool
  5. 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 AsyncContext is never completed or timed out
  • The server-side OutputStream/Sink has 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: 200

This 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

  1. wolf8334 commented on Jun 11, 2026

    @wolf8334
    Contributor

    Thanks for the detailed report. The persistent CLOSE-WAIT sockets 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 AsyncListener fix may be premature.

    Could you please provide:

    • A full ss -tanp or lsof -nP -p <java-pid> output confirming that the affected sockets are owned by the Tomcat/Java process
    • A JVM thread dump captured after the Tomcat pool is exhausted
    • Tomcat access logs showing whether the affected requests have completed
    • A minimal reproducible application and the MCP client code or commands used to disconnect
    • A before/after comparison demonstrating that the proposed Tomcat timeout settings actually reclaim the CLOSE-WAIT sockets
    • Confirmation whether the issue is reproducible with an embedded Tomcat deployment and with the latest supported Spring Boot, Spring AI, Java SDK, and JDK versions

    In 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 AsyncListener is the correct fix.

  2. lxq19991111 commented on Jun 11, 2026

    @lxq19991111
    Author

    Thank you for the detailed guidance. Here is the diagnostic information I can provide:

    1. ss -tanp output confirming sockets owned by Java process (PID 88)

    Captured while Tomcat pool was exhausted and health checks were timing out:

    $ ss -tanp | grep 8080 | awk '{print $1}' | sort | uniq -c | sort -rn
    138 CLOSE-WAIT
      4 TIME-WAIT
      1 LISTEN
      1 ESTAB
    

    Sample entries (2 distinct upstream LB IPs):

    CLOSE-WAIT 116    0  [::ffff:10.125.126.60]:8080  [::ffff:10.125.126.21]:43752
    CLOSE-WAIT 294    0  [::ffff:10.125.126.60]:8080  [::ffff:10.125.216.198]:44508
    CLOSE-WAIT 294    0  [::ffff:10.125.126.60]:8080  [::ffff:10.125.216.198]:56244
    CLOSE-WAIT 116    0  [::ffff:10.125.126.60]:8080  [::ffff:10.125.126.21]:56130
    ... (138 total CLOSE-WAIT)
    

    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 curl requests (both POST initialize and GET SSE with forced abort). CLOSE-WAIT was not observed locally. The production environment differs in:

    • WAR deployment on standalone Tomcat 10.1.34 (not embedded)
    • Linux kernel (K8s pod)
    • Connections routed through upstream L4 load balancer

    I will next attempt reproduction in a Linux Docker container with an nginx reverse proxy.

    5. Critical finding: connectionTimeout does not reclaim these sockets

    The production server.xml Connector configuration:

    <Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
               maxThreads="512"
               maxConnections="10000"
               connectionTimeout="60000" />

    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's connectionTimeout no longer governs the connection lifecycle — only AsyncContext.setTimeout() applies.

    The CLOSE-WAIT count decreased from 146 to 138 over time (8 connections likely hit connectionTimeout before startAsync() 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:

    1. Call startAsync() on the Servlet request for SSE responses
    2. Not set an AsyncContext.setTimeout() (or set it to unlimited/0)
    3. Not detect client TCP disconnect (no AsyncListener.onError or write-failure detection)
    4. Therefore, when the client closes the connection, the async context is never completed, the NIO channel remains registered, and the socket stays in CLOSE-WAIT permanently

    7. Version confirmation

    Component Version
    Spring Boot 3.4.1
    Spring AI 1.1.4
    MCP Java SDK bundled with Spring AI 1.1.4
    JDK 25 (production)
    Tomcat 10.1.34 (standalone, WAR deployment)
    OS Linux (Kubernetes pod)

    8. Workaround status

    • server.tomcat.connection-timeout in application.yml: Does not apply to WAR deployments on external Tomcat
    • connectionTimeout in server.xml: Does not apply to async requests (confirmed above)
    • Testing spring.mvc.async.request-timeout=30000 as potential workaround to force-complete abandoned async contexts
  3. wolf8334 commented on Jun 11, 2026

    @wolf8334
    Contributor

    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 connectionTimeout does not reclaim the sockets is consistent with async processing, but it does not by itself prove that startAsync() 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-WAIT sockets and the reported Tomcat thread exhaustion is still mostly inferred from the connection counts.

    If jstack cannot attach because of the container user mismatch, it may still be possible to use one of the following approaches:

    • run jcmd <pid> Thread.print as the same user as the Java process
    • send SIGQUIT with kill -3 <pid> and collect the thread dump from the container logs
    • temporarily adjust the pod security context so the diagnostic command runs as the same UID
    • expose Tomcat JMX metrics such as currentThreadsBusy, currentThreadCount, and connection count

    It would also be useful to verify the effective Servlet async timeout and Tomcat asyncTimeout, not only the Connector connectionTimeout.

    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.

  4. lxq19991111 commented on Jun 11, 2026

    @lxq19991111
    Author

    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:

    • Attempt 1: 2h 20min
    • Attempt 2: 2h 3min

    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 (Tomcat connectionTimeout, Spring async timeout, etc.) seems to be effective.

    After recovery the state is clean:

    $ ss -tanp | grep 8080 | awk '{print $1}' | sort | uniq -c | sort -rn
    6 ESTAB
    4 TIME-WAIT
    1 LISTEN
    

    For the thread dump — I've deployed a scheduled monitor that will automatically capture it via ThreadMXBean next 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.

  5. wolf8334 commented on Jun 12, 2026

    @wolf8334
    Contributor

    Could you provide a minimal reproducible project or exact reproduction steps, including the MCP client behavior?

    Please also attach a jstack captured 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.

  6. lxq19991111 commented on Jun 12, 2026

    @lxq19991111
    Author

    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 .

  7. wolf8334 commented on Jun 12, 2026

    @wolf8334
    Contributor

    Sounds good. Looking forward to the upstream fix. Thanks for the update!

  8. sdeleuze commented on Jun 12, 2026

    @sdeleuze
    Contributor

    @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.

  9. lxq19991111 commented on Jun 13, 2026

    @lxq19991111
    Author

    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 the AsyncContext / 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions