Debugging MCP analytics
Product analytics for MCP servers. A team ships an MCP server; the @posthog/mcp SDK
wraps it in one line; every tool call, agent intent, and failure lands in PostHog as a
$mcp_* event you can query, chart, alert on, and cluster — plus a dedicated dashboard. The
MCP-layer sibling of @posthog/ai.
The differentiator is intent: not "ran query_run 14 times" but "was trying to find a
churn cohort". Explicit non-goal: this does not replace LLM analytics / AI observability
— generation traces, prompt/response, and token cost belong there.
Status: beta, TypeScript and Python SDKs shipped, whole product still behind the
mcp-analytics early-access flag (products/mcp_analytics/frontend/featurePreviewGate.ts).
PostHog dogfoods it — its own MCP server instruments itself, and that data drives the
dashboard. Public tracking: mega-issue PostHog/posthog#64016, which is the live source
for roadmap and customer wishlist.
Repos
GitHub is the source of truth for where the code lives. Paths below are in-repo; for the
repos outside this monorepo, resolve a local checkout via
references/local-repos.md rather than assuming a location.
| Concern |
Repo |
Where to look |
| Product / dashboard |
PostHog/posthog (this repo) |
products/mcp_analytics/ — Django/DRF + HogQL query runners + Temporal, Kea frontend, the query-mcp-* tool registry, and the analysis skills |
| Self-instrumented server |
PostHog/posthog (this repo) |
services/mcp/ — PostHog's own MCP server (Hono); the dogfood event producer. Also hosts the generated query-mcp-* handlers |
| Shared query reference |
PostHog/posthog (this repo) |
models-mcp.md — products/posthog_ai/skills/querying-posthog-data/references/ |
TypeScript SDK @posthog/mcp |
PostHog/posthog-js |
packages/mcp/ — the library customers install. Vocabulary source of truth: src/extensions/constants.ts. docs/ARCHITECTURE.md now covers conversation anchoring (ADR-0004) but trails the newest era handling — where it and CHANGELOG.md disagree, trust the changelog and the source |
Python SDK posthog.mcp |
PostHog/posthog-python |
posthog/mcp/ — mirrors posthog.ai. Ships inside posthog (pip install posthog); mcp/fastmcp are lazily-imported peer deps, no [mcp] extra. At TS parity since 7.40.0-7.42.1 — MCP Python SDK v2, conversation anchoring, typed errors, client UA/vendor |
| Docs |
PostHog/posthog.com |
contents/docs/mcp-analytics/ (incl. surfaces/), plus src/hooks/productData/mcp_analytics.tsx and the mcp_analytics entry in src/data/tools.ts |
| Install codemod |
PostHog/context-mill |
context/skills/mcp-analytics/{config.yaml,description.md} |
| Wizard CLI |
PostHog/wizard |
bin.ts, src/commands/mcp-analytics.ts, src/lib/programs/mcp-analytics/ |
| Wizard test harness |
PostHog/wizard-workbench |
apps/mcp-analytics/ fixtures |
Don't conflate:
PostHog/mcp-analytics is the archived prototype of this SDK — stuck at 0.0.9 with an
old track(server, {...}) API. It published under the same @posthog/mcp name, so grepping
that name can land you there. npm @posthog/mcp now resolves to PostHog/posthog-js.
products/mcp_store/ is the MCP server marketplace / team gateway, not this product. (Older
notes also mention a products/mcp/ build-tooling directory; it no longer exists — the server
and its generation tooling live in services/mcp/.)
wizard mcp add installs the PostHog MCP server into a coding agent. That is NOT
wizard mcp-analytics, which instruments the user's own server.
Line numbers drift and this area moves fast — grep for the symbol, never trust a
remembered line number. Confirm a checkout is on a sane branch before quoting its code.
Hard rules (break these and the numbers are silently wrong)
These are the failure modes that produce a plausible-looking answer rather than an error.
- Always resolve the effective tool name through
EFFECTIVE_TOOL_SQL. The expression
lives once, in products/mcp_analytics/backend/hogql_queries/base.py:
coalesce(nullIf(toString(properties.$mcp_exec_tool_call_name), ''), toString(properties.$mcp_tool_name)).
It exists because a single-exec server can report the tool two different ways, and the two
eras of data coexist. Today services/mcp resolves the inner tool itself and passes it
straight in as the tool name (execToolName() in src/hono/tool-executor.ts, which falls
back to the literal exec when the inner command isn't recognized), so
$mcp_tool_name usually already holds the real tool. $mcp_exec_tool_call_name is
registered in posthog/taxonomy/taxonomy.py and coalesced defensively here, but nothing
on master emits it — treat it as historical rows plus in-flight work, not current
producer behaviour. Either way, aggregate through the coalesce: hand-rolling
properties.$mcp_tool_name alone silently buckets unrecognized exec calls under exec,
and misses any data that does carry the dedicated property.
- Failures come from
$mcp_is_error / $mcp_error_type / $mcp_error_status, never
$exception. $exception can be disabled, isn't emitted when no error value is passed,
and never matched new-SDK events — so querying it returns nothing rather than failing.
- Dash the in-progress bucket. Every time-bucketed chart zero-fills and marks the final
incomplete interval via
products/mcp_analytics/frontend/timeBuckets.ts (resolveWindow,
normalizeBucket, buildBucketKeys, lastBucketIsInProgress). Omit it and a partial
period reads as a real decline.
harness is derived, and its logic exists in three places that must move in lockstep:
products/mcp_analytics/backend/mcp_harness.py (source of truth — see its module
docstring), products/mcp_analytics/frontend/dashboard/harnessRegistry.ts, and
models-mcp.md.
- Check which SDK version the dogfood server is on before trusting dogfood data.
services/mcp consumes the SDK through an alias in its package.json and has historically
lagged the published version, so version-dependent properties (typed error types, $lib
identity, payload redaction) can be absent from PostHog's own data even when documented as
current. A query filtering on $lib = 'posthog-node-mcp' silently excludes all dogfood
traffic if that pin predates SDK 0.7.0. Note too that services/mcp uses the
custom-dispatcher (PostHogMCP) path rather than instrument(), so behaviour living
only in the instrument() path — stable sessions, $identify deduplication, _meta-based
client identity — has never applied to it at any version.
- Know which session model produced the data. Under the stateless spec there is no
transport session, so
$session_id is only stable if the server opted into conversation
anchoring — enableConversationId, which is off by default. With it off, a stateless
client's sessions fragment (often one per request); with it on, $session_id is derived
from an agent-echoed handle and survives reconnects, restarts, and pods. Check the flag
before diagnosing "fragmented sessions" as an ingestion problem. See
references/stateless-and-sessions.md.
- There are no SQL template files. Every dashboard and tool-quality query is a typed
query runner behind the generic
/query/ endpoint. A backend/templates/*.sql referenced
by older notes no longer exists.
Event vocabulary
All data lives on the shared ClickHouse events table — there is no dedicated table.
Every metric is an aggregation over $mcp_tool_call, usually grouped by $session_id.
Source of truth for the SDK-emitted names is packages/mcp/src/extensions/constants.ts in
PostHog/posthog-js, exported as PostHogMCPAnalyticsEvent / PostHogMCPAnalyticsProperty
(import them for typesafe queries). PostHog-side descriptions — including the server-stamped
and exec-mode properties the SDK does not define — live in posthog/taxonomy/taxonomy.py.
Events (all $-prefixed; non-$ names would be treated as customer events):
$mcp_tool_call (primary), $mcp_tools_list, $mcp_initialize, $mcp_missing_capability,
$mcp_resource_read / $mcp_resources_list, $mcp_prompt_get / $mcp_prompts_list,
$identify, $exception.
$mcp_initialize is not a reliable session anchor — but check whose server you're looking
at. The 2026-07-28 revision removes the initialize handshake, so a customer server on the
SDK's instrument() path emits nothing for a stateless client. PostHog's own server is the
exception: services/mcp fires the same $mcp_initialize event from server/discover as
from initialize (dispatcher.ts::recordDiscoveryRequest covers both entry points), so the
event is present in dogfood data either way. Treat its absence as meaningful only for
customer servers. The real anchor is now the conversation handle when the server enables it —
references/stateless-and-sessions.md covers the
resolution order and the delivery protocol. Live consequence, for customer servers only:
frontend/mcpAnalyticsOnboardingLogic.ts derives has_initialize from this event, so a
stateless customer server reads as not-instrumented until its first tool call. Onboarding
still completes — hasToolCall is checked first, in both that selector and
statusFromProbeDefinitions. Projects on services/mcp are unaffected, since it emits the
event from server/discover.
Full property tables — split by provenance (SDK-emitted vs stamped by PostHog's own server vs
exec-mode only), the identifier distinctions, per-version SDK behaviour, and TypeScript/Python
parity — are in references/event-vocabulary.md. Read that
before writing queries or changing what gets captured.
Reading the data
Prefer the dedicated analysis skills over hand-written HogQL; they already encode the
exec-mode and harness handling that Hard rules 1 and 4 describe:
exploring-mcp-tool-usage — front door / router: takes a broad "how is my MCP doing"
question and dispatches to the right typed tool or focused skill. Start here.
exploring-mcp-tool-quality — error rates, latency, reach, failing and slow tools.
exploring-mcp-sessions — session list, per-session tool calls, intent.
exploring-mcp-intent-clusters — "what are people trying to do" clusters.
improving-mcp-tools — eval-scored campaign loop: measure, make one bounded fix, re-measure.
Typed tools exist for most questions and are preferable to raw SQL: posthog:query-mcp-tool-stats,
-daily-stats, -failures, -failure-occurrences, -descriptions, -neighbors,
-sample-intents, -top-users, and posthog:query-mcp-harness-breakdown, plus session tools
(posthog:mcp-analytics-sessions-list / -tool-calls / -generate-intent) and the intent-cluster
tools. They are declared in products/mcp_analytics/mcp/tools.yaml.
Harness is the friendly label for the calling client (Claude Code, Cursor, ChatGPT,
Windsurf, and ~30 other buckets). It is resolved at query time only, with no stored column:
mcp_harness.py::HARNESS_TOKEN_SQL picks the strongest available signal in priority order,
over exactly three properties — the ones the SDK schemas can emit
($mcp_vendor_client, with the legacy non-$ mcp_vendor_client coalesced for historical
rows -> Claude Code user-agent surface -> Grok user-agent -> $mcp_client_name -> generic
user-agent token, both from $mcp_client_user_agent), then
harness_label_sql() buckets it (or harness_label_or_token_sql(), which names an
unrecognized client verbatim instead of collapsing it into "Other" — use it for ranked
top-N lists, never where labels feed an array or unbounded GROUP BY).
$mcp_client_name is one mid-priority input, not a synonym for harness — grouping by
it directly gives a different, messier answer: on old SDK versions it rode only on the
session's initialize, and Anthropic's pooled surfaces self-report a generic
Anthropic/ClaudeAI that only the vendor header can disambiguate. The dogfood-only
mcp_session_client_name and $mcp_oauth_client_name are no longer read by harness
resolution — the server folds the session-pinned name into per-event $mcp_client_name,
and neither property ever resolved an event alone.
For hand-written SQL, models-mcp.md
carries the property reference and worked query examples.
The pipeline, and where each stage breaks
- Instrument -> the server emits
$mcp_* events via the SDK.
Breaks: handlers not wrapped (instrument() is idempotent and degrades to a silent
no-op on failure); a STDIO server writing to stdout with console.* (corrupts the
protocol stream — wire a logger); a disabled or misconfigured posthog-node client.
For services/mcp there is a single emission path: src/hono/analytics.ts +
src/hono/tool-executor.ts -> getPostHogClient() (src/lib/posthog/client.ts) ->
PostHogMCP, consumed through the dependency alias @posthog/mcp-analytics (the alias
matters when grepping imports). The legacy MCPcat/AgentCat shim and the transition shim
that dual-emitted non-$ mcp_tool_call / mcp_initialize were both removed and are
regression-tested in services/mcp/tests/hono/. services/mcp/ARCHITECTURE.md still
describes the old multi-emitter design and references a deleted lib/mcpcat.ts — trust
the source, not that document.
- Ingest -> events land in ClickHouse
events. Breaks: ordinary ingestion and quota
problems; $session_id not materialized, which breaks session grouping.
- Session list ->
backend/logic.py::list_mcp_sessions runs HogQL over a 7-day
default window (DEFAULT_SESSIONS_DATE_FROM, resolved through QueryDateRange with a
one-day overlap buffer each side) and caches for 30s (SESSIONS_CACHE_TTL_SECONDS).
Breaks: anything outside the window simply isn't there; results can be up to 30s stale.
- Charts and tool quality -> typed
AnalyticsQueryRunner subclasses in
backend/hogql_queries/ (base.py, dashboard_series.py, harness_breakdown.py,
tool_quality_tables.py, tool_tables.py), dispatched via the generic /query/ endpoint
and enumerated in backend/facade/queries.py, with schemas in posthog/schema.py.
Gate: hogql_queries/base.py::validate_mcp_analytics_access — the feature flag plus
the mcp_analytics RBAC resource. Breaks: flag off, RBAC denies, or Hard rules 1-3
ignored.
- Intent generation (on demand, per session) -> collect
$mcp_intent values -> an LLM
summary of at most two sentences -> Postgres posthog_mcp_session. A second,
project-level path produces the intent digest / themes with structured output, bounded
by MAX_DIGEST_THEMES; resolve_themes() derives every countable field from the corpus
so the model cannot invent numbers. Model constants live in backend/intent_generation.py.
Breaks: no $mcp_intent captured at all (the agent never filled the injected context
argument and no intentFallback was configured), so there is nothing to summarize; LLM
key or quota problems.
- Intent clustering (behind
mcp-analytics-intent-routing) -> embed (cached in
MCPIntentEmbeddingCache) -> agglomerative clustering (cosine, average linkage,
DEFAULT_DISTANCE_THRESHOLD) -> JSONB
MCPIntentClusterSnapshot. Temporal end-to-end, no Celery. On-demand recompute
(trigger_intent_cluster_recompute, serialized with select_for_update() and a
deterministic per-team workflow id) and the cluster_mcp_intents management command both
start the workflow; the daily run is a Temporal Schedule
(posthog/temporal/mcp_analytics/intent_clustering/schedule.py, behind the
mcp-analytics-clustering-schedule flag) that triggers
IntentClusteringCoordinatorWorkflow, which fans out one child workflow per team.
Two caps will surprise you: MAX_SNAPSHOT_CLUSTERS (snapshots keep only the top clusters
by volume, enforced at write and again at read) and MAX_QUERY_ROWS.
Note the corpus does not depend on step 5: fetch_intent_corpus takes each session's
first $mcp_intent straight from ClickHouse and only overrides it with the stored LLM
summary where one exists. So a project can cluster with no generated summaries at all.
Breaks: empty clusters almost always mean no $mcp_intent values in the lookback window
(check the corpus before chasing summary generation); schedule flag off; stale embeddings.
Also check the allowlist — intent_clustering/team_discovery.py currently returns a
hard-coded GUARANTEED_TEAM_IDS = [2], so the daily schedule covers only PostHog's own
project and enabling the flag elsewhere still produces nothing until that changes.
- Serve -> DRF viewsets at
/api/projects/{id}/mcp_analytics/{sessions,intent_clusters,feedback,missing_capabilities}
(router in backend/presentation/urls.py) plus custom actions
(sessions/{id}/tool_calls, sessions/{id}/generate_intent, sessions/intent_digest,
sessions/activity_overview, intent_clusters/recompute). Parallel surface: step 4's
runners, exposed to agents as the query-mcp-* tools. The intent-cluster read and
recompute endpoints require mcp-analytics-intent-routing; the other endpoints use
mcp-analytics.
- Frontend -> Kea scene
MCPAnalyticsScene.tsx, with tabs enumerated by
MCPAnalyticsTab in mcpAnalyticsSceneLogic.ts: activity, dashboard, sessions,
tool quality, intent clustering, notifications. The landing tab is volume-gated by
dashboardStage in mcpAnalyticsOnboardingLogic.ts and applies only to the bare
/mcp-analytics redirect — deep links and explicit tab clicks are never overridden.
The intent clustering tab, dashboard KPI, and tool-detail cluster section are all gated by
mcp-analytics-intent-routing; a direct unflagged link renders the standard not-found page.
- Activity (
earlyData/): live tool-call feed plus the intent-themes card.
"Theme" (the LLM digest, Activity tab) is not "cluster" (the embedding clustering,
its own tab). Conflating the two is the most common mistake here.
- Tool quality and the per-tool tool report (
MCPAnalyticsToolDetail.tsx, its own
registered scene): shared date filter, failure-occurrence drill-down with copyable error
context, and "create fix task" straight into products/tasks.
- Dashboard: quill composable
Metric tiles and @posthog/quill-primitives, plus
notable sessions selected by a NotableRule — so that table can legitimately be
short or empty.
- Notifications: first-party destinations for MCP events and recurring AI reports
(
frontend/notifications/), thin wiring over the generic hog-function destination and
subscription machinery.
Postgres models (backend/models.py): MCPSession (the intent store),
MCPIntentClusterSnapshot, MCPAnalyticsSubmission (feedback and missing-capability
reports), MCPIntentEmbeddingCache.
Seeding local data: ./manage.py seed_mcp_sessions --team-id N
(backend/management/commands/), with --sessions, --min-calls/--max-calls, --days,
--missing-capabilities, --seed, and --clear. Seeded events are tagged $mcp_seeded so
--clear removes only seeded data.
Which repo to change
| Change |
Repo |
Workflow |
| SDK behaviour, events, options, instrumentation |
PostHog/posthog-js |
Work in packages/mcp. Run its unit tests, build, and lint. Add a changeset. Ships to npm; then bump the alias in services/mcp/package.json to pick it up. |
| Dashboard, queries, clustering, API |
this repo |
A new chart means a query runner in backend/hogql_queries/ behind validate_mcp_analytics_access — never a SQL template. Obey Hard rules 1-3. |
A new query-mcp-* agent tool |
this repo (two places) |
1) an entry in products/mcp_analytics/mcp/tools.yaml with schema_ref, scopes, description, feature_flag; 2) the matching <Name>Query schema and <Name>QueryRunner in backend/hogql_queries/; 3) regenerate the tool handlers from services/mcp (see its package.json scripts). The generic createQueryWrapper handles the tool shape — no hand-written TypeScript. |
| PostHog's own dogfood events |
this repo |
services/mcp/src/hono/analytics.ts + tool-executor.ts; client in src/lib/posthog/client.ts. |
| Docs |
PostHog/posthog.com |
Keep the event and property tables in contents/docs/mcp-analytics/events.mdx synced with both the TypeScript constants.ts and the Python posthog/mcp/constants.py. |
| The install codemod or the wizard command |
PostHog/context-mill, PostHog/wizard |
See references/wizard-and-onboarding.md — in particular the rule about which changes need a wizard release and which do not. |
Rule of thumb: a change to what gets captured, or how servers are instrumented belongs
in the SDKs. How data is shown, aggregated, or clustered belongs in this product. PostHog's
own dogfood events belong in services/mcp. A new customer-facing capability usually spans
an SDK plus docs, and the product too if it needs a view.
The wizard install flow, the skill-distribution channels, and the in-app onboarding are all in
references/wizard-and-onboarding.md.
Current state
Verified against master, @posthog/mcp 0.11.7, posthog 7.44.0, and MCP spec 2026-07-28
on 2026-08-25. Treat versions and open threads as perishable: re-check
packages/mcp/CHANGELOG.md, the pinned alias in services/mcp/package.json, and
mega-issue 64016 rather than trusting this
section.
Both SDKs now speak the stateless spec and the v2 MCP SDKs. services/mcp speaks both
dialects at the protocol layer (src/lib/stateless-protocol.ts — per-request dialect
detection, server/discover, no session minting for modern clients). The TypeScript SDK's
0.10.9-0.11.7 run instruments MCP TypeScript SDK v2 servers (structural detection in
detect.ts, both @modelcontextprotocol peers optional), resolves client identity and
protocol version through a per-request fallback chain, gates Mcp-Session-Id minting on the
revision each request declares, and captures $mcp_client_user_agent / $mcp_vendor_client.
The Python SDK caught up in posthog 7.40.0-7.42.1: MCP Python SDK v2, conversation-anchored
sessions byte-compatible with TS (derive_session_id_from_conversation), typed
$mcp_error_type / $mcp_error_message, and the same UA/vendor capture. The old parity
threads (posthog-python 803 and
830) were closed unmerged and
superseded — don't cite them as the source of what landed.
references/stateless-and-sessions.md is the reference
for all of it.
Also shipped: structured intent themes, first-party notification destinations and recurring
reports, mcp_analytics access control, the shared ProductEmptyState adoption,
failure-occurrence drill-down with "create fix task", the migration of every chart to typed
query runners, the demo seeder, and exec-mode inner-tool breakout (Hard rule 1).
What still lags, all checkable in this repo: the services/mcp alias pin is 0.10.2 against a
0.11.7 SDK (Hard rule 5 — no 0.11.x SDK-side fix or SDK-emitted property reaches dogfood data,
though the server independently stamps $mcp_client_user_agent and the legacy non-$
mcp_vendor_client regardless of the pin; harness resolution reads the SDK-emitted
$mcp_vendor_client first and coalesces the legacy name for those rows);
the exec-property emitter is still absent from
master (Hard rule 1); the clustering schedule still covers only GUARANTEED_TEAM_IDS = [2];
and the product remains behind the mcp-analytics flag, so a project without it sees nothing.
1---2name: debugging-mcp-analytics3description: Debug, support, and build PostHog MCP Analytics — product analytics for MCP servers (the `@posthog/mcp` and `posthog.mcp` SDKs plus the mcp_analytics product). Use when MCP analytics data looks wrong or missing ("events aren't showing", "intent clusters are empty", "sessions are missing", "per-tool numbers look wrong"), when writing queries over `$mcp_*` events by hand, or when doing feature work on the SDKs, the dashboard and its query runners, the self-instrumented MCP server, the `wizard mcp-analytics` install command, or the in-app onboarding. Covers the repo map, the `$mcp_*` vocabulary and where each property comes from, the rules that silently corrupt metrics when ignored, the end-to-end pipeline and where each stage breaks, and which repo to change. For reading the data rather than fixing it, prefer the `exploring-mcp-*` and `improving-mcp-tools` skills.4---5
6# Debugging MCP analytics
7
8**Product analytics for MCP servers.** A team ships an MCP server; the `@posthog/mcp` SDK
9wraps it in one line; every tool call, agent **intent**, and failure lands in PostHog as a
10`$mcp_*` event you can query, chart, alert on, and cluster — plus a dedicated dashboard. The
11MCP-layer sibling of `@posthog/ai`.
12
13The differentiator is **intent**: not "ran `query_run` 14 times" but "was trying to find a
14churn cohort". Explicit non-goal: this does **not** replace LLM analytics / AI observability
15— generation traces, prompt/response, and token cost belong there.
16
17Status: **beta**, TypeScript and Python SDKs shipped, whole product still behind the
18`mcp-analytics` early-access flag (`products/mcp_analytics/frontend/featurePreviewGate.ts`).
19PostHog dogfoods it — its own MCP server instruments itself, and that data drives the
20dashboard. Public tracking: mega-issue **PostHog/posthog#64016**, which is the live source
21for roadmap and customer wishlist.
22
23## Repos
24
25GitHub is the source of truth for where the code lives. Paths below are in-repo; for the
26repos outside this monorepo, resolve a local checkout via
27[references/local-repos.md](references/local-repos.md) rather than assuming a location.
28
29| Concern | Repo | Where to look |
30| --------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31| **Product / dashboard** | `PostHog/posthog` (this repo) | `products/mcp_analytics/` — Django/DRF + HogQL query runners + Temporal, Kea frontend, the `query-mcp-*` tool registry, and the analysis skills |
32| **Self-instrumented server** | `PostHog/posthog` (this repo) | `services/mcp/` — PostHog's own MCP server (Hono); the dogfood event producer. Also hosts the _generated_ `query-mcp-*` handlers |
33| **Shared query reference** | `PostHog/posthog` (this repo) | [`models-mcp.md`](../../../posthog_ai/skills/querying-posthog-data/references/models-mcp.md) — `products/posthog_ai/skills/querying-posthog-data/references/` |
34| **TypeScript SDK** `@posthog/mcp` | `PostHog/posthog-js` | `packages/mcp/` — the library customers install. Vocabulary source of truth: `src/extensions/constants.ts`. `docs/ARCHITECTURE.md` now covers conversation anchoring (ADR-0004) but trails the newest era handling — where it and `CHANGELOG.md` disagree, trust the changelog and the source |
35| **Python SDK** `posthog.mcp` | `PostHog/posthog-python` | `posthog/mcp/` — mirrors `posthog.ai`. Ships inside `posthog` (`pip install posthog`); `mcp`/`fastmcp` are lazily-imported peer deps, **no `[mcp]` extra**. At TS parity since 7.40.0-7.42.1 — MCP Python SDK v2, conversation anchoring, typed errors, client UA/vendor |
36| **Docs** | `PostHog/posthog.com` | `contents/docs/mcp-analytics/` (incl. `surfaces/`), plus `src/hooks/productData/mcp_analytics.tsx` and the `mcp_analytics` entry in `src/data/tools.ts` |
37| **Install codemod** | `PostHog/context-mill` | `context/skills/mcp-analytics/{config.yaml,description.md}` |
38| **Wizard CLI** | `PostHog/wizard` | `bin.ts`, `src/commands/mcp-analytics.ts`, `src/lib/programs/mcp-analytics/` |
39| **Wizard test harness** | `PostHog/wizard-workbench` | `apps/mcp-analytics/` fixtures |
40
41**Don't conflate:**
42
43- `PostHog/mcp-analytics` is the **archived prototype** of this SDK — stuck at `0.0.9` with an
44 old `track(server, {...})` API. It published under the same `@posthog/mcp` name, so grepping
45 that name can land you there. npm `@posthog/mcp` now resolves to `PostHog/posthog-js`.
46- `products/mcp_store/` is the MCP server marketplace / team gateway, not this product. (Older
47 notes also mention a `products/mcp/` build-tooling directory; it no longer exists — the server
48 and its generation tooling live in `services/mcp/`.)
49- `wizard mcp add` installs the PostHog **MCP server** into a coding agent. That is NOT
50 `wizard mcp-analytics`, which instruments the user's _own_ server.
51
52> Line numbers drift and this area moves fast — **grep for the symbol**, never trust a
53> remembered line number. Confirm a checkout is on a sane branch before quoting its code.
54
55## Hard rules (break these and the numbers are silently wrong)
56
57These are the failure modes that produce a plausible-looking answer rather than an error.
58
591. **Always resolve the effective tool name through `EFFECTIVE_TOOL_SQL`.** The expression
60 lives once, in `products/mcp_analytics/backend/hogql_queries/base.py`:
61 `coalesce(nullIf(toString(properties.$mcp_exec_tool_call_name), ''), toString(properties.$mcp_tool_name))`.
62 It exists because a single-exec server can report the tool two different ways, and the two
63 eras of data coexist. Today `services/mcp` resolves the inner tool itself and passes it
64 straight in as the tool name (`execToolName()` in `src/hono/tool-executor.ts`, which falls
65 back to the literal `exec` when the inner command isn't recognized), so
66 `$mcp_tool_name` usually already holds the real tool. `$mcp_exec_tool_call_name` is
67 registered in `posthog/taxonomy/taxonomy.py` and coalesced defensively here, but **nothing
68 on master emits it** — treat it as historical rows plus in-flight work, not current
69 producer behaviour. Either way, aggregate through the coalesce: hand-rolling
70 `properties.$mcp_tool_name` alone silently buckets unrecognized exec calls under `exec`,
71 and misses any data that does carry the dedicated property.
722. **Failures come from `$mcp_is_error` / `$mcp_error_type` / `$mcp_error_status`, never
73 `$exception`.** `$exception` can be disabled, isn't emitted when no error value is passed,
74 and never matched new-SDK events — so querying it returns nothing rather than failing.
753. **Dash the in-progress bucket.** Every time-bucketed chart zero-fills and marks the final
76 incomplete interval via `products/mcp_analytics/frontend/timeBuckets.ts` (`resolveWindow`,
77 `normalizeBucket`, `buildBucketKeys`, `lastBucketIsInProgress`). Omit it and a partial
78 period reads as a real decline.
794. **`harness` is derived, and its logic exists in three places that must move in lockstep:**
80 `products/mcp_analytics/backend/mcp_harness.py` (source of truth — see its module
81 docstring), `products/mcp_analytics/frontend/dashboard/harnessRegistry.ts`, and
82 [`models-mcp.md`](../../../posthog_ai/skills/querying-posthog-data/references/models-mcp.md).
835. **Check which SDK version the dogfood server is on before trusting dogfood data.**
84 `services/mcp` consumes the SDK through an alias in its `package.json` and has historically
85 lagged the published version, so version-dependent properties (typed error types, `$lib`
86 identity, payload redaction) can be absent from PostHog's own data even when documented as
87 current. A query filtering on `$lib = 'posthog-node-mcp'` silently excludes all dogfood
88 traffic if that pin predates SDK 0.7.0. Note too that `services/mcp` uses the
89 **custom-dispatcher** (`PostHogMCP`) path rather than `instrument()`, so behaviour living
90 only in the `instrument()` path — stable sessions, `$identify` deduplication, `_meta`-based
91 client identity — has never applied to it at any version.
926. **Know which session model produced the data.** Under the stateless spec there is no
93 transport session, so `$session_id` is only stable if the server opted into conversation
94 anchoring — `enableConversationId`, which is **off by default**. With it off, a stateless
95 client's sessions fragment (often one per request); with it on, `$session_id` is derived
96 from an agent-echoed handle and survives reconnects, restarts, and pods. Check the flag
97 before diagnosing "fragmented sessions" as an ingestion problem. See
98 [references/stateless-and-sessions.md](references/stateless-and-sessions.md).
997. **There are no SQL template files.** Every dashboard and tool-quality query is a typed
100 query runner behind the generic `/query/` endpoint. A `backend/templates/*.sql` referenced
101 by older notes no longer exists.
102
103## Event vocabulary
104
105All data lives on the shared ClickHouse **`events`** table — there is **no dedicated table**.
106Every metric is an aggregation over `$mcp_tool_call`, usually grouped by `$session_id`.
107
108Source of truth for the SDK-emitted names is `packages/mcp/src/extensions/constants.ts` in
109`PostHog/posthog-js`, exported as `PostHogMCPAnalyticsEvent` / `PostHogMCPAnalyticsProperty`
110(import them for typesafe queries). PostHog-side descriptions — including the server-stamped
111and exec-mode properties the SDK does not define — live in `posthog/taxonomy/taxonomy.py`.
112
113**Events** (all `$`-prefixed; non-`$` names would be treated as customer events):
114`$mcp_tool_call` (primary), `$mcp_tools_list`, `$mcp_initialize`, `$mcp_missing_capability`,
115`$mcp_resource_read` / `$mcp_resources_list`, `$mcp_prompt_get` / `$mcp_prompts_list`,
116`$identify`, `$exception`.
117
118> **`$mcp_initialize` is not a reliable session anchor — but check whose server you're looking
119> at.** The 2026-07-28 revision removes the `initialize` handshake, so a customer server on the
120> SDK's `instrument()` path emits nothing for a stateless client. **PostHog's own server is the
121> exception**: `services/mcp` fires the same `$mcp_initialize` event from `server/discover` as
122> from `initialize` (`dispatcher.ts::recordDiscoveryRequest` covers both entry points), so the
123> event is present in dogfood data either way. Treat its absence as meaningful only for
124> customer servers. The real anchor is now the conversation handle when the server enables it —
125> [references/stateless-and-sessions.md](references/stateless-and-sessions.md) covers the
126> resolution order and the delivery protocol. Live consequence, for customer servers only:
127> `frontend/mcpAnalyticsOnboardingLogic.ts` derives `has_initialize` from this event, so a
128> stateless customer server reads as `not-instrumented` until its first tool call. Onboarding
129> still completes — `hasToolCall` is checked first, in both that selector and
130> `statusFromProbeDefinitions`. Projects on `services/mcp` are unaffected, since it emits the
131> event from `server/discover`.
132
133Full property tables — split by provenance (SDK-emitted vs stamped by PostHog's own server vs
134exec-mode only), the identifier distinctions, per-version SDK behaviour, and TypeScript/Python
135parity — are in [references/event-vocabulary.md](references/event-vocabulary.md). Read that
136before writing queries or changing what gets captured.
137
138## Reading the data
139
140**Prefer the dedicated analysis skills** over hand-written HogQL; they already encode the
141exec-mode and harness handling that Hard rules 1 and 4 describe:
142
143- `exploring-mcp-tool-usage` — front door / router: takes a broad "how is my MCP doing"
144 question and dispatches to the right typed tool or focused skill. Start here.
145- `exploring-mcp-tool-quality` — error rates, latency, reach, failing and slow tools.
146- `exploring-mcp-sessions` — session list, per-session tool calls, intent.
147- `exploring-mcp-intent-clusters` — "what are people trying to do" clusters.
148- `improving-mcp-tools` — eval-scored campaign loop: measure, make one bounded fix, re-measure.
149
150**Typed tools** exist for most questions and are preferable to raw SQL: `posthog:query-mcp-tool-stats`,
151`-daily-stats`, `-failures`, `-failure-occurrences`, `-descriptions`, `-neighbors`,
152`-sample-intents`, `-top-users`, and `posthog:query-mcp-harness-breakdown`, plus session tools
153(`posthog:mcp-analytics-sessions-list` / `-tool-calls` / `-generate-intent`) and the intent-cluster
154tools. They are declared in `products/mcp_analytics/mcp/tools.yaml`.
155
156**Harness** is the friendly label for the calling client (Claude Code, Cursor, ChatGPT,
157Windsurf, and ~30 other buckets). It is resolved at query time only, with no stored column:
158`mcp_harness.py::HARNESS_TOKEN_SQL` picks the strongest available signal in priority order,
159over exactly three properties — the ones the SDK schemas can emit
160(`$mcp_vendor_client`, with the legacy non-`$` `mcp_vendor_client` coalesced for historical
161rows -> Claude Code user-agent surface -> Grok user-agent -> `$mcp_client_name` -> generic
162user-agent token, both from `$mcp_client_user_agent`), then
163`harness_label_sql()` buckets it (or `harness_label_or_token_sql()`, which names an
164unrecognized client verbatim instead of collapsing it into "Other" — use it for ranked
165top-N lists, never where labels feed an array or unbounded GROUP BY).
166
167**`$mcp_client_name` is one mid-priority input, not a synonym for harness** — grouping by
168it directly gives a different, messier answer: on old SDK versions it rode only on the
169session's `initialize`, and Anthropic's pooled surfaces self-report a generic
170`Anthropic/ClaudeAI` that only the vendor header can disambiguate. The dogfood-only
171`mcp_session_client_name` and `$mcp_oauth_client_name` are **no longer read** by harness
172resolution — the server folds the session-pinned name into per-event `$mcp_client_name`,
173and neither property ever resolved an event alone.
174
175For hand-written SQL, [`models-mcp.md`](../../../posthog_ai/skills/querying-posthog-data/references/models-mcp.md)
176carries the property reference and worked query examples.
177
178## The pipeline, and where each stage breaks
179
1801. **Instrument** -> the server emits `$mcp_*` events via the SDK.
181 _Breaks:_ handlers not wrapped (`instrument()` is idempotent and degrades to a silent
182 no-op on failure); a STDIO server writing to stdout with `console.*` (corrupts the
183 protocol stream — wire a `logger`); a disabled or misconfigured posthog-node client.
184 For `services/mcp` there is a **single emission path**: `src/hono/analytics.ts` +
185 `src/hono/tool-executor.ts` -> `getPostHogClient()` (`src/lib/posthog/client.ts`) ->
186 `PostHogMCP`, consumed through the dependency alias `@posthog/mcp-analytics` (the alias
187 matters when grepping imports). The legacy MCPcat/AgentCat shim and the transition shim
188 that dual-emitted non-`$` `mcp_tool_call` / `mcp_initialize` were both removed and are
189 regression-tested in `services/mcp/tests/hono/`. **`services/mcp/ARCHITECTURE.md` still
190 describes the old multi-emitter design and references a deleted `lib/mcpcat.ts` — trust
191 the source, not that document.**
1922. **Ingest** -> events land in ClickHouse `events`. _Breaks:_ ordinary ingestion and quota
193 problems; `$session_id` not materialized, which breaks session grouping.
1943. **Session list** -> `backend/logic.py::list_mcp_sessions` runs HogQL over a **7-day
195 default window** (`DEFAULT_SESSIONS_DATE_FROM`, resolved through `QueryDateRange` with a
196 one-day overlap buffer each side) and caches for 30s (`SESSIONS_CACHE_TTL_SECONDS`).
197 _Breaks:_ anything outside the window simply isn't there; results can be up to 30s stale.
1984. **Charts and tool quality** -> typed `AnalyticsQueryRunner` subclasses in
199 `backend/hogql_queries/` (`base.py`, `dashboard_series.py`, `harness_breakdown.py`,
200 `tool_quality_tables.py`, `tool_tables.py`), dispatched via the generic `/query/` endpoint
201 and enumerated in `backend/facade/queries.py`, with schemas in `posthog/schema.py`.
202 Gate: `hogql_queries/base.py::validate_mcp_analytics_access` — the feature flag **plus**
203 the `mcp_analytics` RBAC resource. _Breaks:_ flag off, RBAC denies, or Hard rules 1-3
204 ignored.
2055. **Intent generation** (on demand, per session) -> collect `$mcp_intent` values -> an LLM
206 summary of at most two sentences -> Postgres `posthog_mcp_session`. A second,
207 project-level path produces the **intent digest / themes** with structured output, bounded
208 by `MAX_DIGEST_THEMES`; `resolve_themes()` derives every countable field from the corpus
209 so the model cannot invent numbers. Model constants live in `backend/intent_generation.py`.
210 _Breaks:_ no `$mcp_intent` captured at all (the agent never filled the injected `context`
211 argument and no `intentFallback` was configured), so there is nothing to summarize; LLM
212 key or quota problems.
2136. **Intent clustering** (behind `mcp-analytics-intent-routing`) -> embed (cached in
214 `MCPIntentEmbeddingCache`) -> agglomerative clustering (cosine, average linkage,
215 `DEFAULT_DISTANCE_THRESHOLD`) -> JSONB
216 `MCPIntentClusterSnapshot`. **Temporal end-to-end, no Celery.** On-demand recompute
217 (`trigger_intent_cluster_recompute`, serialized with `select_for_update()` and a
218 deterministic per-team workflow id) and the `cluster_mcp_intents` management command both
219 start the workflow; the daily run is a Temporal **Schedule**
220 (`posthog/temporal/mcp_analytics/intent_clustering/schedule.py`, behind the
221 `mcp-analytics-clustering-schedule` flag) that triggers
222 `IntentClusteringCoordinatorWorkflow`, which fans out one child workflow per team.
223 Two caps will surprise you: `MAX_SNAPSHOT_CLUSTERS` (snapshots keep only the top clusters
224 by volume, enforced at write and again at read) and `MAX_QUERY_ROWS`.
225 Note the corpus does **not** depend on step 5: `fetch_intent_corpus` takes each session's
226 first `$mcp_intent` straight from ClickHouse and only _overrides_ it with the stored LLM
227 summary where one exists. So a project can cluster with no generated summaries at all.
228 _Breaks:_ empty clusters almost always mean no `$mcp_intent` values in the lookback window
229 (check the corpus before chasing summary generation); schedule flag off; stale embeddings.
230 Also check the allowlist — `intent_clustering/team_discovery.py` currently returns a
231 hard-coded `GUARANTEED_TEAM_IDS = [2]`, so the daily schedule covers only PostHog's own
232 project and enabling the flag elsewhere still produces nothing until that changes.
2337. **Serve** -> DRF viewsets at
234 `/api/projects/{id}/mcp_analytics/{sessions,intent_clusters,feedback,missing_capabilities}`
235 (router in `backend/presentation/urls.py`) plus custom actions
236 (`sessions/{id}/tool_calls`, `sessions/{id}/generate_intent`, `sessions/intent_digest`,
237 `sessions/activity_overview`, `intent_clusters/recompute`). Parallel surface: step 4's
238 runners, exposed to agents as the `query-mcp-*` tools. The intent-cluster read and
239 recompute endpoints require `mcp-analytics-intent-routing`; the other endpoints use
240 `mcp-analytics`.
2418. **Frontend** -> Kea scene `MCPAnalyticsScene.tsx`, with tabs enumerated by
242 `MCPAnalyticsTab` in `mcpAnalyticsSceneLogic.ts`: activity, dashboard, sessions,
243 tool quality, intent clustering, notifications. The landing tab is volume-gated by
244 `dashboardStage` in `mcpAnalyticsOnboardingLogic.ts` and applies only to the bare
245 `/mcp-analytics` redirect — deep links and explicit tab clicks are never overridden.
246 The intent clustering tab, dashboard KPI, and tool-detail cluster section are all gated by
247 `mcp-analytics-intent-routing`; a direct unflagged link renders the standard not-found page.
248 - **Activity** (`earlyData/`): live tool-call feed plus the intent-**themes** card.
249 "Theme" (the LLM digest, Activity tab) is **not** "cluster" (the embedding clustering,
250 its own tab). Conflating the two is the most common mistake here.
251 - **Tool quality** and the per-tool **tool report** (`MCPAnalyticsToolDetail.tsx`, its own
252 registered scene): shared date filter, failure-occurrence drill-down with copyable error
253 context, and "create fix task" straight into `products/tasks`.
254 - **Dashboard**: quill composable `Metric` tiles and `@posthog/quill-primitives`, plus
255 **notable sessions** selected by a `NotableRule` — so that table can legitimately be
256 short or empty.
257 - **Notifications**: first-party destinations for MCP events and recurring AI reports
258 (`frontend/notifications/`), thin wiring over the generic hog-function destination and
259 subscription machinery.
260
261Postgres models (`backend/models.py`): `MCPSession` (the intent store),
262`MCPIntentClusterSnapshot`, `MCPAnalyticsSubmission` (feedback and missing-capability
263reports), `MCPIntentEmbeddingCache`.
264
265**Seeding local data:** `./manage.py seed_mcp_sessions --team-id N`
266(`backend/management/commands/`), with `--sessions`, `--min-calls`/`--max-calls`, `--days`,
267`--missing-capabilities`, `--seed`, and `--clear`. Seeded events are tagged `$mcp_seeded` so
268`--clear` removes only seeded data.
269
270## Which repo to change
271
272| Change | Repo | Workflow |
273| ----------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
274| SDK behaviour, events, options, instrumentation | `PostHog/posthog-js` | Work in `packages/mcp`. Run its unit tests, build, and lint. **Add a changeset.** Ships to npm; then bump the alias in `services/mcp/package.json` to pick it up. |
275| Dashboard, queries, clustering, API | this repo | A new chart means a query runner in `backend/hogql_queries/` behind `validate_mcp_analytics_access` — never a SQL template. Obey Hard rules 1-3. |
276| A new `query-mcp-*` agent tool | this repo (two places) | 1) an entry in `products/mcp_analytics/mcp/tools.yaml` with `schema_ref`, `scopes`, `description`, `feature_flag`; 2) the matching `<Name>Query` schema and `<Name>QueryRunner` in `backend/hogql_queries/`; 3) regenerate the tool handlers from `services/mcp` (see its `package.json` scripts). The generic `createQueryWrapper` handles the tool shape — no hand-written TypeScript. |
277| PostHog's own dogfood events | this repo | `services/mcp/src/hono/analytics.ts` + `tool-executor.ts`; client in `src/lib/posthog/client.ts`. |
278| Docs | `PostHog/posthog.com` | Keep the event and property tables in `contents/docs/mcp-analytics/events.mdx` synced with **both** the TypeScript `constants.ts` and the Python `posthog/mcp/constants.py`. |
279| The install codemod or the wizard command | `PostHog/context-mill`, `PostHog/wizard` | See [references/wizard-and-onboarding.md](references/wizard-and-onboarding.md) — in particular the rule about which changes need a wizard release and which do not. |
280
281**Rule of thumb:** a change to _what gets captured, or how servers are instrumented_ belongs
282in the SDKs. _How data is shown, aggregated, or clustered_ belongs in this product. _PostHog's
283own dogfood events_ belong in `services/mcp`. A new customer-facing capability usually spans
284an SDK plus docs, and the product too if it needs a view.
285
286The wizard install flow, the skill-distribution channels, and the in-app onboarding are all in
287[references/wizard-and-onboarding.md](references/wizard-and-onboarding.md).
288
289## Current state
290
291Verified against `master`, `@posthog/mcp` 0.11.7, `posthog` 7.44.0, and MCP spec `2026-07-28`
292on 2026-08-25. Treat versions and open threads as perishable: re-check
293`packages/mcp/CHANGELOG.md`, the pinned alias in `services/mcp/package.json`, and
294[mega-issue 64016](https://github.com/PostHog/posthog/issues/64016) rather than trusting this
295section.
296
297**Both SDKs now speak the stateless spec and the v2 MCP SDKs.** `services/mcp` speaks both
298dialects at the protocol layer (`src/lib/stateless-protocol.ts` — per-request dialect
299detection, `server/discover`, no session minting for modern clients). The TypeScript SDK's
3000.10.9-0.11.7 run instruments MCP TypeScript SDK v2 servers (structural detection in
301`detect.ts`, both `@modelcontextprotocol` peers optional), resolves client identity and
302protocol version through a per-request fallback chain, gates `Mcp-Session-Id` minting on the
303revision each request declares, and captures `$mcp_client_user_agent` / `$mcp_vendor_client`.
304The Python SDK caught up in `posthog` 7.40.0-7.42.1: MCP Python SDK v2, conversation-anchored
305sessions byte-compatible with TS (`derive_session_id_from_conversation`), typed
306`$mcp_error_type` / `$mcp_error_message`, and the same UA/vendor capture. The old parity
307threads ([posthog-python 803](https://github.com/PostHog/posthog-python/pull/803) and
308[830](https://github.com/PostHog/posthog-python/pull/830)) were **closed unmerged and
309superseded** — don't cite them as the source of what landed.
310[references/stateless-and-sessions.md](references/stateless-and-sessions.md) is the reference
311for all of it.
312
313Also shipped: structured intent themes, first-party notification destinations and recurring
314reports, `mcp_analytics` access control, the shared `ProductEmptyState` adoption,
315failure-occurrence drill-down with "create fix task", the migration of every chart to typed
316query runners, the demo seeder, and exec-mode inner-tool breakout (Hard rule 1).
317
318What still lags, all checkable in this repo: the `services/mcp` alias pin is `0.10.2` against a
3190.11.7 SDK (Hard rule 5 — no 0.11.x SDK-side fix or SDK-emitted property reaches dogfood data,
320though the server independently stamps `$mcp_client_user_agent` and the legacy non-`$`
321`mcp_vendor_client` regardless of the pin; harness resolution reads the SDK-emitted
322`$mcp_vendor_client` first and coalesces the legacy name for those rows);
323the exec-property emitter is still absent from
324master (Hard rule 1); the clustering schedule still covers only `GUARANTEED_TEAM_IDS = [2]`;
325and the product remains behind the `mcp-analytics` flag, so a project without it sees nothing.