Skip to content

[BUG] McpStreamableServerSession does not close server-side socket when client disconnects, causing CLOSE-WAIT leak and thread pool exhaustion #1021

Description

@lxq19991111

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

Steps to Reproduce

  1. Deploy an MCP Server with Streamable HTTP transport (WebMVC, SYNC mode)
  2. Have an external MCP client send requests to POST /mcp (initialize + tools/call)
  3. Client receives the tool response and closes the TCP connection (sends FIN)
  4. Repeat with multiple clients (or a single client with retry logic)
  5. Observe server-side socket states with ss -tnp | grep 8080

Observed Behavior

After the client closes the connection:

  • Server-side socket enters CLOSE-WAIT and 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:

  1. Detect the peer shutdown (e.g., via IOException on write, or checking SocketChannel.read() == -1)
  2. Close the SSE stream / Reactor Sink associated with that session
  3. Remove the session from the internal session map
  4. Close the server-side socket
  5. 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.Many has 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: 200

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:

// 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

  1. lxq19991111 commented on Jun 11, 2026

    @lxq19991111
    Author

    @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 a ScheduledExecutorService, 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!

  2. lxq19991111 commented on Jun 13, 2026

    @lxq19991111
    Author

    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 AsyncContext when:

    • 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 HttpServletStreamableServerTransportProvider in 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.

  3. added
    bugSomething isn't working
    P1Significant bug affecting many users, highly requested feature
    needs reproWe need a way to reproduce the problem
    on Aug 17, 2026
  4. added theissue type on Aug 17, 2026
  5. amitdk commented on Aug 20, 2026

    @amitdk

    Confirming this in production, with a measured slope — hopefully this addresses the needs repro label.

    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.current open 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 in CLOSE_WAIT on 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)

    • sessions is added to on initialize, and the only sessions.remove(...) is
      inside handleDelete — i.e. it requires the client to send an explicit
      DELETE with a valid mcp-session-id.
    • Grepping the SDK for idleTimeout|sessionTimeout|staleSession|reap|evict
      returns nothing, so there is no TTL or reaper.
    • KeepAliveScheduler pings 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 with Failed 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

    1. Connect an MCP client over streamable HTTP.
    2. Terminate it without sending DELETE (SIGKILL, or drop the network).
    3. 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 only spring-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 to WebMvcStreamableServerTransportProvider, or
    is migrating to the servlet provider the intended path?

  6. lxq19991111 commented on Aug 20, 2026

    @lxq19991111
    Author

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

  7. added this to the 2.1.0 Planning milestone on Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions