Skip to content

Expire idle Streamable HTTP sessions by default and cap concurrent sessions - #3395

Open
maxisbey wants to merge 3 commits into
mainfrom
session-retention-defaults
Open

Expire idle Streamable HTTP sessions by default and cap concurrent sessions#3395
maxisbey wants to merge 3 commits into
mainfrom
session-retention-defaults

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #2455, fixes #3228, fixes #3300

Stateful Streamable HTTP sessions now have a lifecycle the server owns: a session is forgotten as soon as it ends, sessions with nothing in flight expire after session_idle_timeout (default 30 minutes), and a manager holds at most max_sessions (default 10 000) at a time. Both settings are accepted by streamable_http_app(), run_streamable_http_async() and run(transport="streamable-http"), next to max_request_body_size.

Motivation and Context

session_idle_timeout has existed on StreamableHTTPSessionManager since 1.27 but defaulted to None and wasn't reachable from MCPServer or Server.streamable_http_app() (#2455), so at stock settings a session whose client never sent DELETE stayed registered, with its server task and streams, until the process exited. The docstring already recommended 1800 seconds; this makes it the default, which is also what the Ruby SDK ships (the C# SDK measures idleness the same in-flight-aware way, with a longer window and a 10 000-session limit).

Three related bookkeeping fixes ride along because the default only makes sense with them:

max_sessions limits how many stateful sessions one manager holds at a time: while that many are open, a request that would open another gets 503 with a JSON-RPC error body; existing sessions are unaffected and room frees up as they end or expire. Nothing is evicted to make room.

How Has This Been Tested?

New tests in tests/server/test_streamable_http_manager.py, tests/shared/test_streamable_http.py, tests/server/test_streamable_http_router.py and tests/docs_src/: a deleted session is forgotten and its ID answers 404 "Session not found"; each refused opening-request shape leaves no session, and neither does an opening request whose handler raises or is cancelled; the defaults; a tools/call parked past the timeout, an open GET stream, and a request completing under an open GET stream all keep the session, after which it expires and answers 404; session max_sessions + 1 gets 503 and a slot frees as soon as a session is deleted; non-positive values are rejected; the factories forward both settings; the transport can be constructed outside an event loop and creates its idle scope on connect(). The interaction test for DELETE asserts the new contract. Full suite (100 % coverage, strict-no-cover), pyright and ruff pass locally; also exercised end to end against streamable_http_app() under uvicorn with the SDK client and raw HTTP.

Breaking Changes

No signature loses anything and every new parameter has a default (keyword-only on the factories), but two defaults are now active where previously there was no limit:

  • session_idle_timeout defaults to 1800 instead of None. A stateful session with no request in flight for 30 minutes (no open GET stream, no running call) is terminated; the client's next request gets 404 and it has to initialize again, as the spec describes. Clients that keep the GET stream open (the SDK clients do) or have a call running are unaffected. Pass session_idle_timeout=None for the previous behaviour.
  • max_sessions defaults to 10_000. Deployments that expect more than 10 000 concurrent stateful sessions in one process should raise it or pass None.

Smaller observable differences: constructing a stateless manager with a timeout no longer raises (the value is unused there); a request on a deleted session is answered by the manager (404, "Session not found") rather than by the terminated transport; StreamableHTTPServerTransport accepts an optional idle_timeout and creates idle_scope itself when it is set.

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 am assigned to the linked issue (or it is labeled help wanted, or I'm a maintainer)
  • I have disclosed any AI assistance and can explain the change in my own words
  • 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

docs/run/index.md, docs/run/legacy-clients.md and the Session not found entry in docs/troubleshooting.md describe the defaults and how to turn them off. Stateless mode and the 2026-07-28 request path keep no sessions and are unaffected. Thanks to @shaun0927 (#2457) and @sainikhiljuluri (#3229) for the earlier PRs in this area, which this supersedes.

AI Disclaimer

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3395.mcp-python-docs.pages.dev
Deployment https://ed10ebb6.mcp-python-docs.pages.dev
Commit ae2daca
Triggered by @maxisbey
Updated 2026-08-26 14:47:54 UTC

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline findings, I also examined two candidate issues and ruled them out: holding _session_creation_lock while serving the entire opening request (including reading the POST body) serializes concurrent session opens, but that scope is pre-existing behavior, not widened by this PR; and the new docs/migration.md section is a deliberate record of the changed idle-timeout/max-sessions defaults rather than a stray addition.

Extended reasoning...

The inline findings cover the cancellation-window leak around task_group.start(run_server) (src/mcp/server/streamable_http_manager.py:354), the idle-expiry race in src/mcp/server/streamable_http.py:490, and the stateless-mode missing try/finally around terminate(). Separately, I checked whether serving the opening request inside _session_creation_lock (the _send_and_report_status call at src/mcp/server/streamable_http_manager.py:362, which awaits the request body) was a regression — the old code already called handle_request inside the same lock, so this PR does not widen that critical section, and the docs/migration.md addition documents this PR's own default changes, which is the kind of entry a behavior-changing PR legitimately adds. Neither warranted a posted finding; recording them here so a human reviewer knows they were looked at.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/mcp/server/streamable_http_manager.py — pre-existing: _handle_stateless_request still runs handle_request then terminate() with no try/finally, so when the ASGI task is cancelled (client disconnects mid-request — the exact trigger the PR handles for stateful opens) terminate() is skipped and run_stateless_server blocks forever in serve_connection on a read stream only terminate() closes, leaking one task+transport per aborted request in the manager's task group until shutdown, exactly as the base does. The PR added this hardening only to the stateful path (try/finally + shielded terminate(), lines 360-370). Mirror it here: wrap lines 256-259 in try/finally and call terminate() under anyio.CancelScope(shield=True).

    Extended reasoning...

    Path: stateless mode, _handle_stateless_request (src/mcp/server/streamable_http_manager.py:253-259). It starts run_stateless_server in the manager's long-lived self._task_group; that task sits in serve_connection reading the transport's read stream, which is closed only by http_transport.terminate() (streamable_http.py:839-865) — connect()'s own finally can't help because it runs only after serve_connection returns (circular). Trigger: the client disconnects while its POST is being served and the ASGI server cancels the request task (the PR's own test test_opening_request_that_is_cancelled_leaves_no_session models exactly this for the stateful path). await http_transport.handle_request(scope, receive, send) at line 256 raises CancelledError, so line 259's terminate() never runs; the read stream stays open and run_stateless_server blocks forever. No safeguard applies: the new idle timeout is not wired in stateless mode (transport is built without idle_timeout at lines 211-216, so idle_scope is None), and max_sessions only gates the stateful branch — so leak

    Verification: pre-existing — The mechanism is real, and the diff leaves the stateless path with exactly the gap it just closed on the stateful path. Code path (src/mcp/server/streamable_http_manager.py:253-259, byte-identical to base commit 6705402git diff has no hunk in _handle_stateless_request): ```python await self._task_group.start(run_stateless_server) # Handle the HTTP request and return the respo

Comment on lines +483 to +490
self._requests_in_flight += 1
self.idle_scope.deadline = math.inf
try:
await self._handle_request(scope, receive, send)
finally:
self._requests_in_flight -= 1
if not self._requests_in_flight:
self.idle_scope.deadline = anyio.current_time() + self._idle_timeout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Race at idle expiry: once the idle deadline fires, idle_scope.cancel_called is set and setting deadline = math.inf here is a no-op (anyio's setter skips cancelled scopes), yet the transport still accepts the request — _terminated only becomes True after run_server unwinds through serve_connection's connection-cleanup await and calls terminate(). A request landing in that window is dispatched into a session with no reader: writer.send() blocks until terminate() closes the stream, so the client gets a 500 (JSON mode) or a 200 SSE stream that ends with no response (SSE mode) instead of the clean expired-session 404 — a path that now exists by default since session_idle_timeout defaults to 1800 (base default was None; the opt-in base had the same race). Fix: have…

Extended reasoning...

Path: session idle deadline reached with no request in flight. anyio timer fires -> idle_scope.cancel_called = True, cancellation delivered to the serve_loop task. Unwinding is not atomic: serve_loop -> serve_connection's finally: await aclose_shielded(connection) (runs per-connection exit-stack user cleanup, an await) -> back in run_server, with idle_scope: exits catching the cancel -> only THEN does run_server's finally run _forget_session(new_session_id) and await http_transport.terminate() (streamable_http_manager.py:343-349). Throughout that window the manager's existing-session branch (streamable_http_manager.py:270-282) still finds the transport in _server_instances and calls transport.handle_request. handle_request (streamable_http.py:483-490) increments _requests_in_flight and sets self.idle_scope.deadline = math.inf, but anyio's deadline setter does nothing once cancel_called is set, so this cannot revive the session; _handle_request then sees self._terminated still False (line 502) and dispatches. For a POST, writer.send(session_message) (line 620/6

Verification: nit — The race is real: after the idle timer fires, idle_scope.cancel_called is True but the session is still in _server_instances (removed only at streamable_http_manager.py:347 in run_server's finally) and _terminated is still False (set at line 349 via terminate()). Unwinding contains await checkpoints (runner.py:467 finally: await aclose_shielded(connection)), so an HTTP request task c

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i didn't read what claude said, but this does look like a race to me

# Assert task group is not None for type checking
assert self._task_group is not None
# Start the server task
await self._task_group.start(run_server)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 If await self._task_group.start(run_server) raises — the opening request's task is cancelled at that checkpoint, or connect() fails before task_status.started() — the session registered at lines 309-311 is never removed: the established try/finally that discards provisional sessions only begins at line 360, and run_server's own finally (347) is skipped because it never reached its try. The ghost entry then permanently occupies a max_sessions slot and answers 500s (its idle scope is never entered, so the reaper can never fire), so repeated occurrences walk the manager toward 503 for every new client — the exact leak the PR's not-established cleanup was added to prevent. Fix: cover registration through start() with the same _forget_session + shielded terminate() cleanup.

Extended reasoning...

Path: _handle_stateful_request new-session branch registers the transport in _server_instances/_session_owners (309-311) before await self._task_group.start(run_server) (354). anyio's TaskGroup.start is a checkpoint: if the calling (ASGI/host) task is cancelled while awaiting it, anyio cancels the not-yet-started run_server (cancellation lands inside connect() before task_status.started(), so run_server's try/finally at 319-349 — which calls _forget_session — is never entered) and re-raises CancelledError in the caller before the try at line 361, so the manager-side cleanup (365-370) never runs either. The same holds if connect() raises before started(): start() re-raises the exception at 354. Result: _server_instances[new_session_id] holds a transport whose streams were closed by connect()'s teardown and whose idle_scope was never entered (a CancelScope's deadline only arms on __enter__), so the default 30-min reaper cannot fire and _forget_session is never called — the entry lives until manager shutdown, each occurrence consuming one of the `max_se

Verification: pre-existing — The gap is mechanically real: src/mcp/server/streamable_http_manager.py:309-311 registers the session (self._server_instances[http_transport.mcp_session_id] = http_transport) before await self._task_group.start(run_server) at line 354. If the host ASGI task is cancelled at that checkpoint, anyio cancels the not-yet-started child; run_server calls task_status.started() at l

The session manager kept a session's registry entry after the client
ended it with DELETE (the per-session task's cleanup skipped terminated
transports), and a request without a session ID that was refused
(anything but a valid initialize: wrong Accept, malformed JSON, a
non-initialize message, GET/DELETE) still left a registered transport
with a running server task behind it.

Now the manager drops the entry as soon as the transport is terminated,
the per-session task forgets the session and terminates its transport
however the loop ended, and a provisional session whose opening request
was answered with an error is discarded before the request returns. A
follow-up request on a deleted session is answered by the manager
("Session not found", 404) rather than by the dead transport.
`session_idle_timeout` was opt-in (default None), so at stock settings a
stateful session that its client never deleted stayed registered, with
its server task and streams, until the process exited. The docstring
already recommended 1800 seconds; make that the default
(DEFAULT_SESSION_IDLE_TIMEOUT) so sessions nobody is using are
reclaimed after 30 minutes. `None` keeps the previous behaviour.

"Idle" is now measured from the moment the session's last in-flight
request completes rather than from the arrival of the last request:
the transport takes an `idle_timeout` and owns the countdown, holding
it while any request (an open GET stream included) is being served and
restarting it when the last one finishes. A connected client, or a call
that runs longer than the timeout, therefore never loses its session;
a client that goes quiet with no stream open gets 404 on its next
request and initializes again, as the spec describes.

The timeout is simply unused in stateless mode, which keeps no
sessions, so constructing a stateless manager with a timeout no longer
raises.
@maxisbey
maxisbey force-pushed the session-retention-defaults branch from c60175b to 30c3971 Compare August 26, 2026 13:53

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/server/streamable_http.py">

<violation number="1" location="src/mcp/server/streamable_http.py:206">
P2: When `idle_timeout` is `NaN`, the constructor accepts it and the idle scope receives a `NaN` deadline, so session expiration becomes undefined instead of failing fast. Reject non-finite timeout values alongside non-positive values.</violation>

<violation number="2" location="src/mcp/server/streamable_http.py:484">
P2: There is a race window between the idle deadline firing (idle_scope.cancel_called becomes True) and run_server's finally actually calling terminate(). A request that arrives in that window increments _requests_in_flight and sets `self.idle_scope.deadline = math.inf`, but anyio's deadline setter is a no-op once cancel_called is set, so the session cannot be revived. Since `_terminated` is still False, the request is dispatched into a session whose loop is already unwinding, and `writer.send()` blocks until terminate() eventually runs — producing a 500 (JSON mode) or a silently-ended SSE stream instead of the intended clean 404 for an expired session. Guard handle_request against `idle_scope.cancel_called` (or check `_terminated`/loop status) before dispatching, or have this in-flight bump made atomic with the cancellation check.</violation>
</file>

<file name="tests/server/test_streamable_http_manager.py">

<violation number="1" location="tests/server/test_streamable_http_manager.py:638">
P3: These refusal/cancel/failure tests verify only that the session registry empties (`_server_instances == {}`, `_session_owners == {}`), but the PR's claim is that no transport is left behind either. The manager registers the provisional transport and starts its `run_server` task before serving the request, then forgets+terminates it in a finally; if `terminate()` were dropped, the background task would keep running while the dicts are already empty, and all three tests would still pass. Capture the created transport(s) (as `test_stateless_requests_memory_cleanup` does) and assert `transport.is_terminated` after the refused/cancelled/failed open.</violation>
</file>

<file name="src/mcp/server/streamable_http_manager.py">

<violation number="1" location="src/mcp/server/streamable_http_manager.py:349">
P2: During manager shutdown, this `terminate()` await runs inside the cancelled task-group scope and can be interrupted before closing the transport streams. Shield the transport cleanup so session shutdown actually completes.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

"""
if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id):
raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)")
if idle_timeout is not None and idle_timeout <= 0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When idle_timeout is NaN, the constructor accepts it and the idle scope receives a NaN deadline, so session expiration becomes undefined instead of failing fast. Reject non-finite timeout values alongside non-positive values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/streamable_http.py, line 206:

<comment>When `idle_timeout` is `NaN`, the constructor accepts it and the idle scope receives a `NaN` deadline, so session expiration becomes undefined instead of failing fast. Reject non-finite timeout values alongside non-positive values.</comment>

<file context>
@@ -187,12 +189,22 @@ def __init__(
         """
         if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id):
             raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)")
+        if idle_timeout is not None and idle_timeout <= 0:
+            raise ValueError("idle_timeout must be a positive number of seconds")
 
</file context>
Suggested change
if idle_timeout is not None and idle_timeout <= 0:
if idle_timeout is not None and (not math.isfinite(idle_timeout) or idle_timeout <= 0):

Comment thread src/mcp/server/streamable_http_manager.py
# the idle countdown is suspended while any is being served and
# restarts when the last one completes.
self._requests_in_flight += 1
self.idle_scope.deadline = math.inf

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: There is a race window between the idle deadline firing (idle_scope.cancel_called becomes True) and run_server's finally actually calling terminate(). A request that arrives in that window increments _requests_in_flight and sets self.idle_scope.deadline = math.inf, but anyio's deadline setter is a no-op once cancel_called is set, so the session cannot be revived. Since _terminated is still False, the request is dispatched into a session whose loop is already unwinding, and writer.send() blocks until terminate() eventually runs — producing a 500 (JSON mode) or a silently-ended SSE stream instead of the intended clean 404 for an expired session. Guard handle_request against idle_scope.cancel_called (or check _terminated/loop status) before dispatching, or have this in-flight bump made atomic with the cancellation check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/streamable_http.py, line 484:

<comment>There is a race window between the idle deadline firing (idle_scope.cancel_called becomes True) and run_server's finally actually calling terminate(). A request that arrives in that window increments _requests_in_flight and sets `self.idle_scope.deadline = math.inf`, but anyio's deadline setter is a no-op once cancel_called is set, so the session cannot be revived. Since `_terminated` is still False, the request is dispatched into a session whose loop is already unwinding, and `writer.send()` blocks until terminate() eventually runs — producing a 500 (JSON mode) or a silently-ended SSE stream instead of the intended clean 404 for an expired session. Guard handle_request against `idle_scope.cancel_called` (or check `_terminated`/loop status) before dispatching, or have this in-flight bump made atomic with the cancellation check.</comment>

<file context>
@@ -458,6 +473,23 @@ async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
+        # the idle countdown is suspended while any is being served and
+        # restarts when the last one completes.
+        self._requests_in_flight += 1
+        self.idle_scope.deadline = math.inf
+        try:
+            await self._handle_request(scope, receive, send)
</file context>

async with manager.run():
response_start, _ = await _call(manager, _request_scope(), _INITIALIZE_BODY)
assert response_start["status"] == 200
assert manager._server_instances == {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: These refusal/cancel/failure tests verify only that the session registry empties (_server_instances == {}, _session_owners == {}), but the PR's claim is that no transport is left behind either. The manager registers the provisional transport and starts its run_server task before serving the request, then forgets+terminates it in a finally; if terminate() were dropped, the background task would keep running while the dicts are already empty, and all three tests would still pass. Capture the created transport(s) (as test_stateless_requests_memory_cleanup does) and assert transport.is_terminated after the refused/cancelled/failed open.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/server/test_streamable_http_manager.py, line 638:

<comment>These refusal/cancel/failure tests verify only that the session registry empties (`_server_instances == {}`, `_session_owners == {}`), but the PR's claim is that no transport is left behind either. The manager registers the provisional transport and starts its `run_server` task before serving the request, then forgets+terminates it in a finally; if `terminate()` were dropped, the background task would keep running while the dicts are already empty, and all three tests would still pass. Capture the created transport(s) (as `test_stateless_requests_memory_cleanup` does) and assert `transport.is_terminated` after the refused/cancelled/failed open.</comment>

<file context>
@@ -503,9 +628,150 @@ def test_session_idle_timeout_rejects_non_positive():
+    async with manager.run():
+        response_start, _ = await _call(manager, _request_scope(), _INITIALIZE_BODY)
+        assert response_start["status"] == 200
+        assert manager._server_instances == {}
+
+
</file context>

Comment thread docs/migration.md Outdated
… on the server factories

Add `max_sessions` (DEFAULT_MAX_SESSIONS = 10_000, `None` for no
limit) to StreamableHTTPSessionManager: while that many stateful
sessions are open, a request that would open another is answered 503
with a JSON-RPC error body and nothing is allocated; existing sessions
are untouched and room frees up as they end or expire. This matches the
Ruby SDK's defaults (the C# SDK uses the same 10 000 figure).

`session_idle_timeout` and `max_sessions` are accepted by
`Server.streamable_http_app()`, `MCPServer.streamable_http_app()`,
`run_streamable_http_async()` and `run(transport="streamable-http")`,
the same way `max_request_body_size` is, so applications can tune or
disable them without reaching into `session_manager` after the fact.
Docs: run/index.md options list, run/legacy-clients.md session cost,
troubleshooting.md.
@maxisbey
maxisbey force-pushed the session-retention-defaults branch from 30c3971 to ae2daca Compare August 26, 2026 14:45

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline comment, I also looked at whether holding _session_creation_lock across the opening request's entire handle_request (src/mcp/server/streamable_http_manager.py:292-370) lets one slow initialize stall all new-session creation, now including the 503 capacity check — the lock already spanned handle_request before this change, so it is pre-existing behavior rather than something this PR introduces.

Extended reasoning...

This run confirmed one inline finding (the 503 "Too many open sessions" response reusing _error_response's hardcoded INVALID_REQUEST JSON-RPC code) and separately investigated whether the session-creation lock being held across the whole opening request — including the awaited handle_request at src/mcp/server/streamable_http_manager.py:362 — is a serialization bottleneck introduced by this PR. Comparing against the base commit shows the pre-PR code held the same lock across the opening request's handle_request as well, so the behavior predates this change and was ruled out as a finding rather than silently dropped. Earlier review threads from prior runs (idle-expiry race, registration-before-start() cleanup, migration.md scope) remain visible on the PR; the migration.md edits are gone from the current revision, and the remaining threads have author replies whose substance should be judged in those threads rather than restated here.

async with self._session_creation_lock:
if self.max_sessions is not None and len(self._server_instances) >= self.max_sessions:
logger.warning("Refusing to open a new session: %d sessions are already open", self.max_sessions)
await _error_response("Too many open sessions", 503)(scope, receive, send)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit: The new 503 "Too many open sessions" response reuses _error_response, which hardcodes JSON-RPC code INVALID_REQUEST (-32600) — a client-error code meaning the request object is malformed — for what is a transient server-capacity condition. The SDK client (client/streamable_http.py:348-357) forwards that body verbatim, so callers get MCPError code -32600, the same code as "Session not found"/"Session terminated", and reconnect-on-invalid-request logic (as docs/troubleshooting.md teaches) will hot-loop re-initializing against a full server instead of backing off. Give _error_response a code parameter and use a server-error code (e.g. INTERNAL_ERROR or a -32000-range code) for the 503, ideally with a Retry-After header.

Extended reasoning...

Path: manager at capacity -> src/mcp/server/streamable_http_manager.py:293-296 answers the opening request with _error_response("Too many open sessions", 503); _error_response (lines 384-389) builds JSONRPCError(... error=ErrorData(code=INVALID_REQUEST, ...)) unconditionally — INVALID_REQUEST (-32600) per JSON-RPC 2.0 means "the JSON sent is not a valid Request object", but the refused initialize is perfectly valid. Consequence: the SDK client's POST handler (src/mcp/client/streamable_http.py:342-357) sees status >= 400 with an application/json JSON-RPC error body and delivers that error to the caller as-is, so a client gets MCPError with code -32600 and message "Too many open sessions". By code alone this is indistinguishable from the 404 "Session not found" (-32600) that the same PR documents as "reconnect and initialize again" (docs/troubleshooting.md), so any client that branches on the code (rather than string-matching the message) treats capacity exhaustion as a dead session and immediately re-initializes, which at capacity yields another 503/-32600 — a retry lo

Verification: nit. The candidate's mechanics are accurate. The new capacity path at /home/claude/python-sdk/src/mcp/server/streamable_http_manager.py:295 (await _error_response("Too many open sessions", 503)(scope, receive, send)) goes through the shared helper at lines 384-386, which unconditionally builds JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message=message)). So

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

Labels

None yet

Projects

None yet

1 participant