-
Notifications
You must be signed in to change notification settings - Fork 77
fix(mcp): warn when stateless session middleware never attached #856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
gesh
merged 3 commits into
main
from
posthog-self-driving/fixmcp-warn-when-stateless-session-d1e090
Aug 22, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
5 changes: 5 additions & 0 deletions
5
.sampo/changesets/mcp-warn-when-stateless-session-not-wired.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| pypi/posthog: patch | ||
| --- | ||
|
|
||
| MCP analytics now surfaces the previously-silent case where the stateless session mint middleware (`PostHogMcpStatelessSessionMiddleware`) never attached β the trap where an ASGI app is built or mounted before `instrument()` runs, so autowiring can't retrofit it and every session falls back to a fragmented per-process id. `instrument()` warns when `streamable_http_app()` was already called before it ran, and a one-time warning fires the first time a tool call arrives over streamable HTTP and the session still has to come from process memory. Both go to the `posthog.mcp` standard-library logger as well as the `MCPAnalyticsOptions(logger=...)` sink, so they are visible without opting in β silence them with `logging.getLogger("posthog.mcp").setLevel(logging.ERROR)`. Neither fires for stdio, a correctly-wired server, a conversation-anchored session, or the SSE transport (which the mint cannot fix). Documented in the new `posthog/mcp/README.md`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # PostHog MCP analytics | ||
|
|
||
| Product analytics for Model Context Protocol servers. Wrap a Python MCP server so | ||
| every tool call, agent intent, and failure is captured to PostHog as a `$mcp_*` event. | ||
|
|
||
| ```python | ||
| from posthog import Posthog | ||
| from posthog.mcp import instrument | ||
| from mcp.server.fastmcp import FastMCP | ||
|
|
||
| posthog = Posthog("phc_...", host="https://us.i.posthog.com") | ||
| server = FastMCP("my-server") | ||
| analytics = instrument(server, posthog) | ||
| ``` | ||
|
|
||
| Install is just `pip install posthog`. `instrument()` needs the MCP SDK at runtime, | ||
| but anyone wrapping a server already has it. | ||
|
|
||
| ## Stateless / multi-pod servers | ||
|
|
||
| A stateless MCP server issues no session id, so `$session_id` fragments across pods | ||
| and the client identity (sent only at `initialize`) is lost. PostHog fixes this with | ||
| a small ASGI middleware β `PostHogMcpStatelessSessionMiddleware` β that mints a | ||
| self-encoded token onto the `Mcp-Session-Id` response header at `initialize`; the | ||
| client replays it on every request, so any pod recovers the session and harness from | ||
| the header alone. | ||
|
|
||
| ### Zero-config path (recommended) | ||
|
|
||
| `instrument()` wraps the FastMCP server's app factories (`streamable_http_app()` / | ||
| `sse_app()`), so an app you build **after** calling `instrument()` already carries the | ||
| middleware β including `mcp.run(transport="streamable-http")`, which calls those | ||
| factories internally. Nothing extra to add, as long as `instrument()` runs first: | ||
|
|
||
| ```python | ||
| server = FastMCP("my-server", stateless_http=True) | ||
| instrument(server, posthog) | ||
| server.run(transport="streamable-http") # already wired | ||
| ``` | ||
|
|
||
| ### Manual path β required when you build the app yourself | ||
|
|
||
| Autowiring only affects an app built **after** `instrument()` runs. If you build or | ||
| mount the ASGI app before `instrument()`, or in a different module β the common | ||
| FastAPI case β the running app gets **no** middleware and every session falls back to | ||
| a fragmented per-process id. Add the middleware to your app explicitly: | ||
|
|
||
| ```python | ||
| from posthog.mcp import PostHogMcpStatelessSessionMiddleware, get_mcp_session | ||
|
|
||
| app = mcp.streamable_http_app() | ||
| app.add_middleware(PostHogMcpStatelessSessionMiddleware) | ||
| ``` | ||
|
|
||
| This is also the path for a custom `PostHogMCP` dispatcher (you own the ASGI app), | ||
| where you then read the recovered session per request: | ||
|
|
||
| ```python | ||
| sess = get_mcp_session(request) # sess.session_id, sess.client_name, ... | ||
| ``` | ||
|
|
||
| ### Or skip the middleware entirely: conversation ids | ||
|
|
||
| `MCPAnalyticsOptions(enable_conversation_id=True)` derives `$session_id` from the | ||
| agent's conversation handle, deterministically and identically on every pod. That | ||
| needs no middleware and no ordering discipline, and it is the only thing that | ||
| correlates a session under the 2026-07-28 revision's per-request server instances. | ||
| Prefer it if you're on a recent client. | ||
|
|
||
| ### How the SDK tells you it's misconfigured | ||
|
|
||
| The failure used to be silent. It now surfaces two ways: | ||
|
|
||
| - **At `instrument()`** β if `streamable_http_app()` was already called before | ||
| `instrument()` ran, so the live app has no middleware. | ||
| - **At runtime, once** β the first time a tool call arrives over streamable HTTP and the | ||
| session still has to come from this process's memory. | ||
|
|
||
| Both go to the logger you pass via `MCPAnalyticsOptions(logger=...)` **and** to the | ||
| `posthog.mcp` standard-library logger, so you see them without opting in. Silence them | ||
| like any other logger: | ||
|
|
||
| ```python | ||
| logging.getLogger("posthog.mcp").setLevel(logging.ERROR) | ||
| ``` | ||
|
|
||
| Neither fires for stdio, for a correctly-wired server, or for a conversation-anchored | ||
| session. The instrument-time check can't see whether you added the middleware yourself | ||
| (the app is already built by then), so ignore it if you did. | ||
|
|
||
| Two gaps worth knowing: jlowin's `fastmcp` 2.x/3.x doesn't expose the attribute the | ||
| instrument-time check reads, so those servers get the runtime warning only. And the | ||
| deprecated SSE transport is excluded β it keys sessions off a query parameter, and the | ||
| mint sets a response header an SSE client never replays, so the middleware wouldn't | ||
| help it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stateful servers receive an incorrect stateless-session warning
Why we think it's a valid issue
_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.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.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.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.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.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."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_managerexists. 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 itsstatelessvalue or the server'ssettings.stateless_httpvalue. Add a test that builds a stateful app beforeinstrument()and expects no warning.Prompt to fix with AI (copy-paste)