spring-projects / spring-projects/spring-ai

[BUG] WebMvcStreamableServerTransportProvider: session and socket leak when clients disconnect without DELETE

Open
#6,860 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
Dominant language
Java
Stars
9.5k
Forks
2.9k
Avg merge
1d 7h
Merged PRs (30d)
6

Description

[BUG] WebMvcStreamableServerTransportProvider: session and socket leak when clients disconnect without DELETE

Description

WebMvcStreamableServerTransportProvider leaks MCP sessions and their associated TCP sockets permanently when a client disconnects without sending an explicit DELETE /mcp request. Over time this exhausts server file descriptors and makes the application unresponsive.

The root cause is a combination of:

  1. sessions.remove(sessionId) is only called inside handleDelete — no other code path evicts a session.
  2. SseBuilder.onTimeout() only logs; it does not close the stream or remove the session.
  3. SSE write failures (SseBuilder.send() throwing) do not trigger transport cleanup.
  4. KeepAliveScheduler swallows ping failures via .onErrorComplete() and never removes dead sessions.
  5. There is no session TTL or background reaper.

Any client that goes away without a clean DELETE (network drop, process kill, laptop sleep, load-balancer timeout) leaks a session and socket permanently.

Environment

  • Spring AI: 1.1.4 – 1.1.8 (all use WebMvcStreamableServerTransportProvider)
  • Spring Boot: 3.4.x / 3.5.x
  • MCP Java SDK: 0.17.0 – 0.18.3 (bundled via Spring AI)
  • Transport: Streamable HTTP with spring-ai-starter-mcp-server-webmvc
  • Server: Tomcat (both embedded and standalone WAR)
  • OS: Linux (Kubernetes)

Production Evidence

Reproduced independently by two users in production:

User 1 (reported in spring-projects/spring-ai#6384)
  • 138 CLOSE-WAIT sockets accumulated within minutes under moderate load
  • Tomcat listen backlog full (151/150), health checks timing out
  • connectionTimeout ineffective because connections are held by startAsync()
  • Only recovered after ~2 hours via kernel tcp_keepalive_time
User 2 (reported in modelcontextprotocol/java-sdk#1021)
  • Measured over 48 hours on a single host:
elapsed tomcat.connections.current open FDs
baseline 2 72
+~2h 20 91
+~48h 6373 6443
  • FD count only ratchets up, never decreases during idle periods
  • Earlier instance reached 8290 FDs after ~4.5 days
  • Log signature: Failed to send keep-alive ping to session ...: Stream unavailable for session <id>
  • On shutdown: Failed to complete SSE builder for session {}: The response object has been recycled

Steps to Reproduce

  1. Deploy an MCP server using spring-ai-starter-mcp-server-webmvc with Streamable HTTP transport.
  2. Connect an MCP client, complete initialization.
  3. Terminate the client without sending DELETE (e.g. kill -9, network drop).
  4. Repeat steps 2–3.
  5. Observe: session count and socket count only increase; keep-alive warnings appear once per interval per dead session.

Expected Behavior

When a client disconnects (SSE write failure, timeout, or async error):

  1. The current SSE stream/transport should be closed.
  2. The servlet async context should be completed, releasing the Tomcat NIO channel.
  3. After repeated keep-alive failures or a configurable TTL, the logical MCP session should be evicted from sessions.
  4. The server-side socket should be closed, preventing CLOSE-WAIT accumulation.

Root Cause Analysis

In WebMvcStreamableServerTransportProvider:

// onTimeout — only logs, does not close
sseBuilder.onTimeout(() -> log.debug("SSE connection timed out for session: {}", sessionId));

// onComplete — closes listeningStream, but only this one path
sseBuilder.onComplete(() -> listeningStream.close());

// sendMessage write failure — calls sseBuilder.error(), does not close transport
catch (Exception e) {
    sseBuilder.error(e);  // does not trigger session/transport cleanup
}

In KeepAliveScheduler:

// ping failure is swallowed, session never removed
.onErrorComplete()

There is no sessions.remove() outside of handleDelete, no TTL, and no reaper.

Relationship to MCP Java SDK #1027

I previously filed spring-projects/spring-ai#6384 for this issue. A Spring AI maintainer (@sdeleuze) closed it, directing the fix to the MCP Java SDK side. I then submitted modelcontextprotocol/java-sdk#1027 to address the servlet Streamable HTTP transport lifecycle.

However, as clarified in the MCP Java SDK 1.0.0 release notes (February 2026):

"Moved mcp-spring-webflux and mcp-spring-webmvc to Spring AI 2.0" (#805)

The Spring MVC transport has been maintained by Spring AI since February 2026. PR #1027 only fixes HttpServletStreamableServerTransportProvider in mcp-core — a different code path that is not used by spring-ai-starter-mcp-server-webmvc. Therefore, #1027 does not address this issue for any Spring AI WebMVC user.

Proposed Fix

I would like to contribute a PR to address this. The fix mirrors the approach in MCP SDK #1027:

  1. onTimeout → close the listening stream / transport, not just log.
  2. onError → close the transport, completing the async context.
  3. SSE write failure → call transport.close(), not just sseBuilder.error().
  4. Keep-alive ping failure → evict the dead session from sessions after N consecutive failures (or integrate with session TTL logic similar to #1028).
  5. Add regression tests covering client disconnect, timeout, and write-failure scenarios.

The fix should be minimal and non-breaking — closing only the affected physical stream/transport, while preserving the logical session for legitimate reconnects within a bounded window.

If maintainers agree with this direction, I can submit a PR against main targeting the mcp/transport/mcp-spring-webmvc module.

Related

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in the mcp/transport/mcp-spring-webmvc module with WebMvcStreamableServerTransportProvider and its KeepAliveScheduler. Trace the onTimeout, onComplete, SSE send-failure, and keep-alive failure paths, then review the proposed regression scenarios for disconnect, timeout, and write failure. Done means affected streams and async contexts are released and dead sessions are evicted without breaking legitimate reconnects.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring
Domain
api, backend, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.