Skip to content

Commit c55c9b2

Browse files
posthog[bot]gesh
andauthored
fix(mcp): warn when stateless session middleware never attached (#856)
* fix(mcp): warn when stateless session middleware never attached 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 * fix(mcp): probe the right attribute for a built app on MCP 2.x 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 * refactor(mcp): share the request unwrap with get_request_headers `_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 --------- Co-authored-by: Georgis Andonis <georgis@posthog.com>
1 parent b86126e commit c55c9b2

11 files changed

Lines changed: 608 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
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`.

‎examples/mcp_stateless.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,19 @@ def greet(name: str) -> str:
3737
server.run(transport="streamable-http")
3838

3939

40-
# No FastMCP server to wire (a custom dispatcher)? Add the middleware to your own
41-
# ASGI app and read the recovered session per request:
40+
# Building the ASGI app yourself (e.g. mounting into FastAPI) or wiring a custom
41+
# dispatcher? Autowiring only affects an app built AFTER instrument() runs, so an app
42+
# built or mounted earlier gets no middleware. Add it to your own app explicitly, and
43+
# read the recovered session per request:
4244
#
4345
# from posthog.mcp import PostHogMcpStatelessSessionMiddleware, get_mcp_session
4446
#
4547
# app.add_middleware(PostHogMcpStatelessSessionMiddleware)
4648
# sess = get_mcp_session(request) # sess.session_id, sess.client_name, ...
49+
#
50+
# Get that wrong and the SDK now says so, on the `posthog.mcp` logger: once at
51+
# instrument() time, and once on the first request that resolves without a session.
52+
# MCPAnalyticsOptions(enable_conversation_id=True) sidesteps the whole ordering
53+
# question -- it anchors the session with no middleware at all.
54+
#
55+
# See posthog/mcp/README.md (stateless / multi-pod servers) for the full rundown.

‎posthog/mcp/README.md‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# PostHog MCP analytics
2+
3+
Product analytics for Model Context Protocol servers. Wrap a Python MCP server so
4+
every tool call, agent intent, and failure is captured to PostHog as a `$mcp_*` event.
5+
6+
```python
7+
from posthog import Posthog
8+
from posthog.mcp import instrument
9+
from mcp.server.fastmcp import FastMCP
10+
11+
posthog = Posthog("phc_...", host="https://us.i.posthog.com")
12+
server = FastMCP("my-server")
13+
analytics = instrument(server, posthog)
14+
```
15+
16+
Install is just `pip install posthog`. `instrument()` needs the MCP SDK at runtime,
17+
but anyone wrapping a server already has it.
18+
19+
## Stateless / multi-pod servers
20+
21+
A stateless MCP server issues no session id, so `$session_id` fragments across pods
22+
and the client identity (sent only at `initialize`) is lost. PostHog fixes this with
23+
a small ASGI middleware — `PostHogMcpStatelessSessionMiddleware` — that mints a
24+
self-encoded token onto the `Mcp-Session-Id` response header at `initialize`; the
25+
client replays it on every request, so any pod recovers the session and harness from
26+
the header alone.
27+
28+
### Zero-config path (recommended)
29+
30+
`instrument()` wraps the FastMCP server's app factories (`streamable_http_app()` /
31+
`sse_app()`), so an app you build **after** calling `instrument()` already carries the
32+
middleware — including `mcp.run(transport="streamable-http")`, which calls those
33+
factories internally. Nothing extra to add, as long as `instrument()` runs first:
34+
35+
```python
36+
server = FastMCP("my-server", stateless_http=True)
37+
instrument(server, posthog)
38+
server.run(transport="streamable-http") # already wired
39+
```
40+
41+
### Manual path — required when you build the app yourself
42+
43+
Autowiring only affects an app built **after** `instrument()` runs. If you build or
44+
mount the ASGI app before `instrument()`, or in a different module — the common
45+
FastAPI case — the running app gets **no** middleware and every session falls back to
46+
a fragmented per-process id. Add the middleware to your app explicitly:
47+
48+
```python
49+
from posthog.mcp import PostHogMcpStatelessSessionMiddleware, get_mcp_session
50+
51+
app = mcp.streamable_http_app()
52+
app.add_middleware(PostHogMcpStatelessSessionMiddleware)
53+
```
54+
55+
This is also the path for a custom `PostHogMCP` dispatcher (you own the ASGI app),
56+
where you then read the recovered session per request:
57+
58+
```python
59+
sess = get_mcp_session(request) # sess.session_id, sess.client_name, ...
60+
```
61+
62+
### Or skip the middleware entirely: conversation ids
63+
64+
`MCPAnalyticsOptions(enable_conversation_id=True)` derives `$session_id` from the
65+
agent's conversation handle, deterministically and identically on every pod. That
66+
needs no middleware and no ordering discipline, and it is the only thing that
67+
correlates a session under the 2026-07-28 revision's per-request server instances.
68+
Prefer it if you're on a recent client.
69+
70+
### How the SDK tells you it's misconfigured
71+
72+
The failure used to be silent. It now surfaces two ways:
73+
74+
- **At `instrument()`** — if `streamable_http_app()` was already called before
75+
`instrument()` ran, so the live app has no middleware.
76+
- **At runtime, once** — the first time a tool call arrives over streamable HTTP and the
77+
session still has to come from this process's memory.
78+
79+
Both go to the logger you pass via `MCPAnalyticsOptions(logger=...)` **and** to the
80+
`posthog.mcp` standard-library logger, so you see them without opting in. Silence them
81+
like any other logger:
82+
83+
```python
84+
logging.getLogger("posthog.mcp").setLevel(logging.ERROR)
85+
```
86+
87+
Neither fires for stdio, for a correctly-wired server, or for a conversation-anchored
88+
session. The instrument-time check can't see whether you added the middleware yourself
89+
(the app is already built by then), so ignore it if you did.
90+
91+
Two gaps worth knowing: jlowin's `fastmcp` 2.x/3.x doesn't expose the attribute the
92+
instrument-time check reads, so those servers get the runtime warning only. And the
93+
deprecated SSE transport is excluded — it keys sessions off a query parameter, and the
94+
mint sets a response header an SSE client never replays, so the middleware wouldn't
95+
help it.

‎posthog/mcp/_instrumentation.py‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@
2121
from ._exceptions import capture_exception
2222
from ._intent import resolve_tool_call_intent, set_event_intent
2323
from ._internal import MCPAnalyticsData, handle_identify, resolve_event_properties
24-
from .logger import log
24+
from .logger import log, warn
25+
from .request_headers import get_request
2526
from ._sanitization import build_captured_mcp_parameters
2627
from ._transport_identity import stamp_transport_identity
27-
from .session import resolve_session_id
28+
from .session import resolve_session_id, resolve_session_id_with_source
2829
from .session_token import SessionTokenPayload, decode_session_id
2930

3031
# Keep strong refs to in-flight capture tasks/futures and their lifecycle owners so
@@ -268,6 +269,46 @@ async def prime_session(
268269
await resolve_session_id(data, mcp_session_id, token=token)
269270

270271

272+
def _is_sse_request(extra: Optional[Dict[str, Any]]) -> bool:
273+
"""True for the deprecated SSE transport, which carries its session as a
274+
``session_id`` query parameter rather than a header.
275+
276+
Such a request resolves to a ``generated`` session for a reason the stateless
277+
mint cannot fix -- the mint sets a response header an SSE client never replays --
278+
so :func:`_warn_stateless_session_not_wired` would be recommending a remedy that
279+
does not apply."""
280+
try:
281+
params = getattr(get_request(extra), "query_params", None)
282+
return bool(params is not None and params.get("session_id"))
283+
except Exception: # noqa: BLE001 - a transport probe must never break a tool call
284+
return False
285+
286+
287+
def _warn_stateless_session_not_wired(data: MCPAnalyticsData) -> None:
288+
"""Warn once per server when a tool call/listing arrives over HTTP but the
289+
session still had to come from this process's memory.
290+
291+
That is the fingerprint of a stateless/multi-pod server whose mint middleware
292+
never attached — most often because the ASGI app was built (or mounted from
293+
another module) *before* ``instrument()`` ran, so wrapping the app factories
294+
couldn't retrofit the already-built app. The result is a silently fragmented
295+
``$session_id``; this makes that failure loud instead of dark-in-prod."""
296+
if data.warned_no_stateless_session:
297+
return
298+
data.warned_no_stateless_session = True
299+
warn(
300+
"Warning: an MCP tool request arrived over streamable HTTP with no session id, so "
301+
"PostHog generated a per-process $session_id that will fragment across requests "
302+
"and pods. This usually means PostHogMcpStatelessSessionMiddleware never attached "
303+
"— e.g. the ASGI app was built or mounted before instrument() ran. If you build "
304+
"the app yourself, add the middleware explicitly: "
305+
"app.add_middleware(PostHogMcpStatelessSessionMiddleware). "
306+
"Enabling conversation ids (MCPAnalyticsOptions(enable_conversation_id=True)) also "
307+
"anchors the session without any middleware. "
308+
"See posthog/mcp/README.md (stateless / multi-pod servers)."
309+
)
310+
311+
271312
async def prepare_request(
272313
data: MCPAnalyticsData,
273314
*,
@@ -305,10 +346,20 @@ async def prepare_request(
305346
when ``capture_event`` builds the initialize event — otherwise the first
306347
``$mcp_initialize`` is anonymous even when identify resolves on the same request.
307348
(Still not byte-parity with the TS SDK, which wraps the real initialize handler;
308-
the Python SDK handles initialize in the session layer, not ``request_handlers``.)"""
309-
session_id = await resolve_session_id(
349+
the Python SDK handles initialize in the session layer, not ``request_handlers``.)
350+
351+
A request that reached us over HTTP yet still resolved to this process's memory
352+
has nothing correlating it across pods, which on a stateless server means the
353+
mint middleware never attached — warn once rather than fragment silently."""
354+
session_id, session_source = await resolve_session_id_with_source(
310355
data, mcp_session_id, token=token, conversation_id=conversation_id
311356
)
357+
if (
358+
session_source == "generated"
359+
and get_request(extra) is not None
360+
and not _is_sse_request(extra)
361+
):
362+
_warn_stateless_session_not_wired(data)
312363
identify_event = await handle_identify(data, session_id, request, extra)
313364
if identify_event:
314365
fire_and_forget(capture_event(data, identify_event), data)

‎posthog/mcp/_internal.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ class MCPAnalyticsData:
6262
session_id: str = ""
6363
session_source: str = "generated" # "generated" | "mcp" | "token"
6464
last_mcp_session_id: Optional[str] = None
65+
# Set once we've warned that an HTTP request resolved with no session id — the
66+
# signature of a stateless server whose mint middleware never attached. Warned
67+
# a single time per server so the log isn't flooded on every request.
68+
warned_no_stateless_session: bool = False
6569
last_activity: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
6670
identified_sessions: IdentityCache = field(default_factory=IdentityCache)
6771
tool_categories: Dict[str, str] = field(default_factory=dict)

‎posthog/mcp/asgi.py‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import json
4141
from typing import Any, Optional
4242

43-
from .logger import log
43+
from .logger import log, warn
4444
from .session import new_session_id
4545
from .session_token import (
4646
MCP_SESSION_HEADER,
@@ -245,6 +245,7 @@ def autowire_stateless_mint(server: Any) -> None:
245245
On fastmcp 2.x, ``streamable_http_app`` / ``sse_app`` can be thin wrappers over
246246
``http_app``; wrapping all three could add the middleware twice to one app, so
247247
the factory guards against a double-add (see ``_app_already_wrapped``)."""
248+
_warn_if_app_built_before_instrument(server)
248249
for attr in ("streamable_http_app", "sse_app", "http_app"):
249250
original = getattr(server, attr, None)
250251
if not callable(original) or getattr(original, _AUTOWIRED, False):
@@ -255,6 +256,51 @@ def autowire_stateless_mint(server: Any) -> None:
255256
log(f"PostHog MCP: could not auto-wire stateless mint on {attr} - {error}")
256257

257258

259+
def _app_was_already_built(server: Any) -> bool:
260+
"""Whether the streamable-HTTP app already exists, so wrapping the factories
261+
now cannot retrofit it.
262+
263+
The tell is ``_session_manager``, created lazily on the first
264+
``streamable_http_app()`` call and non-``None`` forever after. It sits on the
265+
server itself on the official SDK's ``FastMCP`` (1.x) and on the low-level
266+
server it delegates to (2.x's ``MCPServer`` renamed that attribute
267+
``_lowlevel_server``; older/other wrappers may still use ``_mcp_server``), so
268+
check both names.
269+
270+
Deliberately partial: jlowin's ``fastmcp`` 2.x/3.x keeps its session manager as
271+
a local inside ``http_app()`` and never stores it, so there is nothing to probe
272+
and those servers get no instrument-time warning. The runtime warning in
273+
``_instrumentation`` still covers them."""
274+
low_level = getattr(server, "_mcp_server", None) or getattr(
275+
server, "_lowlevel_server", None
276+
)
277+
for candidate in (server, low_level):
278+
try:
279+
if getattr(candidate, "_session_manager", None) is not None:
280+
return True
281+
except Exception: # noqa: BLE001 - never let a probe break instrument()
282+
continue
283+
return False
284+
285+
286+
def _warn_if_app_built_before_instrument(server: Any) -> None:
287+
"""Catch the ordering trap that silently disables stateless capture: the
288+
streamable-HTTP app was built (and likely already mounted) *before* ``instrument()``
289+
ran, so wrapping the factories now can't retrofit that already-built app."""
290+
if not _app_was_already_built(server):
291+
return
292+
warn(
293+
"Warning: streamable_http_app() was called before instrument(), so the ASGI app "
294+
"already in use has no PostHog MCP middleware and stateless sessions will not be "
295+
"captured (autowiring only affects apps built after instrument() runs). Call "
296+
"instrument(server) before building or mounting the app, or add the middleware "
297+
"manually: app.add_middleware(PostHogMcpStatelessSessionMiddleware). "
298+
"You can ignore this if you already added the middleware yourself — the app is "
299+
"built by then, so there is no way for us to tell from here. "
300+
"See posthog/mcp/README.md (stateless / multi-pod servers)."
301+
)
302+
303+
258304
def _app_already_wrapped(app: Any) -> bool:
259305
"""True if ``app`` already carries our middleware -- so wrapping a factory that
260306
delegates to another wrapped factory (fastmcp 2.x aliases) doesn't add it twice."""

‎posthog/mcp/logger.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
protocol messages, so the SDK must never ``print``. We accept a ``logger``
99
option on the public API; when omitted, log calls are silently dropped. Plug in
1010
any callable (e.g. a file logger, or ``print`` for non-STDIO transports).
11+
12+
:func:`warn` is the exception to "silently dropped" -- see its docstring.
1113
"""
1214

1315
from __future__ import annotations
1416

17+
import logging
1518
from typing import Callable, Optional
1619

1720
__all__ = ["set_logger"]
@@ -20,6 +23,8 @@
2023

2124
_active_logger: Optional[LoggerFn] = None
2225

26+
_stdlib_logger = logging.getLogger("posthog.mcp")
27+
2328

2429
def set_logger(logger: Optional[LoggerFn]) -> None:
2530
global _active_logger
@@ -33,3 +38,21 @@ def log(message: str) -> None:
3338
except Exception:
3439
# never let logging blow up the tracking pipeline
3540
pass
41+
42+
43+
def warn(message: str) -> None:
44+
"""A misconfiguration the host almost certainly wants to know about, sent to
45+
the ``logger`` option *and* to the ``posthog.mcp`` standard-library logger.
46+
47+
Reserved for warnings that can only fire on an HTTP transport, where the
48+
STDIO constraint above does not apply. A default-configured host still sees
49+
these on stderr (logging's lastResort handler), which is the whole point:
50+
the misconfigurations this is used for are invisible in the data, so a
51+
warning nobody has opted in to receive is a warning nobody reads. Hosts that
52+
do configure logging can route or silence them by name like any other
53+
logger."""
54+
log(message)
55+
try:
56+
_stdlib_logger.warning(message)
57+
except Exception:
58+
pass

‎posthog/mcp/request_headers.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,35 @@ def identify(request, extra):
3838
RequestHeaderBag = Dict[str, str]
3939

4040

41-
def get_request_headers(extra: Any) -> Optional[RequestHeaderBag]:
42-
"""The request's HTTP headers as a plain dict with lowercase keys, or ``None``.
41+
def get_request(extra: Any) -> Optional[Any]:
42+
"""The transport's per-request object (Starlette ``Request`` or equivalent)
43+
underneath ``extra``, or ``None`` on stdio / in-memory transports.
4344
4445
Accepts the ``extra`` dict handed to a callback, or the raw per-request
45-
context itself, so it works whichever one a host happens to hold.
46+
context itself, so it works whichever one a host happens to hold. Both SDK
47+
majors reach it the same way from their own context object
48+
(``ServerRequestContext`` on 2.x, ``RequestContext`` on 1.x).
49+
50+
Shared by anything that needs to read the request beyond just its headers
51+
(e.g. query params) -- one place that knows how to unwrap ``extra``/``ctx``
52+
down to the request, instead of each caller re-deriving it.
4653
"""
4754
ctx = extra
4855
if isinstance(extra, dict):
4956
ctx = extra.get("ctx")
5057
if ctx is None:
5158
return None
59+
return getattr(ctx, "request", None)
60+
5261

53-
# Both majors reach the transport's request the same way from their own
54-
# context object (`ServerRequestContext` on 2.x, `RequestContext` on 1.x);
62+
def get_request_headers(extra: Any) -> Optional[RequestHeaderBag]:
63+
"""The request's HTTP headers as a plain dict with lowercase keys, or ``None``.
64+
65+
Accepts the ``extra`` dict handed to a callback, or the raw per-request
66+
context itself, so it works whichever one a host happens to hold.
67+
"""
5568
# `request` is None on stdio.
56-
source = getattr(getattr(ctx, "request", None), "headers", None)
69+
source = getattr(get_request(extra), "headers", None)
5770
if source is None:
5871
return None
5972
return _to_header_bag(source)

0 commit comments

Comments
 (0)