Skip to content

Commit 32c8acd

Browse files
authored
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 independent signals: - instrument() warns when streamable_http_app() was already called before it ran (FastMCP's cached _session_manager is the tell). - A one-time runtime warning fires when a tool call arrives over HTTP with no session id and resolution falls back to a generated session. Both point to the manual fix, app.add_middleware(...), now documented in posthog/mcp/README.md. No behavior change on correctly-wired servers or on stdio. Generated-By: PostHog Code Task-Id: 9efb38cb-2672-4236-8da6-208e4f83f686
1 parent 9b4690e commit 32c8acd

9 files changed

Lines changed: 292 additions & 3 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()` now warns when `streamable_http_app()` was already called before it ran, and a one-time runtime warning fires the first time a tool call arrives over HTTP with no session id. Both point to the manual fix (`app.add_middleware(PostHogMcpStatelessSessionMiddleware)`), which is now documented in `posthog/mcp/README.md`. No behavior change on correctly-wired servers or stdio.

‎examples/mcp_stateless.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,14 @@ 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 and sessions fragment silently. Add the
43+
# middleware to your own app explicitly, and 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+
# See posthog/mcp/README.md (stateless / multi-pod servers) for the full rundown.

‎posthog/mcp/README.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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:
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+
### How the SDK tells you it's misconfigured
63+
64+
The failure used to be silent. It now surfaces two ways:
65+
66+
- **At `instrument()`** — if `streamable_http_app()` was already called before
67+
`instrument()` ran (so the live app has no middleware), a warning is logged.
68+
- **At runtime** — the first time a tool call arrives over HTTP with no session id and
69+
PostHog falls back to a per-process `generated` session, a one-time warning is logged.
70+
71+
Both point back to `app.add_middleware(PostHogMcpStatelessSessionMiddleware)`. Warnings
72+
go through the logger you pass via `MCPAnalyticsOptions(logger=...)`.

‎posthog/mcp/_instrument_fastmcp.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ async def wrapped(
110110
request=request,
111111
extra=extra,
112112
token=token,
113+
http_request=_has_http_request(context),
113114
)
114115

115116
missing_name = resolve_missing_capability_tool_name(data.options)
@@ -242,6 +243,7 @@ async def list_handler(req: Any) -> Any:
242243
request=request,
243244
extra=extra,
244245
token=token,
246+
http_request=_low_level_has_http_request(server),
245247
)
246248

247249
start = time.monotonic()
@@ -445,3 +447,20 @@ def _mcp_session_id(context: Any) -> Optional[str]:
445447
except Exception: # noqa: BLE001
446448
pass
447449
return None
450+
451+
452+
def _has_http_request(context: Any) -> bool:
453+
"""True when this call arrived over an HTTP transport (a request object is on the
454+
request context). stdio has none, so this distinguishes a legitimate per-process
455+
session from an HTTP server whose stateless mint middleware never attached."""
456+
try:
457+
return getattr(context.request_context, "request", None) is not None
458+
except Exception: # noqa: BLE001
459+
return False
460+
461+
462+
def _low_level_has_http_request(server: Any) -> bool:
463+
"""HTTP-transport check for the ``tools/list`` seam, which runs on the underlying
464+
low-level server rather than a FastMCP ``Context`` (see ``_low_level_session_id``)."""
465+
ctx = _low_level_request_context(server)
466+
return getattr(ctx, "request", None) is not None

‎posthog/mcp/_instrument_lowlevel.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ async def handler(req: Any) -> Any:
113113
request=request,
114114
extra=extra,
115115
token=token,
116+
http_request=_has_http_request(server),
116117
)
117118

118119
missing_name = resolve_missing_capability_tool_name(data.options)
@@ -254,6 +255,7 @@ async def handler(req: Any) -> Any:
254255
request=request,
255256
extra=extra,
256257
token=token,
258+
http_request=_has_http_request(server),
257259
)
258260

259261
start = time.monotonic()
@@ -402,3 +404,11 @@ def _mcp_session_id(server: Any) -> Optional[str]:
402404
except Exception: # noqa: BLE001
403405
pass
404406
return None
407+
408+
409+
def _has_http_request(server: Any) -> bool:
410+
"""True when the current request arrived over an HTTP transport (a request object
411+
is on the request context). stdio has none — this tells a legitimate per-process
412+
session apart from an HTTP server whose stateless mint middleware never attached."""
413+
ctx = _request_context(server)
414+
return getattr(ctx, "request", None) is not None

‎posthog/mcp/_instrumentation.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,29 @@ def resolve_session_and_client(
240240
return token, client_name, client_version, protocol_version
241241

242242

243+
def _warn_stateless_session_not_wired(data: MCPAnalyticsData) -> None:
244+
"""Warn once per server when a tool call/listing arrives over HTTP with no
245+
session id at all, so PostHog fell back to a per-process ``generated`` session.
246+
247+
That is the fingerprint of a stateless/multi-pod server whose mint middleware
248+
never attached — most often because the ASGI app was built (or mounted from
249+
another module) *before* ``instrument()`` ran, so wrapping the app factories
250+
couldn't retrofit the already-built app. The result is a silently fragmented
251+
``$session_id``; this makes that failure loud instead of dark-in-prod."""
252+
if data.warned_no_stateless_session:
253+
return
254+
data.warned_no_stateless_session = True
255+
log(
256+
"Warning: an MCP tool request arrived over HTTP with no session id, so PostHog "
257+
"generated a per-process $session_id that will fragment across requests and pods. "
258+
"This usually means PostHogMcpStatelessSessionMiddleware never attached — e.g. the "
259+
"ASGI app was built or mounted before instrument() ran. Fix it by adding the "
260+
"middleware to your app explicitly: "
261+
"app.add_middleware(PostHogMcpStatelessSessionMiddleware). "
262+
"See posthog/mcp/README.md (stateless / multi-pod servers)."
263+
)
264+
265+
243266
async def prepare_request(
244267
data: MCPAnalyticsData,
245268
*,
@@ -250,6 +273,7 @@ async def prepare_request(
250273
extra: Optional[Dict[str, Any]],
251274
token: Optional[SessionTokenPayload] = None,
252275
protocol_version: Optional[str] = None,
276+
http_request: bool = False,
253277
) -> str:
254278
"""Resolve the session id, run identify, then lazily emit initialize. Returns
255279
the session id to stamp on the event for this request.
@@ -262,8 +286,20 @@ async def prepare_request(
262286
when ``capture_event`` builds the initialize event — otherwise the first
263287
``$mcp_initialize`` is anonymous even when identify resolves on the same request.
264288
(Still not byte-parity with the TS SDK, which wraps the real initialize handler;
265-
the Python SDK handles initialize in the session layer, not ``request_handlers``.)"""
289+
the Python SDK handles initialize in the session layer, not ``request_handlers``.)
290+
291+
``http_request`` marks requests that arrived over an HTTP transport (vs stdio).
292+
When such a request carries no token and no ``mcp_session_id`` and resolves to a
293+
per-process ``generated`` session, the stateless mint middleware isn't attached —
294+
we warn once so the otherwise-silent misconfiguration surfaces."""
266295
session_id = await resolve_session_id(data, mcp_session_id, token=token)
296+
if (
297+
http_request
298+
and token is None
299+
and not mcp_session_id
300+
and data.session_source == "generated"
301+
):
302+
_warn_stateless_session_not_wired(data)
267303
identify_event = await handle_identify(data, session_id, request, extra)
268304
if identify_event:
269305
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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,30 @@ 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 _warn_if_app_built_before_instrument(server: Any) -> None:
260+
"""Catch the ordering trap that silently disables stateless capture: the
261+
streamable-HTTP app was built (and likely already mounted) *before* ``instrument()``
262+
ran, so wrapping the factories now can't retrofit that already-built app.
263+
264+
FastMCP lazily creates ``_session_manager`` the first time ``streamable_http_app()``
265+
is called, so a non-``None`` value here means the app already exists without our
266+
middleware. Best-effort and guarded — an SDK that doesn't expose this attribute
267+
just yields no warning."""
268+
try:
269+
if getattr(server, "_session_manager", None) is None:
270+
return
271+
except Exception: # noqa: BLE001 - never let a probe break instrument()
272+
return
273+
log(
274+
"Warning: streamable_http_app() was called before instrument(), so the ASGI app "
275+
"already in use has no PostHog MCP middleware and stateless sessions will not be "
276+
"captured (autowiring only affects apps built after instrument() runs). Call "
277+
"instrument(server) before building or mounting the app, or add the middleware "
278+
"manually: app.add_middleware(PostHogMcpStatelessSessionMiddleware). "
279+
"See posthog/mcp/README.md (stateless / multi-pod servers)."
280+
)
281+
282+
258283
def _app_already_wrapped(app: Any) -> bool:
259284
"""True if ``app`` already carries our middleware -- so wrapping a factory that
260285
delegates to another wrapped factory (fastmcp 2.x aliases) doesn't add it twice."""

‎posthog/test/mcp/test_session_token.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
PostHogMcpStatelessSessionMiddleware,
1313
get_mcp_session,
1414
)
15+
from posthog.mcp._instrumentation import prepare_request
16+
from posthog.mcp.logger import set_logger
1517
from posthog.mcp.session import new_session_id, resolve_session_id
1618
from posthog.mcp.session_token import (
1719
MCP_SESSION_HEADER,
@@ -540,3 +542,115 @@ def ping() -> str:
540542
assert payload is not None, "instrument() did not auto-wire the mint"
541543
assert payload.client_name == "Cursor"
542544
assert payload.client_version == "0.42"
545+
546+
547+
# --- loud diagnostics for the silent "middleware never attached" failure -----
548+
549+
550+
def _capture_logs():
551+
"""Route the SDK logger into a list, restoring the previous sink after."""
552+
logs: list[str] = []
553+
set_logger(logs.append)
554+
return logs
555+
556+
557+
async def test_prepare_request_warns_once_on_sessionless_http_request():
558+
"""An HTTP request that resolves to a per-process `generated` session (no token,
559+
no Mcp-Session-Id) is the fingerprint of a stateless server whose mint middleware
560+
never attached. That used to be silent; it must now warn -- but only once, so the
561+
log isn't flooded on every subsequent request."""
562+
logs = _capture_logs()
563+
try:
564+
data = _data()
565+
for _ in range(3):
566+
await prepare_request(
567+
data,
568+
mcp_session_id=None,
569+
client_name=None,
570+
client_version=None,
571+
request={"method": "tools/call", "params": {}},
572+
extra={},
573+
token=None,
574+
http_request=True,
575+
)
576+
finally:
577+
set_logger(None)
578+
579+
warnings = [m for m in logs if "no session id" in m]
580+
assert len(warnings) == 1
581+
assert "add_middleware(PostHogMcpStatelessSessionMiddleware)" in warnings[0]
582+
assert data.warned_no_stateless_session is True
583+
584+
585+
async def test_prepare_request_does_not_warn_for_stdio():
586+
"""stdio has no HTTP request, so a generated per-process session is correct --
587+
never warn there (that would be noise on the common local-dev path)."""
588+
logs = _capture_logs()
589+
try:
590+
data = _data()
591+
await prepare_request(
592+
data,
593+
mcp_session_id=None,
594+
client_name=None,
595+
client_version=None,
596+
request={"method": "tools/call", "params": {}},
597+
extra={},
598+
token=None,
599+
http_request=False,
600+
)
601+
finally:
602+
set_logger(None)
603+
604+
assert not [m for m in logs if "no session id" in m]
605+
assert data.warned_no_stateless_session is False
606+
607+
608+
async def test_prepare_request_does_not_warn_when_token_present():
609+
"""A correctly-wired stateless server replays our token, so the session resolves
610+
from it -- no warning even though the request came over HTTP."""
611+
logs = _capture_logs()
612+
try:
613+
data = _data()
614+
token = decode_session_id(
615+
encode_session_id(SessionTokenPayload(session_id="ses_ok"))
616+
)
617+
await prepare_request(
618+
data,
619+
mcp_session_id=None,
620+
client_name=None,
621+
client_version=None,
622+
request={"method": "tools/call", "params": {}},
623+
extra={},
624+
token=token,
625+
http_request=True,
626+
)
627+
finally:
628+
set_logger(None)
629+
630+
assert not [m for m in logs if "no session id" in m]
631+
632+
633+
def test_autowire_warns_when_app_built_before_instrument():
634+
"""The ordering trap: building streamable_http_app() before instrument() leaves the
635+
live app without our middleware, and wrapping the factory afterward can't retrofit
636+
it. instrument() must warn instead of failing silently."""
637+
pytest.importorskip("starlette.testclient")
638+
from mcp.server.fastmcp import FastMCP
639+
640+
from posthog.mcp import instrument
641+
642+
class _Sink:
643+
def capture(self, *_: object, **__: object) -> None:
644+
pass
645+
646+
srv = FastMCP("posthog-ordering-trap", stateless_http=True, json_response=True)
647+
648+
# Build the app BEFORE instrument() -- the customer's failure mode.
649+
srv.streamable_http_app()
650+
651+
logs: list[str] = []
652+
instrument(srv, _Sink(), MCPAnalyticsOptions(logger=logs.append))
653+
654+
assert any(
655+
"streamable_http_app() was called before instrument()" in m for m in logs
656+
)

0 commit comments

Comments
 (0)