Skip to content

feat: add idle timeout for StreamableHTTP sessions#1994

Open
felixweinberger wants to merge 5 commits intov1.xfrom
fweinberger/idle-timeout-termination
Open

feat: add idle timeout for StreamableHTTP sessions#1994
felixweinberger wants to merge 5 commits intov1.xfrom
fweinberger/idle-timeout-termination

Conversation

@felixweinberger
Copy link
Contributor

@felixweinberger felixweinberger commented Feb 4, 2026

Summary

Adds a session_idle_timeout parameter to StreamableHTTPSessionManager that enables automatic cleanup of idle sessions, fixing the memory leak described in #1283.

Motivation and Context

Sessions created via StreamableHTTPSessionManager persist indefinitely in _server_instances even after clients disconnect without sending DELETE. Over time this leaks memory. This was reported in #1283 and originally addressed in #1159 by @hopeful0 — this PR reworks that approach.

Key design decisions:

  • Uses a CancelScope with a deadline inside each session's run_server task. When the deadline passes, app.run() is cancelled and the session cleans up. No background tasks needed — each session manages its own lifecycle.
  • Incoming requests push the deadline forward, so active sessions are never terminated.
  • terminate() on the transport is now idempotent (early return if already terminated).
  • Default is None (no timeout, fully backwards compatible). Docstring recommends 1800s (30 min) for most deployments.

How Has This Been Tested?

Tests added to tests/server/test_streamable_http_manager.py: idle session reaping (deterministic, no sleeps), terminate idempotency, and parameterized input validation. All existing tests pass unchanged.

Breaking Changes

None. session_idle_timeout defaults to None (no timeout).

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

Additional context

Based on the approach from PR #1159 by @hopeful0, reworked to use cancel scope deadlines instead of transport-level timeout logic.

Closes #1283

@Kludex
Copy link
Member

Kludex commented Feb 5, 2026

Can't the task manage itself? I don't think we want the overhead of a background task that checks other tasks.

This seems something the specification should mention.

@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch from debfd7d to 73f4cf2 Compare February 5, 2026 10:19
@felixweinberger felixweinberger marked this pull request as draft February 5, 2026 10:23
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch 7 times, most recently from f8b58ad to 3b38262 Compare February 9, 2026 13:34
Add a session_idle_timeout parameter to StreamableHTTPSessionManager that
enables automatic cleanup of idle sessions, fixing the memory leak
described in #1283.

Uses a CancelScope with a deadline inside each session's run_server task.
When the deadline passes, app.run() is cancelled and the session cleans
up. Incoming requests push the deadline forward. No background tasks
needed — each session manages its own lifecycle.

- terminate() on the transport is now idempotent
- Effective timeout accounts for retry_interval
- Default is None (no timeout, fully backwards compatible)

Github-Issue: #1283
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch from 3b38262 to 2c87708 Compare February 9, 2026 13:47
- Remove unrelated blank line between logger.info and try block
- Only create CancelScope when session_idle_timeout is set, keeping
  the no-timeout path identical to the original code
- Add docstring to _effective_idle_timeout explaining the 3x
  retry_interval multiplier rationale
The helper silently clamped the user's configured timeout to
retry_interval * 3, which is surprising. Users should set a timeout
that suits their deployment. Updated the docstring to note that the
timeout should comfortably exceed retry_interval when both are set.
@felixweinberger
Copy link
Contributor Author

@Kludex another attempt, no external managers or anything this time.

@felixweinberger
Copy link
Contributor Author

Same thing for main branch: #2022

@maxisbey maxisbey added the bug Something isn't working label Feb 10, 2026
@maxisbey maxisbey added the P2 Moderate issues affecting some users, edge cases, potentially valuable feature label Feb 10, 2026
@felixweinberger felixweinberger marked this pull request as draft February 16, 2026 12:23
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch from 6141a20 to 3469185 Compare February 16, 2026 12:28
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch 4 times, most recently from 0aa0e6c to 8b91fb3 Compare February 16, 2026 12:46
@felixweinberger felixweinberger marked this pull request as ready for review February 16, 2026 12:51
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch from 8b91fb3 to 619cdb3 Compare February 16, 2026 12:57
- Reformat session_idle_timeout docstring to use 4-space continuation
  indent and fill to 120 chars
- Move idle timeout tests from tests/issues/ into
  tests/server/test_streamable_http_manager.py alongside existing
  session manager tests
- Remove redundant/unnecessary tests (6 dropped, 5 kept)
- Replace sleep-based test assertions with event-based polling
  using anyio.fail_after for deterministic behavior

Github-Issue: #1283
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch from 619cdb3 to d5686df Compare February 16, 2026 12:58
Comment on lines +239 to +241
# Push back idle deadline on activity
if transport.idle_scope is not None and self.session_idle_timeout is not None:
transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the response takes more than the deadline?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm good question - there's no logic to prevent the cleanup from happening, so the response wouldn't make it back before the Transport gets closed. It'd get a ClosedResourceError and the response would be lost. I'd probably argue that's something for the server dev to calibrate for their use case if they have very long running requests? Default stays no timeout anyway.

if idle_scope.cancelled_caught:
session_id = http_transport.mcp_session_id
logger.info(f"Session {session_id} idle timeout")
if session_id is not None: # pragma: no branch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the session_id be None here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - Line 253 already does

assert http_transport.mcp_session_id is not None

But because the type is str | None we have to do it again here - I replaced it with the same assert now though.

@felixweinberger felixweinberger marked this pull request as draft February 16, 2026 13:03
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch 6 times, most recently from 77f7e81 to 1292484 Compare February 16, 2026 15:50
assert session_id is not None, "Session ID not found in response headers"

# Wait for the 50ms idle timeout to fire and cleanup to complete
await anyio.sleep(0.1)
Copy link
Contributor Author

@felixweinberger felixweinberger Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some back and forth trying out event based stuff, given we're implementing timeout behavior here, this actually seems like the right way to test this - a case where a anyio.sleep(0.1) is justified?

- Fix docstring indentation: use 4-space continuation indent filled to
  120 cols consistently across all Args parameters
- Change stateless+idle_timeout error from ValueError to RuntimeError
- Use logger.exception() instead of logger.error() with exc_info
- Remove unnecessary None guard on session_id in idle timeout cleanup
- Replace while+sleep(0) polling with anyio.Event in test

Github-Issue: #1283
@felixweinberger felixweinberger force-pushed the fweinberger/idle-timeout-termination branch from 1292484 to 4191aa6 Compare February 16, 2026 16:00
@felixweinberger felixweinberger marked this pull request as ready for review February 16, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working P2 Moderate issues affecting some users, edge cases, potentially valuable feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants