fix(mcp): warn when stateless session middleware never attached - #856
Conversation
posthog-python Compliance ReportDate: 2026-08-21 13:33:35 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
|
This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the |
|
@PostHog/mcp-analytics |
32c8acd to
9d87e5d
Compare
9d87e5d to
e331445
Compare
|
Note 🤖 Automated comment by QA Swarm — not written by a human Multi-perspective review: router (cheap-first pass) + delegated reviewers (qa-team, paul-reviewer, xp-reviewer, security-audit as warranted) Verdict: ✅ APPROVE (round 3 @ 5cf9629)Round 3 covers the delta since round 2 ( Key findingsNone. Zero actionable findings this round. ConvergenceOnly one reviewer ran this round (router); no cross-reviewer convergence to report. Reviewer summaries
Previous rounds (2)round 2 @ 65d6575 — ✅ APPROVE: single-commit delta (2.x attribute-probe fix), zero actionable findings, empirical claims reproduced directly against both SDK majors. Automated by QA Swarm — not a human review |
65d6575 to
9158386
Compare
The stateless-session mint (PostHogMcpStatelessSessionMiddleware) is zero-config only when the ASGI app is built after instrument() runs. An app built or mounted earlier (the common FastAPI case) silently gets no middleware, so every session falls back to a fragmented per-process id with nothing in the SDK saying so. Make the failure loud with two signals: - instrument() warns when streamable_http_app() was already called before it ran (a cached _session_manager is the tell, on the FastMCP server or the low-level server it delegates to). - A one-time runtime warning fires when a tool call arrives over streamable HTTP and the session still has to come from this process's memory. Both go to the posthog.mcp stdlib logger as well as the logger option, so they are visible without opting in -- routing them only through the opt-in sink would have left the failure as dark as it was. Detection reads the session source returned for *this* request rather than data.session_source, which is shared mutable state the conversation_id branch deliberately never writes; reading it after the fact would warn about conversation-anchored sessions that are perfectly healthy. The HTTP probe reuses get_request_headers, so all three adapters (v1 FastMCP, low-level, v2) are covered with no plumbing. Silent for stdio, correctly-wired servers, conversation-anchored sessions, and the deprecated SSE transport, whose session lives in a query param the mint cannot help with. Generated-By: PostHog Desktop Task-Id: 145ef960-7152-4c88-bed9-3214c268b1d0
The instrument-time "app built before instrument()" check looked for the low-level server at `_mcp_server`. That is the 1.x FastMCP name; 2.x's MCPServer calls it `_lowlevel_server`, so the probe never saw a built app on 2.x and the warning could not fire there — the half of the matrix the check claimed to cover. Probe both names, and cover it with a test that runs on whichever major is installed rather than one guarded to 1.x. That asymmetry is what hid the bug: the probe had no 2.x coverage at all, so every leg stayed green. Reverting the fix now fails the 2.x leg and passes 1.x. Also drop a `_Sink` test double that was redefined inside one test while an identical one sits at module scope. Generated-By: PostHog Desktop Task-Id: 145ef960-7152-4c88-bed9-3214c268b1d0
`_is_sse_request` needs the request object itself (for query params), not a header bag, so it hand-rolled the extra -> ctx -> request traversal that `request_headers` already owns. Lift that step into `get_request()` and have both call it, so only one place knows the shape — the same reason `_instrument_v2` already routes its header read through this module. The HTTP-ness probe in `prepare_request` now uses `get_request` too. It only ever asked "is there a request", so riding on the header-bag contract was indirect as well as wasteful: it built and iterated a dict per request to answer a question two getattrs settle. Generated-By: PostHog Desktop Task-Id: 145ef960-7152-4c88-bed9-3214c268b1d0
9158386 to
5cf9629
Compare
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 1 should fix, 0 consider. Published 1 finding (view the review). |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
| if getattr(candidate, "_session_manager", None) is not None: | ||
| return True |
There was a problem hiding this comment.
Stateful servers receive an incorrect stateless-session warning
Why we think it's a valid issue
- Checked:
_app_was_already_builtatposthog/mcp/asgi.py:259-283, the FastMCP source it probes, and the runtime twin_warn_stateless_session_not_wiredatposthog/mcp/_instrumentation.py:287. - Found:
mcp/server/fastmcp/server.py:950-961createsself._session_manageron the firststreamable_http_app()call for every server, and passesstateless=self.settings.stateless_httpinto it. The constructor default isstateless_http: bool = False(server.py:168), so a stateful server sets the exact attribute the probe reads. - Found: I reproduced it. A default
FastMCP("stateful-default")withsettings.stateless_http = False, whose app is built beforeinstrument(), makes_app_was_already_builtreturnTrueand emits the ordering warning to theposthog.mcpstdlib logger. The same object exposes_session_manager.stateless = False, so a correct check is available at the probe site. - Found: I then ran a full transport test on that stateful server (initialize → initialized → tools/call over
starlette.testclient). The transport issued its ownMcp-Session-Id(c1cf19ee...),resolve_session_id_with_sourcereturned source"mcp", and the runtime warning stayed silent. The session is healthy and the mint middleware is not needed there. - Found: The two signals disagree. The runtime warning is guarded on
session_source == "generated"(_instrumentation.py:357-362), so it correctly skips stateful servers. The instrument-time probe has no equivalent guard, so only it misfires. - Impact: The warning fires on the SDK's default configuration in exactly the scenario this PR targets — an app mounted before
instrument(). It goes to the stdlib logger at WARNING level by design, so every default-configured stateful host sees text that says "stateless sessions will not be captured" when the sessions are captured correctly. The two remedies it recommends (reorderinstrument(), or addPostHogMcpStatelessSessionMiddleware) change nothing for a stateful server. This contradicts the PR's own stated bar intest_runtime_silent_when_correctly_wired: "A diagnostic that cries wolf on healthy servers is worse than no diagnostic." - Impact: Note for the fix — read
statelessoff the discovered session manager rather thansettings.stateless_httpalone. On mcp 2.x,MCPServer.streamable_http_app()takesstateless_httpper call, so there is no server-level setting to read, but the manager object is aStreamableHTTPSessionManagerin both majors and storesself.stateless(mcp/server/streamable_http_manager.py:72). Also note thattest_app_built_probe_fires_on_the_installed_sdk_majorbuilds a statefulMCPServeron the 2.x leg, so that test needsstateless_http=Trueonce the guard lands.
Issue description
The probe only checks whether _session_manager exists. Stateful servers also create this manager. They already issue stable MCP session IDs and do not need the mint middleware. The new code warns these users that sessions will fragment.
Suggested fix
Check that the session manager is stateless before returning True. For example, inspect its stateless value or the server's settings.stateless_http value. Add a test that builds a stateful app before instrument() and expects no warning.
Prompt to fix with AI (copy-paste)
## Context
@posthog/mcp/asgi.py#L279-280
<issue_description>
The probe only checks whether `_session_manager` exists. Stateful servers also create this manager. They already issue stable MCP session IDs and do not need the mint middleware. The new code warns these users that sessions will fragment.
</issue_description>
<issue_validation>
- **Checked:** `_app_was_already_built` at `posthog/mcp/asgi.py:259-283`, the FastMCP source it probes, and the runtime twin `_warn_stateless_session_not_wired` at `posthog/mcp/_instrumentation.py:287`.
- **Found:** `mcp/server/fastmcp/server.py:950-961` creates `self._session_manager` on the first `streamable_http_app()` call for every server, and passes `stateless=self.settings.stateless_http` into it. The constructor default is `stateless_http: bool = False` (`server.py:168`), so a stateful server sets the exact attribute the probe reads.
- **Found:** I reproduced it. A default `FastMCP("stateful-default")` with `settings.stateless_http = False`, whose app is built before `instrument()`, makes `_app_was_already_built` return `True` and emits the ordering warning to the `posthog.mcp` stdlib logger. The same object exposes `_session_manager.stateless = False`, so a correct check is available at the probe site.
- **Found:** I then ran a full transport test on that stateful server (initialize → initialized → tools/call over `starlette.testclient`). The transport issued its own `Mcp-Session-Id` (`c1cf19ee...`), `resolve_session_id_with_source` returned source `"mcp"`, and the runtime warning stayed silent. The session is healthy and the mint middleware is not needed there.
- **Found:** The two signals disagree. The runtime warning is guarded on `session_source == "generated"` (`_instrumentation.py:357-362`), so it correctly skips stateful servers. The instrument-time probe has no equivalent guard, so only it misfires.
- **Impact:** The warning fires on the SDK's default configuration in exactly the scenario this PR targets — an app mounted before `instrument()`. It goes to the stdlib logger at WARNING level by design, so every default-configured stateful host sees text that says "stateless sessions will not be captured" when the sessions are captured correctly. The two remedies it recommends (reorder `instrument()`, or add `PostHogMcpStatelessSessionMiddleware`) change nothing for a stateful server. This contradicts the PR's own stated bar in `test_runtime_silent_when_correctly_wired`: "A diagnostic that cries wolf on healthy servers is worse than no diagnostic."
- **Impact:** Note for the fix — read `stateless` off the discovered session manager rather than `settings.stateless_http` alone. On mcp 2.x, `MCPServer.streamable_http_app()` takes `stateless_http` per call, so there is no server-level setting to read, but the manager object is a `StreamableHTTPSessionManager` in both majors and stores `self.stateless` (`mcp/server/streamable_http_manager.py:72`). Also note that `test_app_built_probe_fires_on_the_installed_sdk_major` builds a stateful `MCPServer` on the 2.x leg, so that test needs `stateless_http=True` once the guard lands.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Check that the session manager is stateless before returning `True`. For example, inspect its `stateless` value or the server's `settings.stateless_http` value. Add a test that builds a stateful app before `instrument()` and expects no warning.
</potential_solution>
💡 Motivation and Context
A customer on a stateless server lost weeks of analytics sessions because
PostHogMcpStatelessSessionMiddlewaresilently never attached — and nothing in the SDK said so. Two support round trips and an SDK release that didn't fix their issue.The mint is zero-config only for an ASGI app built after
instrument()runs.autowire_stateless_mintmonkey-patches the app factories, but FastMCP builds a fresh Starlette app per call and never retains it — so an app built or mounted beforeinstrument()(the common FastAPI-mounts-at-import case) can't be retrofitted and gets no middleware at all. Every session then falls throughresolve_session_idto the per-processgeneratedbranch, and$session_idfragments across pods with nothing logged. Works-in-dev (mcp.run()calls the patched factory), dark-in-prod (FastAPI mounts its own app).Two independent signals now surface it — either would have ended the ticket on day one:
instrument()warningstreamable_http_app()was already called beforeinstrument()ranBoth go to the
posthog.mcpstdlib logger as well as theMCPAnalyticsOptions(logger=...)sink. That part matters:log()is a no-op unless the host opts in, so routing these through it alone would have left the failure exactly as dark as it was — the customer had no logger configured. Silence withlogging.getLogger("posthog.mcp").setLevel(logging.ERROR).Was stacked on #881 — now rebased onto
main#881 squash-merged (
b0ab12c) while this was in review, so GitHub retargeted this PR tomainand the squash left the branch conflicting. Rebased the two commits ontomain; the conflict was purely the squash-vs-individual-commits history mismatch, and the replay was clean. No longer blocked on anything.The dependency on #881 was real while it lasted — this needs
conversation_idin scope andrequest_headers.get_request_headers, both of which are now inmain.Rebasing surfaced two things worth calling out.
1. Detection can't read
data.session_source. #881'sresolve_session_idshort-circuits onconversation_idbefore the lock and never writes that field, so it keeps a stale"generated". A check reading it would warn about conversation-anchored sessions — which are deterministic and stable across pods, i.e. exactly the case #881 exists to support. Soresolve_session_id_with_source()now returns the source for this request and the detector uses that.resolve_session_id()is kept as a thin wrapper; no caller changes.2. The v1/v2 adapter changes were deleted. #881 populates
extra["ctx"]at all sevenprepare_requestcall sites, so the HTTP probe derives fromget_request_headers(extra)insideprepare_requestinstead of being threaded through as a parameter. That removes every edit to_instrument_fastmcp.pyand_instrument_lowlevel.pyand covers_instrument_v2.pyfor free — which the earlier revision of this PR left unplumbed.Also excluded: the deprecated SSE transport. It keys sessions off a query param, and the mint sets a response header an SSE client never replays, so recommending the middleware there would be wrong advice.
💚 How did you test it?
Green on both CI legs —
mcp>=1.26,<2: 225 passed / 1 skipped;mcp>=2,<3: 209 passed / 13 skipped, with v1-only tests skipping viaimportorskip("mcp.server.fastmcp")rather than erroring. All 41 GitHub checks pass.ruff check/ruff formatclean, public API snapshot regenerated.Tests exercise the real transport (
starlette.testclient), not ahttp_request=Trueparameter passed in by the test itself — a wronggetattrin the probe would otherwise leave the signal dead with everything green. Each guard was mutation-tested to confirm it bites:data.session_sourcetest_no_warning_when_session_is_anchored_by_conversation_idtest_no_warning_for_stdiotest_no_warning_for_sse_transporttest_runtime_warning_fires_once_per_servertest_warnings_are_visible_without_configuring_a_loggertest_runtime_warns_when_app_was_built_before_instrumenttest_runtime_silent_when_correctly_wired+ 3Known gap, documented rather than hidden: jlowin's
fastmcp2.x/3.x keeps its session manager as a local insidehttp_app(), so there's nothing to probe at instrument time — those servers get the runtime warning only.📝 Checklist
sampo addto generate a changeset file🤖 Agent context
Autonomy: Supervised — reviewed and rebuilt by Claude Code (Opus 5) on top of #881 after a human review flagged that the original implementation, authored against a since-closed #830, misfired on #881's conversation-anchored sessions and broke its mcp 2.x CI leg.