diff --git a/.snapshots.yml b/.snapshots.yml index 7fafc26ab0ef..d7bbdc71ad08 100644 --- a/.snapshots.yml +++ b/.snapshots.yml @@ -31,7 +31,7 @@ snapshots: pricing--desktop: hash: v1.k54649d6d.86c2b292ff02e47ad5108af5ba373f2ca35dcfa21f348eb9ea1b42e359738b62.A6B3pTKcyehwxJvuKPSGckDNlu03eMegU-3RiSowieI pricing--mobile: - hash: v1.k54649d6d.0960f404d5bac7e7e64fe38f7ff21cadecc89d960074be9725c5fc8dfcb80d1b.GlhFE8hjyRL2xppjOhGeqZ5KhJU3-QPyD0W94lsdlOU + hash: v1.k54649d6d.c4a01959d853183f465782d56ce1d0a6bd6f1385f1cd03349430f937c0dc1e59.oJN4qafmXzE03Fl0tudiFZKdSAvyHQHy3TG_tP02q2U product-analytics--desktop: hash: v1.k54649d6d.93209c9c499bc9651ad6991098a9c7d0deeed55f40b3f72f7c7a835e3177627a.9723yEHo8tmRt44URKU_IitJC9NrSqAVBV9HFb2W2H0 product-analytics--mobile: @@ -43,4 +43,4 @@ snapshots: surveys--desktop: hash: v1.k54649d6d.fff1c7652fe7cfc4de61ea6fd8c9821255cefbcfc9d956423f525bcc8a633cc4.SQoHuWZAI_t0dALwUc-xYOqI8CfCuAAjB4REZWt2Rjs surveys--mobile: - hash: v1.k54649d6d.fa499a502301a1d93c877a9ac62ca9df2d64fd405e1db502ba1b543b1674b57d.Tz1MqBKasGzORj_prX6kW9JKCdf619Y8Ta2fPiK82nM + hash: v1.k54649d6d.3bfb92842973342add36bb85fda355a8cd5b718d5df557e7de7dfe6f63c8ccd2.MzHKvwQpkAT-tT2DknX-xnOwltCkNfW_QbWCd3UVsic diff --git a/contents/docs/mcp-analytics/custom-servers.mdx b/contents/docs/mcp-analytics/custom-servers.mdx index e6dbe60d171d..07e23d00cf6c 100644 --- a/contents/docs/mcp-analytics/custom-servers.mdx +++ b/contents/docs/mcp-analytics/custom-servers.mdx @@ -147,3 +147,29 @@ posthog.flush() # PostHogMCP is a posthog client — flush/shutdown it yourself ``` `PostHogMCP(api_key, missing_capability_tool_name="get_more_tools", mcp_exception_autocapture=True, **posthog_kwargs)` accepts the standard `posthog` client kwargs (e.g. `host`). Set `mcp_exception_autocapture=False` to stop a failed tool call from emitting a `$exception` sibling. As in TypeScript, the wrapping-path hooks (`identify`, `context`, `intent_fallback`, `event_properties`) don't apply here — pass identity and properties on each `capture_*` call. + +### Stateless / multi-pod dispatchers + +On a stateless deployment (a fresh server per request, often across pods) there's no connection to carry a session, so `$session_id` fragments and the client name/version — sent only at `initialize` — go missing from later requests. Add the mint middleware to your ASGI app once. It mints a self-encoded token onto the `Mcp-Session-Id` response header at `initialize` and decodes the client's replay on every later request, so every pod recovers the same values with no shared store: + +```python +from posthog.mcp import PostHogMcpStatelessSessionMiddleware, get_mcp_session + +app.add_middleware(PostHogMcpStatelessSessionMiddleware) + +# ...then in your request handler, feed the recovered session into each capture. +# The token carries the client identity too — pass it as $mcp_client_* properties +# (capture_tool_call takes session_id directly, client name/version via properties): +sess = get_mcp_session(request) # None until the client replays the token +posthog.capture_tool_call( + name, + session_id=sess.session_id if sess else None, + intent=prepared.intent, + properties={ + "$mcp_client_name": sess.client_name if sess else None, + "$mcp_client_version": sess.client_version if sess else None, + }, +) +``` + +The token is unsigned and carries only what the client volunteered at `initialize` — treat `$session_id` and `$mcp_client_*` as analytics labels, not authentication. diff --git a/contents/docs/mcp-analytics/installation.mdx b/contents/docs/mcp-analytics/installation.mdx index b9881919038d..c224165b1b06 100644 --- a/contents/docs/mcp-analytics/installation.mdx +++ b/contents/docs/mcp-analytics/installation.mdx @@ -219,12 +219,6 @@ if (body?.method === "initialize" && !req.headers[MCP_SESSION_HEADER]) { Some frameworks construct the transport for you and don't expose `enableJsonResponse`, and a client that ignores the header falls back to a generated session per request either way. In both cases, group by user with [`identify`](/docs/mcp-analytics/identifying-users) — `distinct_id` groups a person's calls however many requests they span, and it requires nothing from the client. [Conversation IDs](/docs/mcp-analytics/conversation-id) give finer, per-conversation grouping when the agent cooperates. - - -Session tokens are TypeScript-only today. A Python server on a stateless deployment gets a session per request — group with `identify` as above. - - - ## Python A Python SDK ships inside the [`posthog`](/docs/libraries/python) package (the same way [`posthog.ai`](/docs/ai-engineering) does), so there's nothing extra to install: @@ -286,6 +280,26 @@ instrument(server, posthog, MCPAnalyticsOptions( | `event_properties` | `(request, extra) -> dict` | — | Properties merged onto every event. | | `logger` | `(message: str) -> None` | no-op | STDIO-safe log sink. | +### Stateless and multi-pod servers + +Same problem as [above](#stateless-and-multi-pod-servers) — a stateless deployment fragments `$session_id` and loses the client name/version after `initialize`. Python fixes it with the same self-encoded session token (minted onto the `Mcp-Session-Id` header, replayed by the client), but from an ASGI layer, so there's **no `enableJsonResponse` caveat** — JSON and SSE both work. + +On a **FastMCP** server (official `mcp.server.fastmcp` or jlowin's `fastmcp` 2.0) it's zero-config: `instrument()` wraps the server's `streamable_http_app()` / `sse_app()` factories (which `run()` uses too), so just make the server stateless: + +```python +server = FastMCP("my-server", stateless_http=True) +instrument(server, posthog) +server.run(transport="streamable-http") # or: app = server.streamable_http_app() +``` + +When you build the ASGI app yourself — a low-level `Server`, or a custom [`PostHogMCP`](/docs/mcp-analytics/custom-servers) dispatcher — add the middleware to that app once: + +```python +from posthog.mcp import PostHogMcpStatelessSessionMiddleware + +app.add_middleware(PostHogMcpStatelessSessionMiddleware) +``` + ### Flushing on exit The `posthog` client batches events asynchronously and you own its lifecycle. On the `instrument()` path, auto-captured events are scheduled in the background — `await analytics.flush()` waits for in-flight events, then `posthog.flush()` / `posthog.shutdown()` sends them. Call this from your shutdown/`SIGTERM` handler so trailing events aren't dropped (see [`examples/mcp_analytics_demo.py`](https://github.com/PostHog/posthog-python/blob/main/examples/mcp_analytics_demo.py) for a runnable end-to-end example):