fix(api): stop per-request MCP session leak; cap glibc arenas (#96)#102
Conversation
mcp 1.10.0's _handle_stateless_request never terminates the transport, so every POST /mcp leaks a live anyio task + ServerSession until the container hits its memory limit. The fix (terminate after handle_request) landed upstream in mcp 1.12.0; bump to 1.22.0 with fastmcp 2.13.3 — the newest pair that avoids fastmcp 2.14's fakeredis runtime dep (conflicts with our dev pin). Both are now pinned exactly since fastmcp minors break APIs and pin narrow mcp ranges. Also set MALLOC_ARENA_MAX=2 on the api and worker containers: freed transient search/ingest working sets otherwise scatter across up to 32 glibc arenas and never return to the OS, pinning RSS at its peak. Test fallout: fastmcp >=2.13's get_access_token() converts foreign token types with claims=None, which its own model rejects — the test auth helpers now build fastmcp's AccessToken (what verify_token returns in production) instead of the bare SDK one. MCP suite: 1167 passed / 0 failed against the new pins; pip resolves .[api] and .[all] cleanly (redis stays 5.3.1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD6Ef83TfvdqxyczzCKpCA
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD6Ef83TfvdqxyczzCKpCA
mruwnik
left a comment
There was a problem hiding this comment.
Raven (Claude Opus 4.8): Reviewed. This is a clean, well-justified fix for #96 with no blocking issues. I independently verified the load-bearing claims rather than taking the description on faith:
Central leak fix — confirmed. Downloaded both wheels: mcp 1.10.0's _handle_stateless_request does NOT call http_transport.terminate(); mcp 1.22.0 DOES (terminate runs after handle_request, closing the streams and ending the anyio task). The root cause and the fix are real and present in the chosen version.
Dependency resolution — confirmed compatible. fastmcp 2.13.3 requires mcp!=1.21.1,<1.23,>=1.19.0; mcp==1.22.0 satisfies it. fastmcp 2.14 requires mcp>=1.23.1, so pinning to 2.13.3 to stay on the tested mcp range is sound.
Test-harness change — complete coverage. Exactly three sites construct AccessToken directly (conftest.py, test_meta.py, test_teams.py) and all three are switched to fastmcp's AccessToken (matching what verify_token returns in prod). The other 14 MCP test files build auth via conftest's mcp_auth_context, so the single conftest fix covers them. Nothing missed.
MALLOC_ARENA_MAX=2 — valid and well-targeted. Effective because all runtime images are python:3.12-slim (glibc, not musl). Placed on the api service and on the worker-env anchor, so it also reaches worker and ingest-hub — the services that actually hold large transient ingest payloads. environment overrides env_file, so it takes effect. Comments are accurate and appropriately explain the "why".
Minor nits (non-blocking):
- The requirements-api.txt comment attributes 2.14 avoidance to a fakeredis conflict; that dep is actually only in 2.14.7, while the hard blocker across all of 2.14 is
mcp>=1.23.1. Inline note left — reword only, decision is correct. - Upstream (not this PR's concern): 1.22.0's terminate() runs after handle_request but not in a
finally, so a raising handle_request would still leak. Rare path; out of scope.
I can't run the MCP suite here (mcp/fastmcp aren't installed in the sandbox), so I'm relying on the PR's "1167 passed" for the runtime-behavior surface — but the production imports (FastMCP, get_access_token, middleware, mcp.types, auth provider/settings/shared.auth) are stable across this range and the test count is strong evidence. Approve.
mruwnik
left a comment
There was a problem hiding this comment.
Raven (Claude Opus 4.8): Re-review after CI went red. Request changes — the PR is not mergeable as-is: it turns a green pyright into 4 errors.
The backend check fails at the "Run linters" step. I traced it to pyright (ruff passes) and reproduced locally: pip install .[all] + mcp==1.22.0/fastmcp==2.13.3 yields 4 pyright errors, all in production files this PR doesn't modify. Downgrading to master's resolved pins (mcp 1.10.0 + fastmcp 2.11.2) gives 0 errors on the same files, so the dependency bump is the sole cause. Details and per-line fixes are in the inline comment on the mcp==1.22.0 line; summary:
oauth_provider.py:944load_access_tokenreturns the SDKAccessTokenbut fastmcp 2.13's base now expectsFastMCPAccessToken(incompatible override). Same class of mismatch you fixed in the test helpers — check whether it's also a latent runtime bug here, not just typing.oauth_provider.py:1020RefreshToken(client_id=…)—client_idis nowstr | None.oauth_provider.py:610iteratingclient_info.redirect_uris, nowOptional.visibility_middleware.py:106on_list_toolsnarrowsCallNext[…, Sequence[Tool]]tolist[Tool].
The rest of the PR is sound and I verified the substantive claims independently: the leak fix is real (mcp 1.10.0's _handle_stateless_request never calls terminate(); 1.22.0 does, right after handle_request); mcp==1.22.0 sits inside fastmcp 2.13.3's mcp>=1.19.0,<1.23 range; the three direct AccessToken construction sites are all updated and the other 14 MCP test files go through the fixed conftest helper; and MALLOC_ARENA_MAX=2 is effective (glibc python:3.12-slim) and reaches api + worker + ingest-hub.
So: approve the approach, but the production type-mismatches must be fixed (or CI's pyright made to pass) before merge. Per this repo's convention, red CI on the branch — even in files the PR didn't intend to touch — is in scope for the PR. Happy to re-review once the linter step is green.
Four pyright errors surfaced by the pin bump (CI lint step): - OAuthClientMetadata.redirect_uris and OAuthClientInformationFull .client_id are now optional in the SDK — guard the registration allowlist loop and bail out of load_refresh_token for clients without an id (URL-based client IDs may omit it). - fastmcp's OAuthProvider is now generic over its own AccessToken, so load_access_token returns FastMCPAccessToken (a subclass of the SDK type — same wire behavior, and immune to fastmcp's claims=None conversion crash). - Middleware.on_list_tools now speaks Sequence[Tool], not list[Tool]. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD6Ef83TfvdqxyczzCKpCA
Only fastmcp 2.14.7 carries the fakeredis runtime dep; the 2.14 line's harder constraint is mcp>=1.23.1. Lead with that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD6Ef83TfvdqxyczzCKpCA
|
Raven (Claude Opus 4.8): Approve. The new commit (430f678) fixes all 4 pyright regressions and CI is green (backend + frontend both pass). I verified the fixes independently, not just by reading: applied the head versions of Each fix is correct and minimal:
The whole PR now holds together: the leak fix (terminate() in mcp 1.22.0, independently confirmed), the fastmcp/mcp pin (in-range), the arena cap (glibc, reaches api + workers + ingest-hub), the test-harness AccessToken switch, and now the production type-signature updates the bump required. One trivial, non-blocking leftover: the |
Summary
Fixes the primary root cause of #96: the
apicontainer's RSS ratcheting from ~264MB to its 1GiB limit over ~5 days.mcp1.10.0 → 1.22.0 — mcp ≤1.11 never callshttp_transport.terminate()in_handle_stateless_request, so everyPOST /mcpleaks a live anyio task + ServerSession + memory streams for the life of the process (~1,150/day in prod). The upstream fix (terminate afterhandle_request) landed in mcp 1.12.0; verified present in 1.22.0.fastmcp==2.13.3(was floating>=2.10.0) — newest 2.x line that avoids fastmcp 2.14's runtimefakeredis<2.35dependency (conflicts with our dev pinfakeredis==2.36.0) and pins a narrow compatible mcp range. Pinned exactly since fastmcp minors break APIs; the requirements comment says to bump both together.MALLOC_ARENA_MAX=2on the api and worker containers — freed transient search/ingest working sets otherwise scatter across up to 32 glibc arenas and never return to the OS, pinning RSS at its historical peak (root cause 2 in the issue).Test fallout
fastmcp ≥2.13's
get_access_token()converts foreign token types withclaims=None, which its own model rejects. The test auth helpers (conftest.pymcp_auth_context+ local copies intest_teams.py/test_meta.py) now build fastmcp'sAccessToken— the same typeverify_tokenreturns in production — instead of the bare SDK one.Verification
pip install --dry-runresolves.[api]and.[all]cleanly; redis stays 5.3.1._handle_stateless_requestconfirmed to terminate the transport.Post-deploy check (from the issue): RSS curve should flatten at ~300MB instead of creeping ~7MB/h; optionally verify
len(asyncio.all_tasks())stays in the dozens.Remaining #96 items (payload-free candidate search, client caching, per-service metrics, MCP user_id attribution, oauth_client reaper) are intentionally out of scope.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TD6Ef83TfvdqxyczzCKpCA