Skip to content

Close servlet streamable HTTP transports on async lifecycle events#1027

Open
lxq19991111 wants to merge 1 commit into
modelcontextprotocol:mainfrom
lxq19991111:fix/streamable-http-async-lifecycle
Open

Close servlet streamable HTTP transports on async lifecycle events#1027
lxq19991111 wants to merge 1 commit into
modelcontextprotocol:mainfrom
lxq19991111:fix/streamable-http-async-lifecycle

Conversation

@lxq19991111

@lxq19991111 lxq19991111 commented Jun 13, 2026

Copy link
Copy Markdown

Summary

Fixes #1021.

Register servlet async lifecycle cleanup for Streamable HTTP SSE transports created by HttpServletStreamableServerTransportProvider.

This PR makes the current HTTP/SSE transport close when the servlet async context completes, times out, or errors. It also routes SSE write failures through the transport close() path instead of directly removing the logical MCP session from the session registry.

Motivation and Context

The servlet Streamable HTTP transport creates async SSE responses for multiple request paths:

  • GET listening streams
  • GET replay streams
  • POST streaming responses

When a client disconnects, the current HTTP/SSE response can become unusable while the SDK still keeps the async context and transport state alive. In production this can leave server-side sockets in CLOSE-WAIT and keep Tomcat resources tied up until an external timeout or kernel keepalive eventually reclaims them.

This issue was observed from Spring AI WebMVC usage first, but the lifecycle gap is in the MCP Java SDK core servlet transport.

Related Issues and PRs

Changes

  • Add a shared servlet async lifecycle listener helper.
  • Register async lifecycle cleanup for GET replay streams.
  • Reuse the same cleanup helper for GET listening streams.
  • Register async lifecycle cleanup for POST streaming responses.
  • Close replay and POST streaming transports through the transport close() path on handling failures.
  • Close only the current transport on SSE write failure instead of removing the logical MCP session.
  • Add focused regression coverage for GET listening cleanup, GET replay cleanup, POST streaming response cleanup, and write-failure behavior.

Rationale

A TCP/SSE stream lifecycle is not the same as an MCP logical session lifecycle.

A servlet async error, timeout, completion, or response write failure proves that the current HTTP/SSE transport is no longer usable. It does not necessarily prove that the logical MCP session should be removed from the session registry.

This distinction matters for Streamable HTTP because request-specific POST responses can fail or be closed after the response has already been delivered. Removing the logical session on that single transport failure causes the next request with the same mcp-session-id to fail with Session not found.

This PR therefore closes the current transport and completes the associated servlet async context, while leaving logical session eviction to existing protocol-level paths such as DELETE, server shutdown, or follow-up liveness policy such as #1028.

The async listener reuses the existing stream/transport close paths. These paths are guarded and idempotent, so cleanup can be triggered consistently from servlet async completion, timeout, error, and write-failure paths.

Scope

This PR intentionally focuses on the MCP Java SDK core servlet Streamable HTTP transport:

  • HttpServletStreamableServerTransportProvider

Out of scope:

  • Keep-alive failed-ping logical session eviction, handled separately in Evict streamable HTTP sessions after failed keep-alive pings #1028
  • Explicit session TTL
  • Session persistence / distributed session storage
  • Last-Event-ID resumability / event store support
  • Spring AI WebMVC-specific follow-up
  • Deprecated HttpServletSseServerTransportProvider

How Has This Been Tested?

mvn -pl mcp-core -Dtest=HttpServletStreamableServerTransportProviderTests -DforkCount=0 test
mvn -pl mcp-core test

The added tests cover:

  • GET listening stream async error closes the listening stream
  • GET replay request async error closes the current transport
  • POST streaming response async error closes the current transport
  • SSE write failure closes only the current transport and does not remove the logical MCP session

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@ShemTovYosef

Copy link
Copy Markdown

Hi @lxq19991111 does your resolve the same issue as done in PR #726

@lxq19991111

Copy link
Copy Markdown
Author

Hi @ShemTovYosef, thanks for linking #726.

This PR addresses the same production symptom, but not the same code path.

#726 targets the older WebMvcSseServerTransportProvider / McpServerSession path. This PR targets the core HttpServletStreamableServerTransportProvider used by Streamable HTTP.

The issue being fixed here is that Streamable HTTP servlet SSE responses are not consistently wired to servlet async lifecycle cleanup. When the client disconnects, the current GET/POST SSE transport can remain open, leaving Tomcat resources stuck in CLOSE_WAIT.

This PR specifically covers:

  • GET listening stream async cleanup
  • GET replay stream async cleanup
  • POST streaming response async cleanup
  • SSE write failure cleanup
  • keeping the logical MCP session registered when only the current transport fails

So I agree it is related to #726 in terms of the observed CLOSE_WAIT / Tomcat resource leak symptom, but I don’t think it is a duplicate. It fixes the Streamable HTTP servlet transport path.

@ShemTovYosef

Copy link
Copy Markdown

Hi @Kehrlann can you take a look on this solution for real production issue ?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants