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

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

This makes replay GET streams and POST streaming responses close their current transport 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.

Fixes #1021

Motivation and Context

The servlet Streamable HTTP transport creates async SSE responses whose lifecycle is not wired back to SDK transport cleanup. When a client disconnects (sends TCP FIN), the server-side socket remains open because:

  1. The Sinks.Many has no subscribers but the stream is never terminated
  2. The servlet AsyncContext is never completed
  3. The server-side socket enters CLOSE-WAIT indefinitely
  4. The Tomcat worker thread is never released back to the pool

Under moderate client churn (~50 disconnects), all Tomcat threads are exhausted within seconds, making the server completely unresponsive to new requests including health checks. This causes rolling deployments to fail in production (new pods get flooded by retrying clients immediately after startup).

Changes

  • Add a shared servlet async lifecycle listener helper
  • Register async lifecycle cleanup for replay GET 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 unit 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 write failure or client disconnect proves that the current HTTP/SSE transport is no longer usable, but it does not necessarily mean the whole MCP session should be removed from the session registry.

This change closes the current transport and completes the associated servlet async context, while leaving session eviction to existing protocol-level paths such as DELETE, server shutdown, or future keep-alive/session-expiration work (see #1022 / #1028).

The async listener reuses the existing stream/transport close paths. These close 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 transport (HttpServletStreamableServerTransportProvider). Out of scope:

How Has This Been Tested?

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

Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

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

Full module test suite passes: mvn -pl mcp-core test

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

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

1 participant