diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/.posthog-wizard b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/.posthog-wizard new file mode 100644 index 000000000..e69de29bb diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/SKILL.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/SKILL.md new file mode 100644 index 000000000..57e830f0e --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/SKILL.md @@ -0,0 +1,399 @@ +--- +name: mcp-analytics +description: >- + Add PostHog MCP analytics to a TypeScript, JavaScript, or Python MCP server. + Captures tool calls, agent intent, failures, and protocol revision, plus + self-reported model identity on supported TypeScript paths. Both SDKs support + legacy sessions and the handshake-free 2026-07-28 revision. Detects the server + style and wires in compatible instrumentation, credentials, conversation + correlation, and graceful shutdown. +metadata: + author: PostHog + version: dev +--- + +# Add PostHog MCP analytics + +Use this skill to instrument a user's own **MCP server** with PostHog MCP analytics. Once instrumented, every tool call, agent intent, and failure the server handles is captured as a `$mcp_*` event in PostHog. Supported TypeScript instrumentation paths can also capture the agent's self-reported model, so the user can compare quality, errors, and latency by model. + +There are two SDKs and this skill handles both: +- **TypeScript / JavaScript** — the [`@posthog/mcp`](https://posthog.com/docs/mcp-analytics) Node package. +- **Python** — `posthog.mcp`, which ships inside the [`posthog`](https://posthog.com/docs/libraries/python) package (like `posthog.ai`). + +This is **not** about adding the PostHog MCP *server* to a coding agent (that's `wizard mcp add`). This skill instruments the user's *own* MCP server code so it reports analytics about itself. + +## Scope and guardrails + +- **TypeScript / JavaScript and Python are supported.** Detect the language in STEP 1 and follow the matching part of every step. If the MCP server is written in anything else (Go, Rust, …), **stop**: emit `[ABORT] unsupported language for mcp analytics` on its own line and do nothing else. +- **This must be an MCP server.** Search thoroughly before concluding there isn't one: check dependency manifests *and* source for the STEP 1 signals across the whole project, including monorepo workspace packages and subdirectories — a server is often under `packages/*`, `apps/*`, `server/`, or `src/`, not the repo root. Only after an exhaustive search finds nothing, **stop**: emit `[ABORT] no mcp server found` on its own line, and in the same message tell the user where you looked and to re-run the command from inside the package or directory that actually defines their MCP server. Do nothing else. +- **Beta SDK.** Both SDKs are pre-1.0 and may ship breaking changes in minor releases. Pin a version (see STEP 3). +- **Minimal, additive changes only.** Add instrumentation alongside the existing server; do not restructure tool handlers or change their behavior. The wrapper is designed to be one line. + +### Abort cases + +If anything blocks instrumentation, **always** emit exactly one `[ABORT] ` line and stop — never halt, finish, or error out silently. The wizard catches `[ABORT]` and terminates the run for you; don't try to exit yourself. A silent stop is recorded as a failed run with no reason, which can't be acted on, so every dead end must carry a reason. Use one of: + +- `[ABORT] no mcp server found` — an exhaustive search (see the guardrail above) found no MCP server in the project. +- `[ABORT] unsupported language for mcp analytics` — the server is neither TypeScript/JavaScript nor Python. +- `[ABORT] could not locate the server entry point` — MCP signals are present, but the place the server is constructed or where requests are dispatched couldn't be found to instrument. +- `[ABORT] ` — anything else that blocks the run (e.g. no readable project, or no PostHog credentials and no MCP server connected to fetch them). Keep it short and specific so it's useful when aggregated across runs. + +## Instructions + +Follow these steps IN ORDER. Each step has a **TypeScript / JavaScript** part and a **Python** part — use the one for the language you detect in STEP 1. + +### STEP 1: Identify the language and the MCP server entry point + +Determine the language first, then route to the matching instructions throughout: + +- **TypeScript / JavaScript** — there's a `package.json`. Look for MCP signals in dependencies and source: + - `@modelcontextprotocol/sdk` — the official SDK, **v1** (most common). + - `@modelcontextprotocol/server` / `@modelcontextprotocol/core` / `@modelcontextprotocol/client` — the official SDK, **v2**. A v2 project has no `@modelcontextprotocol/sdk` at all, so never read that one package's absence as "no MCP server here". + - `mcp-handler` — the Next.js / Vercel adapter. + - `@rekog/mcp-nest` — the NestJS adapter (tools defined with `@Tool()` decorators; the server is built inside `McpModule.forRoot(...)`, so there's no `new McpServer` in user code). + - `fastmcp`, `xmcp`, or a similar TS MCP framework. + - A custom HTTP/edge handler speaking the MCP protocol directly (JSON-RPC methods like `tools/call`, `initialize`, an `Mcp-Session-Id` header) with none of the above. + + Determine the package manager from the lockfile (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lockb`). + + **Record which SDK major the project is on.** STEP 2, STEP 3, and STEP 4 each contain a **For `@modelcontextprotocol/sdk` (v1)** and a **For `@modelcontextprotocol/server` (v2)** section — follow only the one matching the major found here. `sdk-v2.md` is the reference for everything v2-specific. + +- **Python** — there's a `pyproject.toml`, `requirements.txt`, or `setup.py`, or `.py` sources. Look for MCP signals: + - the official `mcp` package — `from mcp.server.fastmcp import FastMCP` on 1.x, `from mcp.server.mcpserver import MCPServer` on 2.x, or `from mcp.server.lowlevel import Server` on either major. + - jlowin's standalone `fastmcp` 2.0 — `from fastmcp import FastMCP`. + - a custom HTTP/edge dispatcher (FastAPI / Starlette / Flask / edge) speaking the MCP protocol directly with no server object to wrap. + + Determine the installer (pip / uv / poetry) from the lockfile / `pyproject.toml`. + + Record which official `mcp` major the project uses. The wrapper is tested against `mcp>=1.26,<3`; don't confuse this with jlowin's separately versioned `fastmcp` package. + +- If it's neither TS/JS nor Python, apply the guardrail above and stop. + +Then identify the file and the exact place where the server is constructed or where MCP requests are dispatched, and read it before editing. If PostHog MCP analytics is already wired in (an `instrument(` call, or a `PostHogMCP` client in either language), don't duplicate it. Verify the existing setup against STEP 4, add supported modern options that are missing, then continue through STEP 7. + +### STEP 2: Choose the instrumentation path + +Pick exactly one based on what STEP 1 found. When in doubt, read the bundled reference docs — `installation.md` covers the wrapping paths; `custom-servers.md` covers the custom-dispatcher paths; `sdk-v2.md` covers what differs on MCP SDK v2. + +#### TypeScript / JavaScript + +- **Path A — official SDK server object** (`new Server(...)` or `new McpServer(...)` from the official SDK, either major): wrap it with `instrument(server, posthog)`. One line. `instrument()` detects the server's shape at runtime, so the call is the same on both majors — never branch the code you write on which major is installed. +- **Path B — `mcp-handler`** (`createMcpHandler((server) => { ... })`): same `instrument(server, posthog)` call, inside the setup callback. Because Vercel's transport is stateless, also wire `identify` (STEP 4) and flush per invocation (STEP 6). +- **Path C — custom dispatcher** (Hono / Express / Cloudflare Worker / edge function with no SDK server object to wrap): use the `PostHogMCP` client and call `captureToolCall` / `captureInitialize` yourself at the dispatch points. +- **Path D — `@rekog/mcp-nest`** (NestJS): the framework builds the server, so there's no `new McpServer` for you to wrap. Instrument it through the module's `serverMutator` hook in `McpModule.forRoot(...)`. See STEP 4. + +On Paths A and B, the SDK major recorded in STEP 1 decides the specifics — STEP 3 and STEP 4 each have a matching section per major: + +##### For @modelcontextprotocol/sdk (v1) + +The server object comes from `@modelcontextprotocol/sdk`. Follow the **v1** sections of STEP 3 and STEP 4; `installation.md` is the reference. + +##### For @modelcontextprotocol/server (v2) + +The server object comes from `@modelcontextprotocol/server`. Follow the **v2** sections of STEP 3 and STEP 4; `sdk-v2.md` is the reference for everything v2-specific. + +#### Python + +- **Path P1 — a high-level or low-level server** (the official `mcp` package's 1.x `FastMCP`, 2.x `MCPServer`, or `Server` from either major; or jlowin's standalone `fastmcp` package): wrap it with `instrument(server, posthog)`. One line — the SDK detects the framework and major. +- **Path P2 — custom dispatcher** (FastAPI / Starlette / Flask / edge with no server object to wrap): use the `PostHogMCP` client and call `capture_tool_call` / `capture_initialize` yourself at the dispatch points. + +### STEP 3: Install the SDK + +#### TypeScript / JavaScript + +Install `@posthog/mcp` and `posthog-node` with the project's package manager, pinning `@posthog/mcp` to its current published version (it's pre-1.0) — e.g. `pnpm add @posthog/mcp@ posthog-node`. Read the installed version back from `package.json` / the lockfile rather than guessing. Self-reported model capture requires `@posthog/mcp>=0.12.0` on wrapping paths and `@posthog/mcp>=0.13.0` on the custom-dispatcher path; upgrade an older installed version before enabling it. + +**Never install an MCP SDK.** Both majors are *optional* peer dependencies of `@posthog/mcp`, and the project already has the one it uses. Adding the other pulls in a whole SDK the code never imports. + +##### For @modelcontextprotocol/sdk (v1) + +No extra constraint — pinning the current published `@posthog/mcp` release is enough. + +##### For @modelcontextprotocol/server (v2) + +`@posthog/mcp` must be **`>=0.11.2`**. If the project already depends on something older, upgrade it — earlier versions rejected high-level v2 servers in a compatibility check that `instrument()` swallows, so the integration looked healthy and captured nothing at all. + +#### Python + +The SDK ships inside `posthog`, so install (or require) `posthog>=7.40.0` with the project's installer — e.g. `pip install "posthog>=7.40.0"`, `uv add "posthog>=7.40.0"`, `poetry add "posthog>=7.40.0"`. Version 7.40.0 added official MCP SDK 2.x and `2026-07-28` support. The MCP SDK is a peer dependency tested across `mcp>=1.26,<3`; don't add or change it as part of this command. jlowin's standalone `fastmcp` package is also supported. A custom-dispatcher (path P2) project needs nothing beyond `posthog`. + +### STEP 4: Instrument the server + +Create the PostHog client **once at module scope** (never per request), reading credentials from env (set up in STEP 5). + +#### TypeScript / JavaScript + +```ts +import { PostHog } from "posthog-node" + +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: process.env.POSTHOG_HOST, // https://us.i.posthog.com or https://eu.i.posthog.com +}) +``` + +**Path A — official SDK server:** wrap the server with `instrument(server, posthog)` immediately after constructing it. `instrument()` is idempotent per server and returns an analytics handle (used later for custom events). It works on both the low-level `Server` and the high-level `McpServer`, and the wrapping line is identical on both majors — only the SDK import differs. Use the section matching the major from STEP 1: + +##### For @modelcontextprotocol/sdk (v1) + +```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { instrument } from "@posthog/mcp" + +const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }) +const analytics = instrument(server, posthog, { + captureModel: true, +}) // wrap immediately after constructing the server +// register tools as usual — tools added after instrument() are still captured +``` + +##### For @modelcontextprotocol/server (v2) + +```ts +import { McpServer } from "@modelcontextprotocol/server" +import { instrument } from "@posthog/mcp" + +const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }) +const analytics = instrument(server, posthog, { + captureModel: true, + enableConversationId: true, +}) // wrap immediately after constructing the server +// register tools with registerTool() as usual — tools added after instrument() are still captured +``` + +v2 reminders: + +- Tools register with `registerTool()` — the deprecated `server.tool()` was removed in v2. +- `@posthog/mcp` must be `>=0.11.2` (STEP 3). +- If the project already calls `instrument(server.server)` — a workaround for an old compatibility check that rejected high-level v2 servers — change it back to `instrument(server)`. + +**Path B — `mcp-handler`:** call `instrument(server, posthog)` as the first line of the setup callback, with the `posthog` client created at module scope (not per request). Because the transport is stateless, group calls by user with `identify`: + +```ts +import { instrument, getRequestHeaders } from "@posthog/mcp" + +const handler = createMcpHandler((server) => { + instrument(server, posthog, { + captureModel: true, + enableConversationId: true, + identify: async (request, extra) => { + const token = getRequestHeaders(extra)?.["authorization"] + return token ? { distinctId: await resolveUserId(token) } : null + }, + }) + server.registerTool("...", { /* ... */ }, async () => { /* ... */ }) +}) +``` + +**Always read request headers through `getRequestHeaders(extra)`** — in `identify`, `intentFallback`, `eventProperties` and `beforeSend` alike, on either major. The callbacks receive the MCP SDK's `extra` unchanged, and the two majors shape its headers differently — a hand-rolled read that works on one silently returns `undefined` on the other, so `identify()` returns `null` and every event goes out anonymous with no error anywhere. The helper handles both majors and returns a plain lowercase-keyed object. `sdk-v2.md` documents the per-major shapes. + +**Path C — custom dispatcher:** swap the existing PostHog client for `PostHogMCP` (a drop-in `posthog-node` subclass) and call the capture helpers at the dispatch points. Read `custom-servers.md` for the full field reference before editing. + +```ts +import { PostHogMCP } from "@posthog/mcp" + +const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN, { + host: process.env.POSTHOG_HOST, + captureModel: true, +}) + +// when building the tools/list response: +const serverTools = getServerTools() +const advertisedTools = posthog.prepareToolList(serverTools) + +// only on a 2025-11-25 initialize handshake: +posthog.captureInitialize({ clientName, clientVersion, distinctId, protocolVersion: "2025-11-25" }) + +// after each tools/call resolves (wrap the existing handler, time it): +const start = Date.now() +const originalTool = serverTools.find((tool) => tool.name === request.params.name) +const { args, intent, intentSource, llmModel, llmModelSource } = posthog.prepareToolCall( + request.params.name, + request.params.arguments, + { originalTool }, +) +const result = await runTool(request.params.name, args) +posthog.captureToolCall({ + toolName: request.params.name, + parameters: args, + response: result, + durationMs: Date.now() - start, + isError: false, + intent, + intentSource, + llmModel, + llmModelSource, + distinctId, // who the request is from, if known + sessionId, // your transport/session id, if you have one + protocolVersion, // read this from the current request +}) +``` + +Return `advertisedTools` from `tools/list`. Always dispatch the cleaned `args`, not the raw request arguments. Pass the raw `originalTool` descriptor on every call so model ownership remains correct when `tools/list` and `tools/call` reach different replicas. The helper preserves an application-owned `llm_model` field and declines to capture it. + +Resolve `distinctId` / `sessionId` from whatever auth/session the dispatcher already has; omit them rather than inventing values. Pass `protocolVersion` on every capture. On `2025-11-25`, use the revision the dispatcher's existing session state negotiated during initialize. On `2026-07-28`, read `MCP-Protocol-Version` from the current request because there is no initialize handshake or protocol session. If the dispatcher exposes neither source, omit the property rather than hardcoding a revision. Don't fabricate `$mcp_initialize` on `2026-07-28`. Conversation-id injection isn't available on the custom-dispatcher path. Model capture is self-reported and unverified. These calls are fire-and-forget and never throw, so they can't take down a tool. + +**Path D — `@rekog/mcp-nest` (NestJS):** the framework builds the server, so pass a `serverMutator` to `McpModule.forRoot(...)`. Prefer the `instrumentMutator` helper — it instruments the server and returns it, so it drops straight into the hook: + +```ts +import { Module } from "@nestjs/common" +import { McpModule } from "@rekog/mcp-nest" +import { PostHog, instrumentMutator } from "@posthog/mcp" + +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: process.env.POSTHOG_HOST, +}) + +@Module({ + imports: [ + McpModule.forRoot({ + name: "my-mcp-server", + version: "1.0.0", + serverMutator: instrumentMutator(posthog, { + enableConversationId: true, + }), + }), + ], +}) +class AppModule {} +``` + +`instrumentMutator` returns the server (not `instrument()`'s handle), so it slots straight into the hook. Compose with an existing `serverMutator` if there is one, and handlers nest registers after the mutator runs are still captured. For [custom events](https://posthog.com/docs/mcp-analytics/custom-events), call `instrument()` directly inside your own mutator and keep its handle, returning the server yourself. + +If mcp-nest keeps one persistent server instance, also set `captureModel: true`. Don't enable it when `statelessMode: true` creates a fresh low-level server per request: that path advertises `llm_model` but can't confirm ownership before the call, so the model property stays empty. + +##### For @modelcontextprotocol/server (v2), any path — sessions and protocol revisions + +Protocol revision is a property of each *request*, not of the server. A v2 server serves `2025-11-25` traffic too, so instrument once and never branch on the major. The `2026-07-28` revision removed the `initialize` handshake and the `Mcp-Session-Id` header, so on that traffic **every request becomes its own `$session_id`** unless `enableConversationId: true` is set. Enable it on supported v2 wrapping paths. `captureModel: true` works on both revisions, and its value is self-reported and unverified. `sdk-v2.md` has the rest, including MCP Apps compatibility and the current instrumentation gaps. + +#### Python + +```python +import os +from posthog import Posthog + +posthog = Posthog( + os.environ["POSTHOG_PROJECT_TOKEN"], + host=os.environ["POSTHOG_HOST"], # https://us.i.posthog.com or https://eu.i.posthog.com +) +``` + +**Path P1 — FastMCP / low-level Server:** + +```python +from posthog.mcp import instrument + +server = FastMCP("my-mcp-server") +analytics = instrument(server, posthog) # wrap right after constructing the server +# register tools as usual — tools added after instrument() are still captured +``` + +`instrument()` is idempotent per server and returns an analytics handle (used later for custom events). The same call works on the official MCP SDK 1.x `FastMCP`, 2.x `MCPServer`, the low-level `Server` from either major, and jlowin's standalone `fastmcp` package. + +For the official `mcp` 2.x SDK, enable conversation IDs because `2026-07-28` has no protocol session: + +```python +from posthog.mcp.types import MCPAnalyticsOptions + +analytics = instrument( + server, + posthog, + MCPAnalyticsOptions(enable_conversation_id=True), +) +``` + +**Path P2 — custom dispatcher:** swap the existing client for `PostHogMCP` (a drop-in `posthog` client subclass) and call the capture helpers at the dispatch points. Read `custom-servers.md` for the full field reference before editing. + +```python +import time +from posthog.mcp import PostHogMCP + +posthog = PostHogMCP(os.environ["POSTHOG_PROJECT_TOKEN"], host=os.environ["POSTHOG_HOST"]) + +# only on a 2025-11-25 initialize handshake: +posthog.capture_initialize( + client_name=client_name, + client_version=client_version, + distinct_id=distinct_id, + protocol_version="2025-11-25", +) + +# after each tools/call resolves (time it): +start = time.monotonic() +# ...run the tool... +posthog.capture_tool_call( + request.params.name, + parameters=arguments, + response=result, + duration_ms=(time.monotonic() - start) * 1000, + is_error=False, + distinct_id=distinct_id, # who the request is from, if known + session_id=session_id, # your transport/session id, if you have one + protocol_version=protocol_version, # read this from the current request +) +``` + +Resolve `distinct_id` / `session_id` from whatever auth/session the dispatcher already has; omit them rather than inventing values. Pass `protocol_version` on every capture. On `2025-11-25`, use the revision the dispatcher's existing session state negotiated during initialize. On `2026-07-28`, read `MCP-Protocol-Version` from the current request because there is no initialize handshake or protocol session. If the dispatcher exposes neither source, omit the property rather than hardcoding a revision. Don't call `capture_initialize` on `2026-07-28`. Python doesn't support self-reported model capture yet, so don't add an `llm_model` field. These calls are fire-and-forget and never throw, so they can't take down a tool. + +### STEP 5: Wire up credentials + +- Check existing env files (`.env`, `.env.local`, etc.) for a PostHog project token. If a valid `phc_…` token and host are already set, reference those and skip the rest of this step. +- If the token is missing, use the PostHog MCP server's `projects-get` tool to fetch the project's `api_token`. If multiple projects come back, ask the user which to use. If the MCP server isn't connected, ask the user for their project token directly. +- Host: `https://us.i.posthog.com` for US Cloud, `https://eu.i.posthog.com` for EU Cloud. +- Write `POSTHOG_PROJECT_TOKEN` and `POSTHOG_HOST` to the appropriate env file and reference them in code (`process.env.*` in JS, `os.environ[...]` in Python) — never hardcode the token. + +### STEP 6: Ensure events get flushed + +The PostHog client batches events; the user owns the client's lifecycle. + +**TypeScript / JavaScript:** + +- **Long-running server (STDIO or a persistent HTTP server):** drain on shutdown. + + ```ts + process.on("SIGTERM", async () => { + await posthog.shutdown() + process.exit(0) + }) + ``` + +- **Serverless / edge (mcp-handler on Vercel, Workers, Lambda):** `SIGTERM` is unreliable — flush at the end of each invocation with `await posthog.flush()`, or `ctx.waitUntil(posthog.flush())` where supported. +- **STDIO transports specifically:** the server's stdout is the protocol channel. Do not add `console.log` for debugging — it corrupts the MCP stream. If you need SDK-internal warnings, pass a `logger` option to `instrument()` that writes to stderr or a file. + +**Python:** + +- **Long-running server (STDIO or persistent HTTP):** drain on exit. On the `instrument()` path, `await analytics.flush()` waits for in-flight auto-capture events, then `posthog.shutdown()` flushes and stops the client — call both from your shutdown path. For `PostHogMCP`, `posthog.shutdown()` (or `posthog.flush()`) drains the MCP captures first. +- **STDIO transports specifically:** stdout is the protocol channel — never `print()` to it. For SDK-internal warnings pass `logger=lambda m: print(m, file=sys.stderr)` (or a file writer) via `MCPAnalyticsOptions(...)`. + +### STEP 7: Verify + +- **TypeScript / JavaScript:** run the project's type-check and/or build script (e.g. `tsc --noEmit`, `pnpm build`) and fix any errors your changes introduced. Run any linter/formatter the project uses on the files you touched. +- **Python:** run the project's type-check / tests if present (`mypy`, `pytest`) and fix any errors your changes introduced. Run any formatter the project uses (`ruff`, `black`) on the files you touched. +- For any supported TypeScript path with model capture, verify `tools/list` advertises a required `llm_model` string, the tool handler doesn't receive it, and a non-`unknown` answer lands on `$mcp_tool_call` as `$mcp_llm_model` with `$mcp_llm_model_source = "self_reported"`. +- For a `2026-07-28` wrapping path, verify the first tool call captures without an initialize request. When conversation IDs are enabled, verify the returned handle is echoed on the next call and produces the same `$session_id`. +- Don't expect automatic `$mcp_resources_list`, `$mcp_resource_read`, `$mcp_prompts_list`, or `$mcp_prompt_get` events. Those names are reserved, but the wrappers don't emit them yet. +- Check every event name in the final report against `events.md`. Failed tools remain `$mcp_tool_call` events with `$mcp_is_error = true` and can emit a sibling `$exception`; there is no `$mcp_tool_failed` event. +- Summarize for the user: which path you used, the files you changed, the env vars to set, and that they'll see `$mcp_*` events in PostHog once the server handles its next request. Link them to https://posthog.com/docs/mcp-analytics for the dashboard and event reference. + +## Reference files + +- `references/installation.md` - Installing the mcp analytics SDK +- `references/sdk-v2.md` - MCP TypeScript SDK v1 vs v2 — read when the project uses @modelcontextprotocol/server +- `references/custom-servers.md` - Instrumenting a custom server +- `references/intent.md` - Capturing agent intent +- `references/identifying-users.md` - Identifying users +- `references/conversation-id.md` - Conversation ids +- `references/events.md` - Event and property reference +- `references/custom-events.md` - Custom events and metadata +- `references/start-here.md` - Getting started with mcp analytics +- `references/COMMANDMENTS.md` - Framework-specific rules the integration must follow + +`installation.md` is the source of truth for the wrapping paths (A/B and Python P1) and the full `instrument()` options table (`identify`, `context`/intent, TypeScript-only `captureModel`, `enableConversationId`/`enable_conversation_id`, `reportMissing`/`report_missing`, `beforeSend`/`before_send`, `eventProperties`/`event_properties`). `custom-servers.md` is the source of truth for the custom-dispatcher paths (C and P2), including the `2026-07-28` rule that no initialize event exists. `sdk-v2.md` is the source of truth for both SDK-major splits, sessions on `2026-07-28`, MCP Apps compatibility, and current instrumentation gaps. `intent.md`, `identifying-users.md`, and `conversation-id.md` cover optional enrichment; `events.md` and `custom-events.md` describe what gets captured. + +## Key principles + +- **One server, one wrapper.** `instrument()` is idempotent; don't call it twice on the same server. +- **Module-scope client.** Construct the `PostHog` / `Posthog` / `PostHogMCP` client once, not per request. +- **Env, never hardcode.** The project token and host come from environment variables. +- **Additive only.** Don't change tool behavior or restructure the server — just wrap/capture. +- **Don't break STDIO.** No `console.*` (JS) or `print()` (Python) on STDIO transports; use a `logger` instead. +- **Pin the beta SDK** and tell the user it's pre-1.0. (Python: `posthog.mcp` ships inside `posthog`; require `posthog>=7.40.0` for MCP SDK 2.x support.) diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/COMMANDMENTS.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/COMMANDMENTS.md new file mode 100644 index 000000000..18e04ed70 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/COMMANDMENTS.md @@ -0,0 +1,12 @@ +# Framework rules + +Follow these when integrating PostHog into this framework. + +- A missing PostHog configuration must never break the app — read keys optionally (never a required setting), guard init and capture behind their presence, and keep build and boot working with no PostHog environment set — but never silently: in development or debug builds fail loudly, using the language's idiomatic error, with the message " variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once is configured" (substituting the actual variable name); production stays a no-op +- posthog-node is the Node.js server-side SDK package name; posthog-js is browser-only, so use posthog-node on the server instead +- Include enableExceptionAutocapture: true in the PostHog constructor options +- Add posthog.capture() calls in route handlers for meaningful user actions – every route that creates, updates, or deletes data should track an event with contextual properties +- Add posthog.captureException(err, distinctId) in the application's error handler (e.g., Express error middleware, Fastify setErrorHandler, Koa app.on('error')) +- The SDK batches events and flushes asynchronously. await flush() or await shutdown() before letting that process exit. If unsure, set flushAt 1 and flushInterval 0. +- `posthog.capture()` enqueues synchronously and returns; the batched HTTP send happens afterwards. Treat every per-request handler as short-lived even when the framework feels like a server: Next.js / Nuxt / SvelteKit / Remix route handlers, serverless and edge functions, and Lambda are torn down per invocation before the send runs. Create the client with flushAt 1 and flushInterval 0, then await the send before returning. Always use `await posthog.flush()` for a shared/singleton client, `await posthog.shutdown()` for a per-request client. Never skip the awaited flush or risk the enqueued event being silently dropped. +- Reverse proxy is NOT needed for server-side Node.js – only client-side JavaScript needs a proxy to avoid ad blockers diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/conversation-id.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/conversation-id.md new file mode 100644 index 000000000..4072be6de --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/conversation-id.md @@ -0,0 +1,108 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Conversation IDs + +A PostHog `$session_id` normally follows the MCP protocol session. The handshake-free `2026-07-28` revision has no protocol session, so each request gets a new session unless you add another correlation signal. + +`$mcp_conversation_id` groups those calls by conversation. The SDK also derives `$session_id` from an accepted conversation handle, so PostHog session queries group the same calls. + +**Enabled by default** + +Conversation IDs are enabled by default. Calls stay correlated when the agent echoes the handle returned in tool results. + +## Enabling + +No extra configuration is needed in either SDK: + +### TypeScript + +```typescript +instrument(server, posthog) +``` + +### Python + +```python +instrument(server, posthog) +``` + +The SDK does three things: + +1. **Injects an optional `conversation_id` argument** into compatible tool schemas, with a description telling the agent to reuse the value the server returns. +2. **Creates a handle** when needed and returns it on eligible tool responses as a `{"conversation_id":"…"}` text block. +3. **Captures the handle** as `$mcp_conversation_id`. When the agent echoes a valid handle, the SDK derives a stable `$session_id` from it. Until then, existing protocol sessions remain in use. + +The SDK reuses an echoed UUIDv7 that matches the handles it creates. It replaces missing or arbitrary values with a new handle to avoid merging unrelated conversations. + +## Event properties + +``` +{ + event: "$mcp_tool_call", + properties: { + "$session_id": "ses_2a3f…", // derived from the conversation handle + "$mcp_conversation_id": "0198f2d6-…", // echoed UUIDv7 conversation handle + "$mcp_tool_name": "search_events", + ... + } +} +``` + +A new request or connection made by the same agent reusing the same `conversation_id` shares both `$mcp_conversation_id` and `$session_id`. You can group by the conversation property in HogQL: + +SQL + +[Run in PostHog](https://us.posthog.com/sql?open_query=SELECT%0A++properties.%24mcp_conversation_id+AS+conversation%2C%0A++arrayDistinct%28groupArray%28properties.%24mcp_tool_name%29%29+AS+tools_called%2C%0A++count%28%29+AS+tool_calls%0AFROM+events%0AWHERE+event+%3D+'%24mcp_tool_call'%0A++AND+properties.%24mcp_conversation_id+IS+NOT+NULL%0A++AND+timestamp+%3E+now%28%29+-+INTERVAL+7+DAY%0AGROUP+BY+conversation%0AORDER+BY+tool_calls+DESC%0ALIMIT+50) + +```sql +SELECT + properties.$mcp_conversation_id AS conversation, + arrayDistinct(groupArray(properties.$mcp_tool_name)) AS tools_called, + count() AS tool_calls +FROM events +WHERE event = '$mcp_tool_call' + AND properties.$mcp_conversation_id IS NOT NULL + AND timestamp > now() - INTERVAL 7 DAY +GROUP BY conversation +ORDER BY tool_calls DESC +LIMIT 50 +``` + +## Caveats + +**Some tools can't take the injection** + +The SDK cannot add `conversation_id` to schemas that use `oneOf`, `allOf`, `anyOf`, or `$ref`. It also skips tools without an input schema. It logs a warning and returns no handle for these tools. Use [`identify`](/docs/mcp-analytics/identifying-users.md) to group their calls by user. + +A client with a stale cached tool listing does not know about the new parameter. The `ttlMs` setting on `tools/list` can extend this period. + +**The handle is visible in tool output** + +The SDK returns the handle as a `{"conversation_id":"…"}` text block. Clients that display raw tool results also display this JSON. The block contains data rather than an instruction because hardened clients can treat instructions in tool results as prompt injection. + +The SDK preserves structured output and result metadata. Clients that only consume structured output may never see the text block, so they may not echo the handle. + +**Agent-controlled values** + +The SDK only reuses UUIDv7 values that match the shape of handles it can create. It replaces other values with a new handle. A client can still reuse a valid-looking handle across users, so don't use `$mcp_conversation_id` as a security boundary. + +**It also anchors the PostHog session** + +PostHog's session-level joins use `$session_id`. When conversation IDs are enabled, the SDK hashes the accepted `conversation_id` into a deterministic `$session_id`. Separate server instances derive the same value without shared storage. + +## When to skip this + +If your MCP server runs over a long-lived `2025-11-25` connection that already matches your conversation boundaries, you can disable conversation IDs. Set `enableConversationId: false` in TypeScript or `MCPAnalyticsOptions(enable_conversation_id=False)` in Python to omit the injected argument and response block. + +Keep it enabled when: + +- The same logical conversation crosses connections (HTTP/SSE clients that reconnect). +- Your server handles the `2026-07-28` revision and you want more than one request in each PostHog session. + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-events.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-events.md new file mode 100644 index 000000000..1e283f356 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-events.md @@ -0,0 +1,89 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Custom events and metadata + +Use `eventProperties` to add metadata to captured events. Use `analytics.capture()` for events that are not MCP requests. + +## `eventProperties` – metadata on every event + +Pass an `eventProperties` callback to attach extra properties to automatically captured MCP events. The callback receives request context, such as headers, transport, and the request ID. + +TypeScript + +```typescript +import { instrument, getRequestHeaders } from "@posthog/mcp" + +const analytics = instrument(server, posthog, { + eventProperties: async (request, extra) => ({ + $app_version: process.env.GIT_SHA ?? "unknown", + $mcp_region: process.env.FLY_REGION ?? "unknown", + request_id: getRequestHeaders(extra)?.["x-request-id"], + }), +}) +``` + +`getRequestHeaders` reads headers on both MCP SDK majors – see [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md#if-your-callbacks-read-headers-change-them). + +The returned object is spread flat onto the event's properties alongside the built-in `$mcp_*` keys: + +JSON + +```json +{ + "event": "$mcp_tool_call", + "properties": { + "$mcp_tool_name": "search_events", + "$app_version": "a1b2c3d", + "$mcp_region": "iad", + "request_id": "req_…" + } +} +``` + +Return constants from `eventProperties` to add the same values to captured MCP events. This is similar to `posthog.register(...)` in other SDKs. Return values from the request context when metadata must vary between calls. + +For group analytics, return `groups` from [`identify`](/docs/mcp-analytics/identifying-users.md). The SDK adds `$groups` to events for the session. + +Returned values must be JSON-serializable. The SDK catches callback errors and sends them to your `logger`. These errors do not interrupt tool execution. + +## `analytics.capture()` – emit an arbitrary event + +Use `analytics.capture()` for events that aren't MCP requests, such as UI feedback or workflow milestones. It uses the SDK's sanitization, current server session and identity, and `beforeSend` hook. It returns a promise you can `await`. + +Custom capture has no request context and doesn't run `eventProperties`. Pass custom metadata in `properties`. + +You name the event. It's sent verbatim – it's your event, so it is **not** `$`\-prefixed. + +TypeScript + +```typescript +const analytics = instrument(server, posthog) + +await analytics.capture({ + event: "feedback_submitted", + properties: { rating: 5 }, +}) +``` + +PostHog receives: + +- One event under the verbatim `event` name you passed, with your `properties` merged in. +- The current server session and cached identity apply. These may differ from the session of a concurrent tool request. + +`capture()` is a method on the handle that `instrument()` returns, so you call it on the instrumented server's analytics handle directly. + +## Which one to use + +| You want to... | Use | +| --- | --- | +| Attach the same properties to every auto-captured event | `eventProperties` | +| Emit a one-off event that isn't an MCP request | `analytics.capture()` | +| Attach data only to matching requests | Check the request in `eventProperties` and return properties only for matches. | + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-servers.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-servers.md new file mode 100644 index 000000000..c84d45b41 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-servers.md @@ -0,0 +1,410 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Instrumenting a custom server + +[`instrument()`](/docs/mcp-analytics/installation.md) wraps the request handlers of a `@modelcontextprotocol/sdk` `Server` or `McpServer`. A custom dispatcher has no server object to wrap. Examples include [Hono](https://hono.dev/), Express, Cloudflare Workers, and Vercel edge functions that implement the MCP protocol directly. + +For those servers, use **`PostHogMCP`** instead. It's a subclass of the [`posthog-node`](/docs/libraries/node.md) client, so it's a drop-in replacement for your existing PostHog client. It adds preparation helpers for tool schemas and calls, plus capture methods for tool calls, tool listings, initialization, and missing capabilities. You resolve request metadata and call the matching methods yourself. They build the same canonical `$mcp_*` events as `instrument()` and use the same sanitization, truncation, and additional `$exception` events. + +## When to use which + +| Your server | Use | +| --- | --- | +| Built on `@modelcontextprotocol/sdk`'s `Server` / `McpServer` | [`instrument(server, posthog, options?)`](/docs/mcp-analytics/installation.md) | +| A custom HTTP/Hono/edge dispatcher with no server object to wrap | `new PostHogMCP(apiKey, options?)` | + +The examples below are TypeScript. Python has the same helper with the same methods in snake\_case – skip to [Python](#python). + +## Set up + +`PostHogMCP` takes the exact same constructor arguments as `posthog-node`'s `PostHog`, so swap the class and you keep one client for your whole app: + +TypeScript + +```typescript +import { PostHogMCP } from "@posthog/mcp" + +const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", // or https://eu.i.posthog.com + // standard posthog-node options apply, e.g. beforeSend, enableExceptionAutocapture +}) +``` + +`PostHogMCP` provides the standard `PostHog` client options and methods. Its `beforeSend` hook also applies to MCP events. Set `enableExceptionAutocapture: false` to stop additional `$exception` events for failed calls. + +The `instrument()` hooks (`identify`, `context`, `intentFallback`, `eventProperties`) do not run on this path. Pass identity and properties on each capture call. + +## Capture events + +Call the matching method from inside your dispatcher, after you've resolved who the user is and run the tool. The methods are fire-and-forget, just like `posthog.capture()`: + +TypeScript + +```typescript +// On a tools/call, after the tool runs: +posthog.captureToolCall({ + toolName: "search_events", + parameters: request.params.arguments, + response: result, + durationMs: Date.now() - start, + isError: false, + distinctId: user.id, // → distinct_id (enables person processing) + sessionId: mcpSessionId, // → $session_id (omitted if you don't pass one) + protocolVersion: requestProtocolVersion, // → $mcp_protocol_version + groups: { organization: user.orgId }, // → $groups + properties: { $mcp_client_name: "claude-code" }, // any extra props, spread verbatim +}) + +// Only on a 2025-11-25 initialize handshake: +posthog.captureInitialize({ + clientName: "claude-code", + clientVersion: "1.2.3", + protocolVersion: "2025-11-25", + distinctId: user.id, +}) + +// Custom events use the inherited posthog-node capture(): +posthog.capture({ + distinctId: user.id, + event: "feedback_submitted", + properties: { rating: 5 }, +}) +``` + +### Fields shared by every method + +| Field | Maps to | Notes | +| --- | --- | --- | +| `distinctId` | `distinct_id` | Supplying it enables person processing so `$set` updates a person profile. Omit it for anonymous traffic – events are sent with `$process_person_profile: false`. | +| `sessionId` | `$session_id` | Omitted from the event entirely when you don't pass one (so stateless captures don't bucket into a non-existent [Session Replay](/docs/session-replay.md) session). | +| `protocolVersion` | `$mcp_protocol_version` | Pass the revision from each request. The `2026-07-28` revision doesn't have an initialize request that can carry this state forward. | +| `clientUserAgent` | `$mcp_client_user_agent` | Pass the raw `User-Agent` header on HTTP transports. | +| `vendorClient` | `$mcp_vendor_client` | Pass the raw vendor client header, such as `x-anthropic-client`, when present. | +| `groups` | `$groups` | `{ groupType: groupKey }`, stamped on the event so you never hand-write the `$groups` key. | +| `setProperties` | `$set` | Person properties (`{ name, email, plan }`), same as the `properties` you'd pass to `identify`. Updates the person profile. PostHog does not store `$set` on the event. Query these values as [person properties](/docs/product-analytics/person-properties.md). | +| `properties` | spread verbatim | Extra event properties, sitting alongside the `$mcp_*` keys. Values must be JSON-serializable. | +| `timestamp` | event time | Defaults to the time of the capture call. | + +### Tool-call specific fields + +`toolName` -> `$mcp_tool_name`, `toolDescription` -> `$mcp_tool_description`, `parameters` -> `$mcp_parameters`, `response` -> `$mcp_response`, `durationMs` -> `$mcp_duration_ms`, `isError` -> `$mcp_is_error`. When `isError` is true, the SDK emits an additional `$exception` unless `enableExceptionAutocapture` is false. It uses the error you supply. If no error is available, it creates a generic exception from the tool name. + +**Analytics never breaks your request** + +`captureToolCall` and `captureInitialize` queue events without waiting for delivery, like `posthog.capture()`. They do not throw, so analytics failures do not interrupt your tool. Flush at the end of each serverless or edge invocation to send queued events. + +## What you don't get compared with `instrument()` + +Because there's no wrapped server, `PostHogMCP` does **not** manage these for you – you pass the equivalent data per call: + +- **Sessions** – no MCP-session-derived `$session_id` or inactivity rollover. Pass your own `sessionId`. +- **Identity caching / `$identify` dedupe** – pass `distinctId` (and optional `setProperties`) on each call. +- **Automatic intent and missing-capability handling** – use `prepareToolList()` and `prepareToolCall()`, then pass their output to the matching capture method. +- **Conversation IDs** – pass your own stable `sessionId`. The custom dispatcher helpers don't inject or echo `conversation_id`. +- **Model capture** – enabled by default in both SDKs' preparation helpers. Use `prepareToolList()` to advertise the argument, then `prepareToolCall()` to extract it. Pass its `llmModel` and `llmModelSource` to `captureToolCall()`. The experimental Ruby client is opt-in: pass `capture_model: true` to `prepare_tool_list`. + +For model capture on a fresh dispatcher, pass the application's original tool as `originalTool` in the preparation options. Pass `requestMeta` there to capture recognized client metadata. Python uses the equivalent snake\_case fields and keyword arguments. + +The `2026-07-28` revision has no initialize handshake or protocol session. Don't fabricate `$mcp_initialize`. Capture each request's `protocolVersion`. For correlation across requests, pass an authenticated user ID or your own stable session ID. + +Everything from the [event reference](/docs/mcp-analytics/events.md) onward – event names, property shapes, sanitization, error tracking – is identical. + +## Graceful shutdown + +`PostHogMCP` is a `posthog-node` client, so flush it yourself. In serverless or edge environments, flush at the end of each invocation rather than relying on `SIGTERM`: + +TypeScript + +```typescript +// at the end of the request/invocation +await posthog.flush() +// or keep the runtime alive until the flush completes +ctx.waitUntil(posthog.flush()) +``` + +## Python + +The Python SDK ships the same custom-dispatcher path as `PostHogMCP`, a subclass of the [`posthog`](/docs/libraries/python.md) client. Method names are snake\_case and arguments are keyword args rather than an options object: + +Python + +```python +import time +from posthog.mcp import PostHogMCP, get_more_tools_result + +posthog = PostHogMCP("phc_your_project_api_key", host="https://us.i.posthog.com") + +# Advertise your tools with the injected `context` intent argument (and, optionally, +# the get_more_tools virtual tool): +tools = posthog.prepare_tool_list(my_tools, report_missing=True) + +def handle_tools_call(request, name, arguments): + # Pull the agent's intent off the call and strip the injected `context`: + prepared = posthog.prepare_tool_call(name, arguments) + + # Pass these on every capture — see "Attributing the caller" below: + common = dict( + distinct_id=user_id, + session_id=mcp_session_id, + client_user_agent=request.headers.get("user-agent"), + vendor_client=request.headers.get("x-anthropic-client"), + groups={"organization": org_id}, + ) + + if prepared.is_missing_capability: + posthog.capture_missing_capability( + context=prepared.intent, + llm_model=prepared.llm_model, + llm_model_source=prepared.llm_model_source, + **common, + ) + return get_more_tools_result() + + start = time.monotonic() + try: + result = run_tool(name, prepared.args) + except Exception as exc: + posthog.capture_tool_call( + name, + llm_model=prepared.llm_model, + llm_model_source=prepared.llm_model_source, + parameters=prepared.args, + duration_ms=(time.monotonic() - start) * 1000, + is_error=True, + error=exc, # → $mcp_error_message, $mcp_error_type, and the $exception sibling + **common, + ) + raise + + posthog.capture_tool_call( + name, + llm_model=prepared.llm_model, + llm_model_source=prepared.llm_model_source, + intent=prepared.intent, + intent_source=prepared.intent_source, + parameters=prepared.args, + response=result, + duration_ms=(time.monotonic() - start) * 1000, + **common, + ) + return result +``` + +Capture the handshake and the tool listing the same way: + +Python + +```python +posthog.capture_initialize(client_name="claude-code", client_version="1.2.3", **common) +posthog.capture_tools_list(tool_names=[t["name"] for t in tools], **common) + +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 – `host`, and [`before_send`](/docs/mcp-analytics/privacy.md#python) if you need to drop or rewrite payloads. 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. + +### Failed calls + +Pass `error=exc` with `is_error=True` to capture `$mcp_error_message` and `$mcp_error_type` from the exception. The SDK sanitizes the message and limits it to 2048 characters. Set `error_type="timeout"`, or another category, to replace the exception class name. The SDK also unwraps the generic `ToolError` from MCP SDK 2.x. + +### Attributing the caller + +`clientInfo.name` reports `claude-code` for the CLI, Agent SDK, VS Code extension, and desktop app. That name alone cannot distinguish these clients, so the harness breakdown can show mostly "Other". Custom dispatchers must pass the transport headers below. `instrument()` reads them automatically: + +- `client_user_agent` -> `$mcp_client_user_agent` – the parenthetical carries the build (`claude-code/2.1.0 (cli)` vs `(sdk-ts)`). +- `vendor_client` -> `$mcp_vendor_client` – from vendor headers like `x-anthropic-client`, the only thing that separates Anthropic's pooled surfaces (Claude.ai, Cowork, Claude Design) from each other. It separates products but cannot distinguish clients within one product. Claude.ai web, desktop, and mobile connectors use the same fetcher and header value. They all receive the "Claude.ai" label. + +Both are captured raw and classified at query time, so labels improve without an SDK release. stdio and in-memory transports carry no headers, so leave them unset there. + +### Stateless / multi-pod dispatchers + +A stateless deployment creates a new server per request, often across pods. Without correlation, `$session_id` differs between requests. Client name and version arrive only at `initialize`, so later requests lose these values. + +Add the session middleware to your ASGI app once. At `initialize`, it puts a token in the `Mcp-Session-Id` response header. It decodes the token when clients resend it. Each pod recovers the same values without shared storage: + +Python + +```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 contains only client-provided values from `initialize`. Use `$session_id` and `$mcp_client_*` as analytics labels, not authentication. + +## Ruby + +**Ruby SDK is experimental and unsupported** + +`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental and not officially supported**: we don't provide support for it, and its method signatures may change in a minor release. See the [Ruby section of the installation docs](/docs/mcp-analytics/installation.md#ruby). + +### Do you need this? + +If your Ruby server is an `MCP::Server` from the official `mcp` gem, you don't: add `PostHog::MCP.instrument(server, posthog)` and every request is captured. See [Ruby](/docs/mcp-analytics/installation.md#ruby). + +You need this section only when your app speaks the MCP protocol itself: a Rack or Rails endpoint that parses the JSON-RPC body, routes `tools/list` and `tools/call` by hand, and never builds an `MCP::Server`. There's no object to wrap, so you tell PostHog what happened. + +### Set up + +`PostHog::MCP::Client` is a `PostHog::Client` subclass. Create it once, where you create your PostHog client today, and use it for everything else too (`capture`, feature flags, `flush`). It needs nothing beyond `posthog-ruby`, no `mcp` gem. + +Ruby + +```ruby +require "posthog/mcp" + +posthog = PostHog::MCP::Client.new( + api_key: "phc_your_project_api_key", + host: "https://us.i.posthog.com" # or https://eu.i.posthog.com +) +``` + +Two constructor options are specific to MCP: `missing_capability_tool_name:` renames the `get_more_tools` virtual tool, and `mcp_exception_autocapture: false` turns off the `$exception` sibling event for failed calls. + +### Step 1: Prepare the tool list + +When you answer `tools/list`, pass your tool descriptors (the Hashes you return on the wire, with `name` and `inputSchema`) through `prepare_tool_list`. It returns new Hashes; your originals are not changed. + +Ruby + +```ruby +def handle_tools_list + posthog.prepare_tool_list(MY_TOOLS, report_missing: true) +end +``` + +This does two things: + +- It adds a required `context` string argument to every tool. The agent fills it with why it is calling the tool, and you capture that as `$mcp_intent` in step 2. See [Capturing agent intent](/docs/mcp-analytics/intent.md). Pass `context: false` to skip it, or `context: { description: "..." }` to change the prompt. +- With `report_missing: true`, it appends the `get_more_tools` virtual tool, so agents can tell you which capability they were missing. See [Missing capabilities](/docs/mcp-analytics/missing-capability.md). + +Pass `capture_model: true` to also add an optional `llm_model` argument, where the agent reports its model. A tool whose `inputSchema` is composed (`oneOf`, `allOf`, `anyOf`) or a `$ref` is returned unchanged. + +### Step 2: Prepare each tool call + +When you answer `tools/call`, pass the tool name, the raw arguments, and the tool's own `inputSchema` through `prepare_tool_call` **before** you run the tool. It returns a `PreparedToolCall` with: + +- `args`: the arguments without the injected `context` and `llm_model`, so your tool never sees them +- `intent` and `intent_source`: the agent's stated reason, ready to capture +- `llm_model` and `llm_model_source`: the agent's self-reported model, when you set `capture_model: true` +- `is_missing_capability`: `true` when the agent called the `get_more_tools` virtual tool + +Pass the same `inputSchema` Hash you gave to `prepare_tool_list`. If the tool declares its own `context` or `llm_model` field, that field then stays in `args` and is not read as analytics. Without `input_schema:`, both names are always removed. + +Ruby + +```ruby +prepared = posthog.prepare_tool_call(name, arguments, input_schema: tool[:inputSchema]) + +if prepared.is_missing_capability + posthog.capture_missing_capability(context: prepared.intent, distinct_id: user_id) + return PostHog::MCP.get_more_tools_result # the canned reply the agent expects +end + +# Run the tool with prepared.args (see step 3) +``` + +### Step 3: Capture what happened + +After the tool runs, call `capture_tool_call`. Pass the prepared intent (and `llm_model:` and `llm_model_source:`, if you capture the model), the arguments and result, the duration, and whether it failed. On a failure pass `error:` (the exception, or a message); PostHog fills `$mcp_error_type`, `$mcp_error_message`, and emits the `$exception` sibling. + +Ruby + +```ruby +started = Process.clock_gettime(Process::CLOCK_MONOTONIC) +begin + result = run_tool(name, prepared.args) +rescue StandardError => e + posthog.capture_tool_call( + name, + intent: prepared.intent, + intent_source: prepared.intent_source, + parameters: prepared.args, + duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000, + is_error: true, + error: e, + distinct_id: user_id + ) + raise +end + +posthog.capture_tool_call( + name, + intent: prepared.intent, + intent_source: prepared.intent_source, + parameters: prepared.args, + response: result, + duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000, + distinct_id: user_id +) +``` + +The other capture methods follow the same shape and map to the events in the [event reference](/docs/mcp-analytics/events.md): + +| Method | Event | Call it when | +| --- | --- | --- | +| `capture_initialize(client_name:, client_version:, protocol_version:, ...)` | `$mcp_initialize` | You answer an `initialize` handshake. Read the client name and version from `params.clientInfo`. | +| `capture_tools_list(tool_names:, ...)` | `$mcp_tools_list` | You answer `tools/list`. Pass the names you advertised. | +| `capture_tool_call(name, ...)` | `$mcp_tool_call` (+ `$exception`) | A tool ran, succeeded or failed. | +| `capture_missing_capability(context:, ...)` | `$mcp_missing_capability` | The agent called the `get_more_tools` virtual tool. | + +### Step 4: Attribute the caller + +Every capture method accepts the same attribution keywords. Pass them on every call so the events for one client group together: + +| Keyword | Becomes | Where to get it | +| --- | --- | --- | +| `distinct_id:` | the event's person | Your auth (OAuth subject, API key owner). See [Identifying users](/docs/mcp-analytics/identifying-users.md). | +| `session_id:` | `$session_id` | The `Mcp-Session-Id` header (see below). Without it, events are anonymous per request. | +| `set_properties:` | `$set` | Person properties such as name or plan. | +| `groups:` | `$groups` | `{ organization: org_id }` for [group analytics](/docs/product-analytics/group-analytics.md). | +| `client_user_agent:`, `vendor_client:` | `$mcp_client_user_agent`, `$mcp_vendor_client` | The `User-Agent` and `X-Anthropic-Client` request headers. | +| `protocol_version:` | `$mcp_protocol_version` | `params.protocolVersion` on `initialize`, or the `MCP-Protocol-Version` header. | + +For `session_id:`, the simplest option is `use PostHog::MCP::RackMiddleware` in your Rack stack. The middleware reads no request or response body – you already parse the JSON-RPC body yourself – so when you answer an accepted `initialize`, call the mint hook it leaves in `env["posthog_mcp.mint"]`: + +Ruby + +```ruby +session = env["posthog_mcp.mint"]&.call( + client_name: params["clientInfo"]["name"], + client_version: params["clientInfo"]["version"], + protocol_version: params["protocolVersion"] +) + +posthog.capture_initialize( + client_name: params["clientInfo"]["name"], + client_version: params["clientInfo"]["version"], + protocol_version: params["protocolVersion"], + session_id: session&.session_id, + distinct_id: user_id +) +``` + +The middleware attaches the minted token to the `Mcp-Session-Id` response header, clients replay it on every request, and on those requests the decoded token is waiting in `env["posthog_mcp.session"]` – so everywhere else you just pass `session_id: env["posthog_mcp.session"]&.session_id`. The hook is absent (`nil`) when the client already replayed a token, and returns `nil` for a `2026-07-28` client, which must not be answered with an `Mcp-Session-Id`. If you issue your own session header instead, pass `PostHog::MCP.derive_session_id_from_mcp_session(your_id)` so the same connection always maps to the same `$session_id`. + +### Step 5: Flush + +`PostHog::MCP::Client` batches events in the background like any `posthog-ruby` client. Call `posthog.flush` at the end of a short-lived request handler, or `posthog.shutdown` when the process stops. + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/events.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/events.md new file mode 100644 index 000000000..6184cda49 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/events.md @@ -0,0 +1,137 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Event and property reference + +This page is the wire-level contract for the MCP Analytics SDKs. TypeScript-only properties are marked below. All property keys are prefixed with `$mcp_*` so they never collide with PostHog autocapture, Web analytics, or other product events. + +## Events + +| Event name | When it fires | Notable extras | +| --- | --- | --- | +| `$mcp_tool_call` | Every `tools/call` request | `$mcp_tool_name`, `$mcp_tool_description`, `$mcp_parameters`, `$mcp_response`, `$mcp_duration_ms`, `$mcp_is_error`, `$mcp_error_type`/`$mcp_error_message` (on errors), optionally `$mcp_intent`/`$mcp_intent_source` and `$mcp_llm_model`/`$mcp_llm_model_source` | +| `$mcp_tools_list` | Every `tools/list` response | `$mcp_listed_tool_names` (string\[\] of advertised tool names), `$mcp_response` (the response envelope as sent, including `nextCursor` and the 2026-07-28 `ttlMs`/`cacheScope` directives) | +| `$mcp_resources_list` | Every `resources/list` and `resources/templates/list` request | `$mcp_response` (the listing as sent: names, URIs, URI templates, MIME types, `nextCursor`), `$mcp_duration_ms`, `$mcp_is_error`. `$mcp_parameters.request.method` tells the two listings apart. | +| `$mcp_resource_read` | Every `resources/read` request | `$mcp_resource_name` (the URI, with credentials redacted), `$mcp_parameters`, `$mcp_duration_ms`, `$mcp_is_error`, `$mcp_error_type`/`$mcp_error_message` (on errors). The resource body is never captured. | +| `$mcp_initialize` | Every `2025-11-25` client/server handshake | `$mcp_client_name`, `$mcp_client_version`, `$mcp_server_name`, `$mcp_server_version`, `$mcp_protocol_version` | +| *(your event name)* | A call to `analytics.capture({ event, properties })` | Sent under the verbatim `event` name you pass (a customer event, not `$`\-prefixed), with your `properties` merged in. See [Custom events](/docs/mcp-analytics/custom-events.md). | +| `$mcp_missing_capability` | The `get_more_tools` virtual tool is invoked (`reportMissing: true`) | The agent's reasoning is captured as `$mcp_intent`. See [Tracking missing capabilities](/docs/mcp-analytics/missing-capability.md). | +| `$identify` | `identify()` returns a new identity for a session | `$set` populated from the identity's `properties` | +| `$exception` | Sibling event whenever a tool call or resource request errors (unless `enableExceptionAutocapture: false`) | `$exception_list`, `$exception_level`, plus the same `$mcp_*` context as the main event | + +Both SDKs capture resource events. The event enum reserves `$mcp_prompts_list` and `$mcp_prompt_get`, but the wrappers do not emit these events yet. Prompt payloads pass through unchanged. + +## Core properties + +Present on most `mcp_*` events. + +| Wire key | Type | Source | +| --- | --- | --- | +| `$session_id` | string | The MCP session ID (`ses_<32-hex>`). See [session resolution](#session-resolution) below. | +| `$mcp_source` | string | Always `"posthog_mcp_analytics"`. Use this to filter out non-MCP events when querying mixed projects. | +| `$mcp_resource_name` | string | Tool or prompt name, or on resource events the resource URI with its credentials redacted (see [Privacy](/docs/mcp-analytics/privacy.md)) | +| `$mcp_tool_name` | string | Same as `$mcp_resource_name`, but only on `$mcp_tool_call` | +| `$mcp_tool_description` | string | The tool's `description` at the moment of the call. Cached from `tools/list` and (for `McpServer`) seeded from `_registeredTools`. Only on `$mcp_tool_call` and the paired `$exception` event. | +| `$mcp_tool_category` | string | Your own grouping label for the tool, when you set one. Only on `$mcp_tool_call` and the paired `$exception` event. | +| `$mcp_listed_tool_names` | string\[\] | Names of tools advertised in a `tools/list` response. Only on `$mcp_tools_list`. Useful for joining against `$mcp_tool_call` via `$session_id` to find tools advertised but never called. | +| `$mcp_duration_ms` | number (ms) | Wall-clock duration of the tool call or resource request | +| `$mcp_is_error` | boolean | True if the handler threw or returned `isError: true` | +| `$mcp_error_type` | string | Failure category, present only when `$mcp_is_error` is true. Defaults to the thrown error type. Override it with a label such as `validation`, `permission`, `timeout`, or `rate_limited`. Use it to group failures without joining `$exception` events. | +| `$mcp_error_message` | string | The failed call's error message, truncated and passed through the same redaction as `$mcp_parameters` and `$mcp_response`. Only set when `$mcp_is_error` is true. | +| `$mcp_server_name` | string | `server._serverInfo.name` | +| `$mcp_server_version` | string | `server._serverInfo.version` | +| `$mcp_client_name` | string | The calling client as it reports itself. Resolved per request, field by field, through the MCP SDK v2 request envelope, then `params._meta`, then the server's own `getClientVersion()`. | +| `$mcp_client_version` | string | Same resolution as `$mcp_client_name`. | +| `$mcp_client_user_agent` | string | The client's raw `User-Agent`. It can distinguish clients that share a `clientInfo.name`, such as Claude Code CLI and Agent SDK. Available only on HTTP transports. | +| `$mcp_vendor_client` | string | The calling client's vendor client header, captured raw. HTTP transports only. PostHog resolves this and the user agent into friendly product labels at query time, so labels keep improving without an SDK upgrade. | +| `$mcp_protocol_version` | string | The request's MCP revision, such as `2025-11-25`. Resolution order: v2 request envelope, `params._meta`, `MCP-Protocol-Version` header, then server accessors. On `2026-07-28`, each request declares its revision, so one session can contain multiple revisions. Use it to compare adoption, errors, and latency by revision. | +| `$mcp_intent` | string | From the `context` argument the agent passed, or from your `intentFallback` callback. See [Capturing agent intent](/docs/mcp-analytics/intent.md). | +| `$mcp_intent_source` | `"context_parameter" \| "inferred"` | Tells you which path produced the intent. Absent when no intent was captured. | +| `$mcp_llm_model` | string | The model from recognized client metadata or an SDK-injected `llm_model` argument. Enabled by default in both SDKs. Missing, blank, and `unknown` values are omitted. Both sources are unverified. | +| `$mcp_llm_model_source` | `"client_metadata" \| "self_reported"` | How the model identifier was obtained. Recognized client metadata takes priority over self-report. | +| `$mcp_parameters` | object | Sanitized request arguments. SDK-owned analytics arguments are removed before dispatch. Arguments declared by the application remain application data. See [Privacy & redaction](/docs/mcp-analytics/privacy.md). | +| `$mcp_response` | object | Sanitized tool result, or the listing on `$mcp_tools_list` and `$mcp_resources_list`. Never a resource body. | +| `$mcp_conversation_id` | string | Present when `enableConversationId` is on. See [Conversation IDs](/docs/mcp-analytics/conversation-id.md). | + +### Session resolution + +The SDK checks these sources in order and uses the first match: + +1. An accepted agent-provided `conversation_id`, when [conversation IDs](/docs/mcp-analytics/conversation-id.md) are enabled. +2. A session ID carried by the request on `2025-11-25`. +3. The server instance's session ID, which rotates after 30 minutes of inactivity. + +`2026-07-28` has no protocol sessions, so only sources 1 and 3 apply. Conversation handles support correlation across reconnects, restarts, and server instances. The SDK derives the session ID deterministically, without a salt. Separate pods therefore derive the same session ID from the same handle. + +### How the harness label is resolved + +A **harness** is the client label on the dashboard, such as "Claude Code", "Cursor", or "ChatGPT". PostHog derives it at query time from these properties, in priority order: + +1. **`$mcp_vendor_client`** – the vendor header (e.g. `x-anthropic-client`), the only signal separating Anthropic's pooled surfaces (Claude.ai, Cowork, Claude Design) from each other, since they all report the same `clientInfo.name`. +2. **`$mcp_client_user_agent`** – identifies the Claude Code interface: `(cli)`, `(sdk-ts)`, `(claude-vscode)`, or `(claude-desktop)`. It is also the fallback when no client name is available. +3. **`$mcp_client_name`** – the `clientInfo.name` the client reported. + +Requests through a vendor's gateway identify the gateway, not necessarily the original client. Claude.ai web, desktop, and mobile connectors use the same Anthropic fetcher. They share the `Claude-User` user agent and vendor header value. These requests resolve to "Claude.ai" because the request does not distinguish the three clients. + +A call without these properties is unattributed. An unrecognized client appears as "Other". + +If you see mostly "Other", check whether these properties contain values. On HTTP transports, the SDK captures headers automatically. [Custom dispatchers](/docs/mcp-analytics/custom-servers.md#attributing-the-caller) must pass them explicitly. Stdio and in-memory transports have no headers, so only the client name is available. + +## Exception properties + +Present on `$exception` events emitted alongside any failed tool call. The SDK reuses `@posthog/core`'s error-tracking parser, so these are the same `$exception_list` properties every other PostHog SDK emits – they slot straight into [Error tracking](/docs/error-tracking.md). Set `enableExceptionAutocapture: false` (default `true`) to stop a failed tool call from emitting the `$exception` sibling. + +| Wire key | Source | +| --- | --- | +| `$exception_list` | Array of structured exceptions. Each has `type`, `value` (the message), `mechanism`, and a `stacktrace` with parsed `frames` (`filename`, `function`, `lineno`, `colno`, `in_app`). An `Error.cause` chain appears as additional entries. | +| `$exception_level` | Severity, always `"error"`. | + +Plus `$session_id`, `$mcp_conversation_id`, `$mcp_resource_name`, `$mcp_tool_name`, `$mcp_tool_description` and `$mcp_tool_category` (tool calls only), `$mcp_server_*`, `$mcp_client_*` (including `$mcp_client_user_agent` and `$mcp_vendor_client`), `$mcp_protocol_version`, and the model properties when captured. + +**Symbolicating minified MCP servers** + +Upload source maps with the [PostHog CLI](/docs/error-tracking/upload-source-maps.md) to symbolicate stack frames from bundled or minified servers. This follows the standard backend SDK process. The MCP SDK does not yet apply optional Node frame modifiers for source-context lines or project-relative paths. + +## Person properties (`$set`) + +Set on `$identify` events when `identify()` returns a user. + +| Key | Source | +| --- | --- | +| (any) | Keys of the identity's `properties` are written to `$set` (e.g. return `properties: { name, email }` to set a person's name and email) | + +The SDK sends `$set` to update the person profile. PostHog does not retain `$set` on the stored event. Query the resulting values as [person properties](/docs/product-analytics/person-properties.md) rather than filtering events by `$set`. + +## Groups (`$groups`) + +If `identify()` returns a `groups` field (a `Record` of groupType -> groupKey), the SDK stamps it onto every event as `$groups`. You never hand-write `$groups` yourself. See [Identifying users](/docs/mcp-analytics/identifying-users.md). + +## Person profiles for anonymous sessions + +Events for sessions with no resolved identity are sent with `$process_person_profile: false`, so anonymous MCP sessions do not each create a person profile. Once `identify()` resolves an identity for the session, person processing stays on and the events attribute to that user. + +## Constants exported from the package + +For product code that queries against the SDK's contract, the package exports: + +- `POSTHOG_MCP_ANALYTICS_SOURCE` – the constant `"posthog_mcp_analytics"` (matches `$mcp_source`) +- `PostHogMCPAnalyticsEvent` – enum of canonical event names +- `PostHogMCPAnalyticsProperty` – enum of canonical property names + +Use them instead of hard-coding strings so renames stay typesafe: + +TypeScript + +```typescript +import { PostHogMCPAnalyticsEvent, PostHogMCPAnalyticsProperty } from "@posthog/mcp"; + +const event = PostHogMCPAnalyticsEvent.ToolCall; // "$mcp_tool_call" +const key = PostHogMCPAnalyticsProperty.ToolName; // "$mcp_tool_name" +``` + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/identifying-users.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/identifying-users.md new file mode 100644 index 000000000..df85e52b5 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/identifying-users.md @@ -0,0 +1,112 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Identifying users + +By default, the SDK attributes MCP events to a generated session ID (`ses_…`). On `2025-11-25`, this normally follows the protocol session. On `2026-07-28`, [conversation IDs](/docs/mcp-analytics/conversation-id.md) are on by default. Calls share a session only when the agent echoes the handle. The SDK does not know the person behind the session. + +Use `identify` to associate calls with a person. + +## How attribution works + +For each event, the SDK picks `distinct_id` in this order: + +1. The id returned by your `identify(request, extra)` callback (if it returned a `UserIdentity`). +2. The MCP session id (`ses_…`). +3. The literal string `"anonymous"`. + +Before `identify` returns a user, events use the session ID. Later events use the user ID. See [Identity merges](#identity-merges) for how PostHog associates earlier anonymous events. + +## Anonymous sessions and person profiles + +Events for sessions with **no** resolved identity are sent with `$process_person_profile: false`. This keeps anonymous MCP sessions from each creating a person profile (which would inflate your person count and billing). Once `identify` resolves an identity for a session, person processing stays on and the events create/update that user's profile as normal. + +## Configure `identify` + +`identify` is a sync or async callback that returns a `UserIdentity` or `null`. It runs on each request. The SDK caches identities per session to avoid duplicate `$identify` events. It still calls the callback on every request. + +TypeScript + +```typescript +import { instrument, getRequestHeaders } from "@posthog/mcp" + +instrument(server, posthog, { + identify: async (request, extra) => { + const token = getRequestHeaders(extra)?.["authorization"] + if (!token) return null + + const user = await resolveUserFromToken(token) + if (!user) return null + + return { + distinctId: user.id, // becomes distinct_id + properties: { // written to $set + name: user.name, + email: user.email, + plan: user.plan, + signupDate: user.signupDate, + }, + groups: { // becomes $groups on every event + organization: user.orgId, + }, + } + }, +}) +``` + +Read headers with `getRequestHeaders`. The two MCP SDK majors store them in different locations. Reading `extra` directly can return `undefined` and cause anonymous attribution. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md#if-your-callbacks-read-headers-change-them). + +This is the same shape as posthog-node's [`identify({ distinctId, properties })`](/docs/libraries/node.md) – just returned from a per-request callback instead of called imperatively. The fields map to PostHog as follows: + +- `distinctId` -> the event's `distinct_id`. +- `properties` -> written verbatim to `$set` (so to set a person's name or email, put them here, e.g. `properties: { name, email }`). `$set` updates the person profile but isn't retained on the stored event, so query these as [person properties](/docs/product-analytics/person-properties.md). +- `groups` (optional `Record` of groupType -> groupKey) is stamped onto **every** event as `$groups`. You never hand-write `$groups` yourself. + +When this returns a non-null identity, the SDK: + +1. Switches the event's `distinct_id` to `distinctId` for that session. +2. Emits a `$identify` event the first time the identity is observed (or whenever it changes for that session), with `$set` populated from `properties`. +3. Stamps `$groups` onto subsequent events from the returned `groups` map. +4. Caches the identity in a small per-server LRU keyed by session id, so unchanged identities are silently deduped. + +## Identity merges + +An MCP session can emit events before authentication completes. For example, `$mcp_initialize` may occur before you identify the user. These events use the session ID. + +When the SDK emits `$identify`, it sets `$anon_distinct_id` to the prior session ID so PostHog can merge the anonymous events. Subsequent events use `distinctId`. + +For stateless servers that recover a session from a token, the SDK suppresses the first `$identify` after `initialize` to avoid duplicates across instances. If identity only becomes available later, those earlier anonymous events may remain unmerged. Resolve identity during `initialize` when possible. + +The [Node SDK](/docs/libraries/node.md) uses the same identity merge model. Your project's person profile settings also apply. + +## When *not* to call identify + +- **Internal tools without per-user auth.** If your MCP server doesn't authenticate end users (e.g. a single-tenant internal server behind a VPN), leave `identify` unset. Session-scoped attribution is fine. +- **Bots and crawlers.** Returning a junk identity for unauthenticated traffic dilutes your person count. Return `null` for traffic you can't identify – those events stay session-scoped. + +## Querying by identified user + +Once identification is wired up, anything that filters on `person.properties.*` or groups by `distinct_id` works as expected: + +SQL + +[Run in PostHog](https://us.posthog.com/sql?open_query=SELECT%0A++person.properties.plan+AS+plan%2C%0A++properties.%24mcp_tool_name+AS+tool%2C%0A++count%28%29+AS+calls%0AFROM+events%0AWHERE+event+%3D+'%24mcp_tool_call'%0A++AND+timestamp+%3E+now%28%29+-+INTERVAL+30+DAY%0AGROUP+BY+plan%2C+tool%0AORDER+BY+calls+DESC) + +```sql +SELECT + person.properties.plan AS plan, + properties.$mcp_tool_name AS tool, + count() AS calls +FROM events +WHERE event = '$mcp_tool_call' + AND timestamp > now() - INTERVAL 30 DAY +GROUP BY plan, tool +ORDER BY calls DESC +``` + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/installation.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/installation.md new file mode 100644 index 000000000..558d8c924 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/installation.md @@ -0,0 +1,600 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Installing the MCP Analytics SDK + +**Beta SDK** + +`@posthog/mcp` is in beta (pre-1.0). Minor `0.x` releases may contain breaking API changes until `v1`. Pin a version during the beta. + +## Requirements + +- Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), Python 3.10+ – see [Python](#python) below – or Ruby 3.0+ – see [Ruby](#ruby) below (experimental and unsupported) +- An MCP server built on either TypeScript SDK major – `@modelcontextprotocol/sdk` (v1) or `@modelcontextprotocol/{core,server,client}` (v2) – either official Python MCP SDK major (`mcp>=1.26,<3`), or the official Ruby MCP SDK (`mcp` gem `>= 1.4`, experimental and unsupported). jlowin's standalone `fastmcp` package is also supported. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md). (Running a custom dispatcher with no server object to wrap? See [Custom servers](/docs/mcp-analytics/custom-servers.md).) +- A PostHog [project token](/docs/getting-started/project-token.md) (`phc_…`) + +## AI wizard + +The wizard installs the package, adds your `posthog-node` client, and configures `instrument()`. It also supports [LLM coding agents](/blog/envoy-wizard-llm-agent.md), such as Cursor and Bolt: + +`npx @posthog/wizard mcp-analytics` + +[Learn more](/wizard.md) + +For manual installation, follow the steps below. + +## Install + +Terminal + +```bash +npm install @posthog/mcp posthog-node +# or pnpm add @posthog/mcp posthog-node +# or yarn add @posthog/mcp posthog-node +``` + +Pass your [`posthog-node`](/docs/libraries/node.md) client to `instrument()` as the required second argument. This follows the [`@posthog/ai`](/docs/ai-engineering.md) pattern. You manage the client lifecycle. Call `posthog.shutdown()` or `posthog.flush()` to send queued events. + +## Wrap your server + +Call `instrument(server, posthog, options?)` once per server. The `posthog` client is required. The `options` argument is optional. The function returns an analytics handle for [custom events](/docs/mcp-analytics/custom-events.md). A second call on the same server logs a warning and returns early. + +Both SDKs capture intent, models, and exceptions and enable conversation IDs by default. [Missing-capability reporting](/docs/mcp-analytics/missing-capability.md) and agent feedback are opt-in. + +### Low-level `Server` + +If you registered your tools against the raw protocol `Server` from `@modelcontextprotocol/sdk/server/index.js`: + +TypeScript + +```typescript +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { PostHog } from "posthog-node" +import { instrument } from "@posthog/mcp" + +const server = new Server({ name: "my-mcp-server", version: "1.0.0" }) + +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", // or https://eu.i.posthog.com +}) + +// register your tools as usual... + +const analytics = instrument(server, posthog) +``` + +### High-level `McpServer` + +Pass the typed `McpServer` wrapper directly to `instrument()`. The SDK unwraps it and adds a proxy to `_registeredTools`. This proxy also instruments tools that you register later: + +### SDK-v1 + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { PostHog } from "posthog-node" +import { instrument } from "@posthog/mcp" + +const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }) + +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", +}) + +const analytics = instrument(server, posthog) + +server.tool("search_events", { /* ... */ }, async (args) => { + // your handler runs untouched +}) +``` + +### SDK-v2 + +```typescript +import { McpServer } from "@modelcontextprotocol/server" +import { PostHog } from "posthog-node" +import { instrument } from "@posthog/mcp" + +const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }) + +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", +}) + +const analytics = instrument(server, posthog) + +server.registerTool("search_events", { /* ... */ }, async (args) => { + // your handler runs untouched +}) +``` + +Options, callbacks, and events work the same on both majors. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md) for the differences. + +### Next.js / Vercel (`mcp-handler`) + +[`mcp-handler`](https://github.com/vercel/mcp-handler) provides a standard `McpServer` in its setup callback. Call `instrument()` in that callback, before or after you register tools: + +TypeScript + +```typescript +import { createMcpHandler } from "mcp-handler" +import { PostHog, instrument } from "@posthog/mcp" + +// Create the client once at module scope (not per request). +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", // or https://eu.i.posthog.com +}) + +const handler = createMcpHandler( + (server) => { + instrument(server, posthog) + server.registerTool("roll_dice", { /* ... */ }, async ({ sides }) => { /* ... */ }) + }, + {}, + { basePath: "/api" }, +) + +export { handler as GET, handler as POST } +``` + +#### Grouping a client's calls + +On Vercel, `mcp-handler` creates a new server for each request and provides no `Mcp-Session-Id` header. Without another correlation signal, the SDK assigns a separate `$session_id` to each request. + +Use [`identify`](/docs/mcp-analytics/identifying-users.md) to group calls by user. Return a `distinctId` from your authentication data, such as the OAuth subject. The SDK uses this value as `distinct_id` across requests. This requires no client changes: + +TypeScript + +```typescript +instrument(server, posthog, { + identify: (request, extra) => ({ distinctId: getUserId(extra) }), +}) +``` + +[`enableConversationId`](/docs/mcp-analytics/conversation-id.md) is on by default. The SDK adds a `conversation_id` argument and returns a handle in eligible tool results. The tool schema asks the agent to reuse this handle. Correlation is best-effort because some clients ignore the handle or treat tool output as untrusted content. + +If this behavior doesn't suit your client, set `enableConversationId: false` and use `identify` for user-level grouping. + +#### Flushing + +`posthog-node` batches events. A serverless function can freeze before the client sends them. Call `await posthog.flush()` at the end of each invocation. On supported platforms, `ctx.waitUntil(posthog.flush())` keeps the runtime active until the flush completes. + +### NestJS (`@rekog/mcp-nest`) + +[`@rekog/mcp-nest`](https://github.com/rekog-labs/MCP-Nest) creates the server through `McpModule.forRoot(...)`. You define tools with `@Tool()` decorators. Add `instrumentMutator` to the module's `serverMutator` hook: + +TypeScript + +```typescript +import { Module } from "@nestjs/common" +import { McpModule } from "@rekog/mcp-nest" +import { PostHog, instrumentMutator } from "@posthog/mcp" + +// Create the client once at module scope. +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", // or https://eu.i.posthog.com +}) + +@Module({ + imports: [ + McpModule.forRoot({ + name: "my-mcp-server", + version: "1.0.0", + serverMutator: instrumentMutator(posthog), + }), + ], +}) +export class AppModule {} +``` + +`instrumentMutator(posthog)` calls `instrument()` and returns the server. It does not return the analytics handle. The SDK also captures tools that mcp-nest registers after the mutator runs. + +`instrumentMutator(posthog)` uses the same defaults, including on fresh server instances. Conversation correlation requires the agent to echo the handle. + +If you need the analytics handle for [custom events](/docs/mcp-analytics/custom-events.md), call `instrument()` directly inside the mutator and return the server yourself: + +TypeScript + +```typescript +serverMutator: (server) => { + const analytics = instrument(server, posthog) + // ...use `analytics.capture(...)` elsewhere... + return server +} +``` + +## Stateless and multi-pod servers + +A stateless server creates a new instance for each request, often on a different pod. Without correlation, each request gets its own `$session_id`. Client name and version arrive only at `initialize`, so later events lose these values. + +The SDK requires no shared session store or sticky routing. At `initialize`, it creates a token that contains the session ID and client metadata. It sends this token in the `Mcp-Session-Id` response header. Clients resend the header on later requests, so any pod can read the same values. No client changes are required. + +**This applies to 2025-11-25 traffic** + +The `2026-07-28` revision has no `initialize` or `Mcp-Session-Id`, so this section does not apply. See [Sessions on 2026-07-28](/docs/mcp-analytics/sdk-v2.md#sessions-on-2026-07-28). + +### Streamable HTTP needs `enableJsonResponse: true` + +The SDK can send its session token only in JSON mode. In SSE mode, `StreamableHTTPServerTransport` creates response headers before your `initialize` handler runs. The token is missing from the response. Without another correlation signal, the SDK assigns a session per request: + +TypeScript + +```typescript +new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + enableJsonResponse: true, // lets the SDK mint the session header +}) +``` + +Use a fresh transport per request, which stateless mode requires anyway. With [`@rekog/mcp-nest`](https://github.com/rekog-labs/MCP-Nest), set the same option on the module: `streamableHttp: { statelessMode: true, enableJsonResponse: true }`. + +### If you must stream (SSE) + +Set the header yourself at the HTTP layer with `encodeSessionId`, reading `clientInfo` off the `initialize` body. The SDK decodes it either way: + +TypeScript + +```typescript +import { MCP_SESSION_HEADER, encodeSessionId, newSessionId } from "@posthog/mcp" + +// after parsing the POST body, before flushing response headers: +if (body?.method === "initialize" && !req.headers[MCP_SESSION_HEADER]) { + res.setHeader( + MCP_SESSION_HEADER, + encodeSessionId({ + sessionId: newSessionId(), + clientName: body.params?.clientInfo?.name, + clientVersion: body.params?.clientInfo?.version, + }) + ) +} +``` + +### When you can't use a session token + +Some frameworks create the transport without exposing `enableJsonResponse`. Some clients ignore the session header. Either case can produce a separate session per request. + +Use [`identify`](/docs/mcp-analytics/identifying-users.md) to group calls by `distinct_id` without client cooperation. [Conversation IDs](/docs/mcp-analytics/conversation-id.md) group calls by conversation when the agent echoes the handle. + +## Python + +The Python MCP Analytics SDK is part of [`posthog`](/docs/libraries/python.md), like [`posthog.ai`](/docs/ai-engineering.md). Install the package: + +Terminal + +```bash +pip install posthog +``` + +`instrument()` requires `mcp` or `fastmcp` at runtime. These are peer dependencies, not bundled packages. The SDK detects both official `mcp` majors (`mcp>=1.26,<3`) and supports jlowin's standalone `fastmcp`. Custom dispatchers that use `PostHogMCP` require only `posthog`. + +`instrument(server, posthog_client, options?)` works with every common Python MCP server: + +- `FastMCP` and the low-level `Server` from the official [`modelcontextprotocol/python-sdk`](https://github.com/modelcontextprotocol/python-sdk) (the `mcp` package, 1.x) +- `MCPServer` – FastMCP's new name on `mcp` 2.x – and the v2 low-level `Server`, see [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md#python) +- [jlowin's standalone **FastMCP 2.0**](https://github.com/jlowin/fastmcp) (the separate `fastmcp` package) +- `PostHogMCP` for custom dispatchers with no server object (see below) + +Python + +```python +from posthog import Posthog +from posthog.mcp import instrument +from mcp.server.fastmcp import FastMCP + +posthog = Posthog( + "phc_your_project_api_key", + host="https://us.i.posthog.com", # or https://eu.i.posthog.com +) +server = FastMCP("my-server") + +# On MCP SDK 2.x, FastMCP was renamed — instrument() works the same: +# from mcp.server.mcpserver import MCPServer +# server = MCPServer("my-server") + +# register your tools as usual... + +analytics = instrument(server, posthog) +``` + +Options are passed as `MCPAnalyticsOptions`, the snake\_case equivalent of the TypeScript options: + +Python + +```python +from posthog.mcp import instrument +from posthog.mcp.types import MCPAnalyticsOptions, UserIdentity + +instrument(server, posthog, MCPAnalyticsOptions( + report_missing=True, # register the get_more_tools virtual tool + identify=lambda request, extra: UserIdentity(distinct_id="user_123"), +)) +``` + +`MCPAnalyticsOptions` fields (the TypeScript [Configuration](#configuration) table below uses camelCase – these are the Python names): + +| Option | Type | Default | What it does | +| --- | --- | --- | --- | +| `context` | `bool \| MCPAnalyticsContextOptions` | `True` | Inject the `context` intent argument into compatible tool schemas. | +| `report_missing` | `bool` | `False` | Register the `get_more_tools` virtual tool. | +| `missing_capability_tool_name` | `str` | `"get_more_tools"` | Rename the virtual tool registered by `report_missing`. | +| `enable_conversation_id` | `bool` | `True` | Inject an optional `conversation_id` argument to group calls when the agent echoes the handle. | +| `capture_model` | `bool \| MCPAnalyticsModelOptions` | `True` | Capture the model from recognized client metadata or an SDK-injected `llm_model` argument. | +| `enable_exception_autocapture` | `bool` | `True` | Emit a `$exception` sibling on failed tool calls. | +| `identify` | `(request, extra) -> UserIdentity \| None` (sync or async) | – | Map a request to one of your users. | +| `intent_fallback` | `(request, extra) -> str \| None` | – | Provide intent when the agent didn't pass `context`. | +| `before_send` | `(event) -> event \| None` | – | Inspect/modify/drop each event before send. | +| `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 + +A stateless Python deployment has the [same correlation problem](#stateless-and-multi-pod-servers). The SDK creates a session token in the `Mcp-Session-Id` header. Clients resend it on later requests. The ASGI layer supports JSON and SSE, so Python does not need `enableJsonResponse`. + +On official `mcp.server.fastmcp` and jlowin's `fastmcp` 2.0, `instrument()` wraps the `streamable_http_app()` and `sse_app()` factories. These factories also serve `run()`. Set the server to stateless mode: + +Python + +```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.md) dispatcher – add the middleware to that app once: + +Python + +```python +from posthog.mcp import PostHogMcpStatelessSessionMiddleware + +app.add_middleware(PostHogMcpStatelessSessionMiddleware) +``` + +### Flushing on exit + +The `posthog` client batches events asynchronously. You manage its lifecycle. `instrument()` schedules captured events in the background. + +At shutdown, call `await analytics.flush()` to wait for pending captures. Then call `posthog.shutdown()` to send queued events and stop the client. `posthog.flush()` sends queued events without stopping it. See the [complete Python example](https://github.com/PostHog/posthog-python/blob/main/examples/mcp_analytics_demo.py): + +Python + +```python +analytics = instrument(server, posthog) +# ... serve ... +await analytics.flush() # drain in-flight auto-capture events +posthog.shutdown() # flush + stop the posthog client +``` + +### Custom dispatchers (no server object to wrap) + +For a custom dispatcher, use `PostHogMCP`, a `posthog` client subclass that does not require an MCP server object. Call its capture methods to create events. Use `prepare_tool_list()` and `prepare_tool_call()` for intent capture. It uses the same events, redaction, and truncation as `instrument()`. See the [custom dispatcher example](/docs/mcp-analytics/custom-servers.md#python). + +**Python SDK is beta** + +Python MCP Analytics is in beta, so its API may change. See the [event reference](/docs/mcp-analytics/events.md) for SDK coverage. + +It supports both official `mcp` majors and the `2026-07-28` protocol revision. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md) for what that revision changes. + +## Ruby + +**Ruby SDK is experimental and unsupported** + +`PostHog::MCP` is **experimental and not officially supported**. We don't provide support for it, and the MCP analytics team doesn't maintain it. Its API, its options, and the `$mcp_*` events it captures may change in a minor `posthog-ruby` release, and the gem logs a warning when you require it. + +Try it, and report bugs or send patches to [posthog-ruby](https://github.com/PostHog/posthog-ruby/issues) – but don't build production reporting on it yet. For a supported SDK, use [TypeScript](#typescript) or [Python](#python). + +A Ruby SDK ships inside the [`posthog-ruby`](/docs/libraries/ruby.md) gem, so there's nothing extra to install: + +Ruby + +```ruby +gem "posthog-ruby" +``` + +`PostHog::MCP.instrument` needs the official [Ruby MCP SDK](https://github.com/modelcontextprotocol/ruby-sdk) at runtime, but you already have it – you built your server with the `mcp` gem (`>= 1.4`) – so it's treated as a peer dependency rather than bundled. (`PostHog::MCP::Client` for custom dispatchers needs nothing beyond `posthog-ruby`.) + +`PostHog::MCP.instrument(server, client, **options)` wraps an `MCP::Server`, whether you register tools as `MCP::Tool` classes or with `define_tool`, and works over the stdio and Streamable HTTP transports: + +Ruby + +```ruby +require "posthog/mcp" + +posthog = PostHog::Client.new( + api_key: "phc_your_project_api_key", + host: "https://us.i.posthog.com" # or https://eu.i.posthog.com +) +server = MCP::Server.new(name: "my-server", version: "1.0.0", tools: [SearchEvents]) + +# register more tools, prompts, and resources as usual... + +analytics = PostHog::MCP.instrument(server, posthog) +``` + +If your app already uses [`posthog-rails`](/docs/libraries/ruby.md) and calls `PostHog.init`, leave the client out and the SDK picks up `PostHog.client` for you: + +Ruby + +```ruby +PostHog::MCP.instrument(server) +``` + +Options are keyword arguments: + +Ruby + +```ruby +PostHog::MCP.instrument( + server, posthog, + context: true, # inject the `context` intent argument (default) + report_missing: true, # advertise the get_more_tools virtual tool + enable_conversation_id: true, # stitch calls across reconnects and pods + identify: ->(request, extra) { { distinct_id: "user_123", properties: { plan: "pro" } } } +) +``` + +| Option | Type | Default | What it does | +| --- | --- | --- | --- | +| `context` | `Boolean \| { description: }` | `true` | Inject the `context` intent argument into every tool. | +| `report_missing` | `Boolean` | `false` | Advertise the `get_more_tools` virtual tool. | +| `missing_capability_tool_name` | `String` | `"get_more_tools"` | Rename the virtual tool registered by `report_missing`. | +| `enable_conversation_id` | `Boolean` | `false` | Inject an optional `conversation_id` argument to stitch calls. | +| `enable_exception_autocapture` | `Boolean` | `true` | Emit a `$exception` sibling on failed calls. | +| `capture_model` | `Boolean \| { description: }` | `false` | Inject `llm_model` and capture `$mcp_llm_model`. | +| `identify` | `(request, extra) -> Hash \| nil`, or a static Hash | – | Map a request to one of your users (`distinct_id:`, `properties:`, `groups:`). | +| `intent_fallback` | `(request, extra) -> String \| nil` | – | Provide intent when the agent didn't pass `context`. | +| `before_send` | `(payload) -> payload \| nil` | – | Inspect, modify, or drop each event before send. | +| `event_properties` | `(request, extra) -> Hash` | – | Properties merged onto every event. | +| `logger` | `->(message) { ... }` | no-op | STDIO-safe log sink. Never writes to stdout. | + +The injected arguments are stripped before your tool's `call` receives its keywords, so a tool declared as `def self.call(query:, server_context:)` keeps working. A tool that declares `context` in its own `input_schema` keeps it. A tool whose `input_schema` is composed (`oneOf`, `allOf`, `anyOf`) or a `$ref` gets no injected arguments. + +Events are truncated to fit the 32 KB message limit of the `posthog-ruby` client, because the client drops larger messages when it sends a batch. + +`instrument` returns an analytics handle for [custom events](/docs/mcp-analytics/custom-events.md): `analytics.capture("feedback_submitted", { rating: 5 })`. Call it from the tool body. On Ruby 3.2+ it also works from a thread or fiber that the tool starts. On Ruby 3.0 and 3.1 such an event gets its own `$session_id` on HTTP servers. + +Prompt and resource traffic is captured too, as `$mcp_prompt_get`, `$mcp_prompts_list`, `$mcp_resource_read`, and `$mcp_resources_list`. + +### `$lib` on Ruby events + +MCP events report `$lib: "posthog-ruby-mcp"` so you can tell them apart from the rest of your traffic. This is set per event: the client you pass in keeps its own `$lib` (`posthog-ruby` or `posthog-rails`) for everything else it sends, so instrumenting an MCP server inside a Rails app doesn't relabel the app's other events. + +### Stateless and multi-pod servers + +A stateless server keeps nothing between requests, often on a different pod each time. Left alone, every request becomes its own `$session_id`, and the client name and version (only sent at `initialize`) go missing from every event after the handshake. + +The SDK handles this with no session store and no sticky routing. When `MCP::Server::Transports::StreamableHTTPTransport` runs with `stateless: true`, the SDK mints the `Mcp-Session-Id` response header at `initialize` as a token carrying the session ID and client identity. Clients replay that header on every request, so any pod reads the same values back. Nothing changes on the client side, and there's nothing to configure. + +When you build the Rack app yourself, add the middleware once: + +Ruby + +```ruby +use PostHog::MCP::RackMiddleware +``` + +It reads neither the request nor the response body: it publishes the request's headers to the instrumented server below it and carries back the token that server minted once the handshake succeeded. The decoded token is exposed to your app as `env["posthog_mcp.session"]`. (Dispatching MCP requests by hand, with no `MCP::Server` to wrap? Then you mint it yourself – see [Custom servers](/docs/mcp-analytics/custom-servers.md#step-4-attribute-the-caller).) + +Stateful HTTP servers need nothing: the transport's own session ID is hashed deterministically, so a session survives restarts. [Conversation IDs](/docs/mcp-analytics/conversation-id.md) work too and need no middleware at all. + +### Flushing on exit + +Captured events go straight into the `posthog-ruby` client's queue, so there's nothing to drain besides the client itself. Call `posthog.flush` or `posthog.shutdown` when your process stops: + +Ruby + +```ruby +at_exit { posthog.shutdown } +``` + +### Logging on stdio servers + +A stdio MCP server owns `$stdout` for the protocol. The integration's own messages go only to the `logger:` you pass (nowhere by default); the experimental notice and misconfiguration warnings go to stderr. Point the core SDK's logger away from stdout too: + +Ruby + +```ruby +PostHog::Logging.logger = Logger.new($stderr) +``` + +Dispatching MCP requests without an `MCP::Server`? See [Custom servers](/docs/mcp-analytics/custom-servers.md#ruby). + +## Configuration + +The `posthog` client is passed as the required second positional argument – not in this options object. `instrument()` accepts these options as an optional third argument: + +| Option | Type | Default | What it does | +| --- | --- | --- | --- | +| `logger` | `(message: string) => void` | no-op | STDIO-safe log sink for SDK-internal warnings. MCP STDIO transports cannot use `console.*`, so the default discards. Configure a logger to see warnings during development. | +| `enableExceptionAutocapture` | `boolean` | `true` | When `false`, a failed tool call does not emit the `$exception` sibling event. | +| `context` | `boolean \| { description: string }` | `true` | Inject a required `context` argument into compatible tool schemas. See [Capturing agent intent](/docs/mcp-analytics/intent.md). | +| `captureModel` | `boolean \| { description: string }` | `true` | Capture the model from recognized client metadata or an SDK-injected `llm_model` argument. | +| `intentFallback` | `(request, extra) => string \| Promise` | – | Called when the agent didn't pass a `context` argument. See [Capturing agent intent](/docs/mcp-analytics/intent.md). | +| `enableConversationId` | `boolean` | `true` | Inject an optional `conversation_id` argument into compatible tool schemas. See [Conversation IDs](/docs/mcp-analytics/conversation-id.md). | +| `reportMissing` | `boolean` | `false` | Register the `get_more_tools` virtual tool. See [Missing capability](/docs/mcp-analytics/missing-capability.md). | +| `identify` | `async (request, extra) => UserIdentity \| null \| UserIdentity` | – | Map an MCP request to one of your users. See [Identifying users](/docs/mcp-analytics/identifying-users.md). | +| `beforeSend` | `(event) => event \| null \| undefined \| Promise<...>` | – | Runs on each fully-built PostHog payload right before send. Return the (possibly mutated) event to send it, or a nullish value to drop it. See [Privacy](/docs/mcp-analytics/privacy.md). | +| `eventProperties` | `async (request, extra) => Record` | – | Properties merged onto every event. See [Custom events and metadata](/docs/mcp-analytics/custom-events.md). | + +## Capture the calling model + +Model capture is enabled by default in both SDKs. The SDK reads recognized client metadata first, then falls back to the agent's `llm_model` argument. Events include `$mcp_llm_model` and `$mcp_llm_model_source` (`"client_metadata"` or `"self_reported"`). + +The recognized metadata field is Codex's `params._meta["x-codex-turn-metadata"].model`. Other clients can provide the `llm_model` argument. + +Use this unverified client input to compare tool quality, latency, and errors by model. Don't use it for billing or security decisions. Missing, blank, and `unknown` values are omitted. Reasoning effort isn't captured. + +To disable model capture, conversation IDs, or both, set the corresponding options to `false`: + +### TypeScript + +```typescript +instrument(server, posthog, { + captureModel: false, + enableConversationId: false, +}) +``` + +### Python + +```python +from posthog.mcp.types import MCPAnalyticsOptions + +instrument(server, posthog, MCPAnalyticsOptions( + capture_model=False, + enable_conversation_id=False, +)) +``` + +Schema and framework compatibility + +For compatible tool schemas, TypeScript advertises `llm_model` as required. Python does the same on official high-level adapters and custom dispatchers, but makes it optional on raw low-level servers and standalone FastMCP. Dispatch never enforces the injected field. + +The SDK removes arguments it can confirm it injected before your handler runs. A fresh low-level instance that hasn't served `tools/list` can read `llm_model`, but leaves arguments untouched. It can capture an application-owned `llm_model` until it learns the tool's schema. High-level adapters use the registered schema to preserve application-owned arguments. + +On Python's standalone FastMCP with MCP SDK 1.x, middleware overrides of tool-listing or dispatch hooks disable `llm_model` injection. This also applies to middleware that only passes requests through. Capture from recognized client metadata still works. + +Model capture works on both protocol revisions. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md#mcp-apps) for MCP Apps coverage. + +## Graceful shutdown + +The `posthog-node` client queues and batches events asynchronously. You manage its lifecycle. Call `posthog.shutdown()` from your `SIGTERM` or `beforeExit` handler to send queued events: + +TypeScript + +```typescript +import { PostHog } from "posthog-node" +import { instrument } from "@posthog/mcp" + +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN) +instrument(server, posthog) + +process.on("SIGTERM", async () => { + await posthog.shutdown() + process.exit(0) +}) +``` + +Call `posthog.flush()` to send queued events without stopping the client. + +In serverless or edge environments, flush at the end of each invocation because `SIGTERM` may not run. Use `await posthog.flush()`. On supported platforms, use `ctx.waitUntil(posthog.flush())`. + +## What happens after install + +As soon as the wrapper is in place, instrumented MCP requests emit PostHog events: + +- `$mcp_tool_call` per tool invocation +- `$mcp_tools_list` per `tools/list` response +- `$mcp_initialize` per `2025-11-25` client handshake +- `$exception` whenever a tool throws or returns `isError: true` + +Both SDKs also capture resource discovery and reads. Resource bodies pass through unchanged and aren't captured. See the [event reference](/docs/mcp-analytics/events.md) for SDK coverage. Prompt requests don't emit automatic analytics yet. + +On `2025-11-25`, calls keep their MCP protocol session until the agent echoes a valid conversation handle. On `2026-07-28`, there is no protocol session. [Conversation IDs](/docs/mcp-analytics/conversation-id.md) are enabled by default, but calls only stay correlated when the agent echoes the handle. See the [event reference](/docs/mcp-analytics/events.md) for the full catalog. + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/intent.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/intent.md new file mode 100644 index 000000000..26e3c6e78 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/intent.md @@ -0,0 +1,173 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Capturing agent intent + +Intent explains why an agent called a tool. Use it to understand the goals behind tool usage. + +The SDK captures intent as a single property – `$mcp_intent` – that can come from one of two sources: + +1. **A `context` argument** the agent passes on every tool call. Captured with `$mcp_intent_source = "context_parameter"`. +2. **A fallback callback** you supply on `instrument()`. Captured with `$mcp_intent_source = "inferred"`. + +Explicit context always wins. If the agent passes a non-empty `context`, the fallback is not invoked. + +## The `context` argument + +Intent capture is on by default. The SDK adds a `context` string to compatible tool schemas and removes it before your handler runs when it can confirm it injected the field. An application-owned `context` argument is preserved. + +TypeScript advertises the injected field as required. Python makes it optional on raw low-level servers and standalone FastMCP. Calls can omit the injected field without failing validation. + +What the agent sees in the schema: + +JSON + +```json +{ + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "Why are you calling this tool? Briefly describe the user's goal." + }, + "...": "your real tool arguments" + }, + "required": ["context", "..."] +} +``` + +What your handler receives: + +TypeScript + +```typescript +server.tool("search_events", schema, async (args) => { + // args.context has been stripped — only your real arguments are here +}) +``` + +PostHog captures this value as `$mcp_intent`: + +``` +"Finding the last 10 pageviews for user alice@example.com to triage a drop in conversion" +``` + +### Customize the prompt + +If you want to nudge the agent toward a specific style of context (use case, user goal, ticket id, etc.), pass an object: + +TypeScript + +```typescript +instrument(server, posthog, { + context: { + description: "Describe the user's underlying goal in one sentence — not the tool you're calling.", + }, +}) +``` + +### Disabling the injected argument + +Set `context: false` to disable the injected intent argument. Use `intentFallback` if you still want to capture intent. Model capture and conversation IDs have separate options. [Disable those options](/docs/mcp-analytics/installation.md#capture-the-calling-model) to stop their schema changes. + +## The `intentFallback` callback + +A client can omit the SDK-injected `context` argument. Without a fallback, the event has no `$mcp_intent`. + +The SDK calls `intentFallback` when the agent provides no context. It captures a non-empty result as `$mcp_intent` with `$mcp_intent_source = "inferred"`. + +The SDK does no inference of its own. It doesn't call an LLM. It doesn't inspect your tool arguments. It doesn't cache results. Whatever logic you want goes in your callback. + +### Deterministic, per-tool + +Use a synchronous callback when you can derive intent from the tool name and arguments: + +TypeScript + +```typescript +instrument(server, posthog, { + intentFallback: (request) => { + const tool = request.params?.name + const args = request.params?.arguments ?? {} + if (tool === "search_events") return `Searching events for "${args.query}"` + return tool ? `Invoking ${tool}` : null + }, +}) +``` + +### Using transport metadata + +`extra` carries MCP transport details – useful when the agent's user-agent or auth context hints at intent: + +TypeScript + +```typescript +import { getRequestHeaders } from "@posthog/mcp" + +intentFallback: (request, extra) => { + const ua = getRequestHeaders(extra)?.["user-agent"] + return `${ua ?? "unknown client"} invoked ${request.params?.name}` +} +``` + +`getRequestHeaders` reads headers on both MCP SDK majors – see [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md#if-your-callbacks-read-headers-change-them). + +### LLM-derived intent + +An LLM call in `intentFallback` adds latency to each tool call that lacks context. Cache results where possible. Handle failures in the callback: + +TypeScript + +```typescript +intentFallback: async (request) => { + try { + return await summariseIntent(request.params) + } catch { + return null // the SDK swallows null gracefully + } +} +``` + +## Filtering on intent source + +`$mcp_intent_source` is set to `"context_parameter"` or `"inferred"` only when an intent was captured. If neither a `context` argument nor a fallback result was available, both `$mcp_intent` and `$mcp_intent_source` are absent on the event. + +If you want to know what fraction of your traffic is contextualized: + +SQL + +[Run in PostHog](https://us.posthog.com/sql?open_query=SELECT%0A++properties.%24mcp_intent_source+AS+source%2C%0A++count%28%29+AS+calls%0AFROM+events%0AWHERE+event+%3D+'%24mcp_tool_call'%0A++AND+timestamp+%3E+now%28%29+-+INTERVAL+7+DAY%0AGROUP+BY+source%0AORDER+BY+calls+DESC) + +```sql +SELECT + properties.$mcp_intent_source AS source, + count() AS calls +FROM events +WHERE event = '$mcp_tool_call' + AND timestamp > now() - INTERVAL 7 DAY +GROUP BY source +ORDER BY calls DESC +``` + +A high share of `inferred` means most clients do not supply context. Review `context.description` or improve `intentFallback`. + +## Gotchas + +**\`get\_more\_tools\` reports its source as \`context\_parameter\`** + +The virtual `get_more_tools` tool (enabled by `reportMissing: true`) always reports `$mcp_intent_source = "context_parameter"`, even though the SDK is what defined the schema. Defensible – the agent did type a string – but filter it out of source-attribution queries if the number matters. + +**The schema \`required\` field isn't enforced** + +Omitting the SDK-injected `context` argument doesn't fail validation. Your tool's own required arguments still apply. Use `intentFallback` to capture intent when the agent omits context. + +**Skip \`intentFallback\` for tight internal servers** + +You do not need a fallback if your internal client always supplies `context`. Add one if clients can omit this argument. + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/sdk-v2.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/sdk-v2.md new file mode 100644 index 000000000..1e5d22205 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/sdk-v2.md @@ -0,0 +1,154 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# MCP SDK v2 + +The MCP TypeScript SDK has two majors. `@posthog/mcp` detects and supports both at runtime. Neither MCP SDK major is bundled with the package. The [Python SDK](#python) also supports both majors. + +| Your imports | Major | Protocol revisions it serves | +| --- | --- | --- | +| `@modelcontextprotocol/sdk` | **v1** | `2025-11-25` and earlier | +| `@modelcontextprotocol/core`, `/server`, `/client` | **v2** | `2025-11-25` and `2026-07-28` | + +## Setup + +Use the same `instrument()` call as v1. Import `McpServer` from the v2 package. Use `registerTool()` because v2 removes `server.tool()`: + +TypeScript + +```typescript +import { McpServer } from "@modelcontextprotocol/server" +import { PostHog } from "posthog-node" +import { instrument } from "@posthog/mcp" + +const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }) +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN) + +instrument(server, posthog) + +server.registerTool("search_events", { /* ... */ }, async (args) => { /* ... */ }) +``` + +Model capture and conversation IDs are enabled by default. The low-level `Server` uses the same `instrument(server, posthog)` call. + +## If your callbacks read headers, change them + +**This fails silently** + +v1 stores headers at `extra.requestInfo.headers`. v2 stores a WHATWG `Request` at `extra.http.req`. Read its headers with `.get()`. Using the v1 location on v2 returns `undefined`. This can make `identify()` return `null` and send anonymous events without an error. + +Use the exported helper in `identify`, `intentFallback`, and `eventProperties`. It handles both majors and returns an object with lowercase keys. `beforeSend` receives the built event without request context: + +TypeScript + +```typescript +import { instrument, getRequestHeaders } from "@posthog/mcp" + +instrument(server, posthog, { + identify: async (request, extra) => { + const token = getRequestHeaders(extra)?.["authorization"] + return token ? { distinctId: await resolveUserId(token) } : null + }, +}) +``` + +## Python + +The [Python SDK](/docs/mcp-analytics/installation.md#python) supports both official `mcp` majors (`mcp>=1.26,<3`) and detects them at runtime. On 2.x, `FastMCP` was renamed – the `instrument()` call stays the same: + +Python + +```python +from mcp.server.mcpserver import MCPServer +from posthog.mcp import instrument + +server = MCPServer("my-server") +instrument(server, posthog) +``` + +The low-level `Server` works on both majors. jlowin's standalone `fastmcp` package pins `mcp<2`, so it stays on the 1.x adapter path. + +Python also provides different request context shapes on each major. Use the exported helper in `identify`, `intent_fallback`, and `event_properties`. It returns a dictionary with lowercase keys on HTTP transports. It returns `None` on stdio and never raises: + +Python + +```python +from posthog.mcp import get_request_headers + +def identify(request, extra): + headers = get_request_headers(extra) or {} + return resolve_user(headers.get("authorization")) +``` + +Python uses the same defaults. Both SDKs derive the same session ID from the same echoed conversation handle. + +## Ruby + +The official Ruby MCP SDK has a single major that serves both revisions: the `initialize` handshake for `2025-11-25` and earlier, and the per-request `_meta` envelope for `2026-07-28`. The experimental, [unsupported](/docs/mcp-analytics/installation.md#ruby) Ruby SDK handles both with the same call: + +Ruby + +```ruby +server = MCP::Server.new(name: "my-server", version: "1.0.0", tools: [SearchEvents]) +PostHog::MCP.instrument(server, posthog) +``` + +On `2026-07-28` requests it reads the client name, version, and protocol version off the envelope for every event and never answers with an `Mcp-Session-Id`, so [conversation IDs](/docs/mcp-analytics/conversation-id.md) or [`identify`](/docs/mcp-analytics/identifying-users.md) are what correlate calls there. + +Callbacks (`identify`, `intent_fallback`, `event_properties`) receive `extra["headers"]`, a lowercase-keyed Hash on HTTP transports and an empty Hash on stdio, so header reads look the same on either revision: + +Ruby + +```ruby +identify = lambda do |_request, extra| + resolve_user(extra["headers"]["authorization"]) +end +``` + +## Sessions on `2026-07-28` + +That revision removed the `initialize` handshake and the `Mcp-Session-Id` header, so the [stateless session token](/docs/mcp-analytics/installation.md#stateless-and-multi-pod-servers) doesn't apply. Use a conversation handle to correlate requests: + +- **[Conversation IDs](/docs/mcp-analytics/conversation-id.md)** – enabled by default in both SDKs. The SDK injects a `conversation_id` parameter and returns a handle in eligible tool results. Calls share a `$session_id` when the agent echoes that handle. Clients that ignore it can still produce a separate session per request. +- **[`identify`](/docs/mcp-analytics/identifying-users.md)** – attributes calls to a person via `distinct_id`. Use it for user-level grouping. It does not provide a `$session_id`. + +The protocol revision belongs to each request. A v2 server also serves `2025-11-25` traffic, which most clients still negotiate. + +**Missing client name on 2025-11-25 traffic?** + +On `2025-11-25`, the client sends its name and version only at `initialize`. For servers that create an instance per request, the SDK uses a session token. The transport must write response headers after the handler runs to send this token. + +`@rekog/mcp-nest` supports this with `enableJsonResponse: true`. The legacy path in `createMcpHandler` does not. On that path, expect `$mcp_client_name` and `$mcp_client_version` to be absent. `$mcp_protocol_version` remains available. + +## Capture model identity on both revisions + +Both SDKs capture model identity on `2025-11-25` and `2026-07-28`. Recognized client metadata takes priority over the agent's self-reported `llm_model` argument. `$mcp_llm_model_source` records `"client_metadata"` or `"self_reported"`. + +Both sources are unverified. See [model capture](/docs/mcp-analytics/installation.md#capture-the-calling-model) for defaults, opt-outs, and the Python standalone FastMCP limitation. + +## MCP Apps + +`@posthog/mcp` preserves MCP App tool metadata, `ui://` resources, structured tool output, result metadata, HTML, and content security policy metadata on both supported revisions. On the tested high-level `McpServer` path, injected `context` and `llm_model` arguments don't reach the App handler. + +The SDK captures App tool calls with intent, self-reported model, and protocol version. Resource listing and read requests emit `$mcp_resources_list` and `$mcp_resource_read` on both server paths. Resource payloads pass through unchanged. The SDK never captures a resource read body. + +## Not instrumented yet + +These gaps apply to the TypeScript and Python SDKs alike. + +| `2026-07-28` feature | What you get today | +| --- | --- | +| **Tasks** (`io.modelcontextprotocol/tasks`) | A tool returning a task handle records an instant success, so task-based tools look fast and always-succeeding. | +| **Multi round-trip** (`resultType: "input_required"`) | Each round counts as its own `$mcp_tool_call`, inflating call counts and durations. | +| `server/discover` | Not captured – no session-start event on this revision. | +| `Mcp-Method` / `Mcp-Name` headers | Not read. | +| `clientCapabilities` in `_meta` | Not captured. `clientInfo` and protocol version are. | + +Task-based tools and multi-round-trip tools can produce misleading counts and durations. Check these limitations before using their dashboard metrics. + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/start-here.md b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/start-here.md new file mode 100644 index 000000000..cc0a64749 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/start-here.md @@ -0,0 +1,267 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Getting started with MCP Analytics + +**MCP Analytics is in beta** + +`@posthog/mcp` is published as a pre-1.0 release on npm. We're building it in public, so the event shape, options, and tracing behavior may still change before `1.0`. Pin a specific version and don't depend on it for production reporting yet. + +## Add MCP Analytics to your server + +MCP Analytics shows how agents use your server. Add one wrapper call to capture: + +- 🛠️ Every tool call (parameters, response, duration, errors) +- 🎯 Agent intent – the *why* behind each call, not just the *what* +- 🤖 The model from client metadata or the agent's self-report +- 🧭 Tool and resource discovery, plus resource reads without their bodies +- 🪪 The MCP client name and version +- 🧵 Sessions across calls when the client supplies correlation data +- 🚧 Capabilities the agent wished existed (with `reportMissing`) + +Use an existing [supported MCP server](/docs/mcp-analytics/installation.md#requirements) and a PostHog [project token](/docs/getting-started/project-token.md). Install the analytics package for your language: + +### TypeScript + +```bash +npm install @posthog/mcp posthog-node +``` + +### Python + +```bash +pip install posthog +``` + +Set `POSTHOG_PROJECT_TOKEN` in your environment. Call `instrument()` once with your existing `server`, before it accepts requests: + +### TypeScript + +```typescript +import { PostHog } from "posthog-node" +import { instrument } from "@posthog/mcp" + +const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", +}) + +const analytics = instrument(server, posthog) +``` + +### Python + +```python +import os +from posthog import Posthog +from posthog.mcp import instrument + +posthog = Posthog( + os.environ["POSTHOG_PROJECT_TOKEN"], + host="https://us.i.posthog.com", +) + +analytics = instrument(server, posthog) +``` + +For an EU project, use `https://eu.i.posthog.com` as the host. Keep your existing tool registration and server startup code. + +Intent, model capture, conversation IDs, and exception capture are on by default in both SDKs. Missing-capability reporting and agent feedback are opt-in. + +Flush queued events before shutdown. Follow the [TypeScript](/docs/mcp-analytics/installation.md#graceful-shutdown) or [Python](/docs/mcp-analytics/installation.md#flushing-on-exit) instructions for your server. + +Set up TypeScript with the wizard + +The wizard installs the package and configures `instrument()`. It also supports [LLM coding agents](/blog/envoy-wizard-llm-agent.md), such as Cursor and Bolt: + +`npx @posthog/wizard mcp-analytics` + +[Learn more](/wizard.md) + +[ + +Full installation guide + +](/docs/mcp-analytics/installation.md) + +## See your first events + +Run your MCP server. Connect an agent, such as Claude Desktop, Cursor, Codex, or your own client. PostHog receives `$mcp_tool_call` and `$mcp_tools_list` events when the agent calls tools and requests their listing. Clients on `2025-11-25` also produce `$mcp_initialize`. The handshake-free `2026-07-28` revision has no initialize event. + +Open the [activity feed](https://app.posthog.com/activity/explore) in your project. Filter for `event = $mcp_tool_call`. Each row represents a tool invocation and includes `$mcp_tool_name`, `$mcp_parameters`, `$mcp_response`, `$mcp_duration_ms`, and `$mcp_is_error`. + +![PostHog activity feed filtered to $mcp_tool_call events, with tool name, client, error state, and duration columns](https://res.cloudinary.com/dmukukwp6/image/upload/q_auto,f_auto/mcp_activity_feed_light_197cb57f3c.png) + +[ + +See every event the SDK emits + +](/docs/mcp-analytics/events.md) + +## Ship safely + +The SDK sanitizes each event and truncates it to fit ingestion limits. It removes media payloads and masks known credential patterns, sensitive keys, and credentials inside URLs. + +Add `beforeSend` to your existing `instrument()` call to inspect the final payload before the SDK sends it. Return the event to send it. Return `null` or `undefined` to discard it: + +TypeScript + +```typescript +instrument(server, posthog, { + beforeSend: (event) => { + if (event.event === "$exception") return null // drop exceptions + return event + }, +}) +``` + +[ + +Privacy & redaction + +](/docs/mcp-analytics/privacy.md) + +For Python, use the [equivalent `before_send` callback](/docs/mcp-analytics/privacy.md#python). + +## Compare tool quality by model + +[Model capture](/docs/mcp-analytics/installation.md#capture-the-calling-model) is on by default in both SDKs. Check `$mcp_tool_call` events for `$mcp_llm_model`. The source, `$mcp_llm_model_source`, is `"client_metadata"` or `"self_reported"`. + +Use this unverified client input to compare quality, latency, and errors across models, not for billing or security decisions. Some clients report an exact model, others a model family. Missing, blank, and `unknown` values are omitted. + +## Capture what the agent was trying to do + +**Intent** is the user goal that led the agent to call a tool. The SDK adds a `context` argument to compatible tool schemas and captures it as `$mcp_intent`. It removes the argument before your handler runs when it can confirm it injected the field. + +TypeScript + +```typescript +instrument(server, posthog, { + context: { + description: "Describe the user's underlying goal in one sentence — not the tool you're calling.", + }, +}) +``` + +For agents that ignore the schema hint (raw cURL clients, schema-blind crawlers), supply an `intentFallback`. The SDK calls it whenever no `context` argument was passed: + +TypeScript + +```typescript +instrument(server, posthog, { + intentFallback: (request) => { + const tool = request.params?.name + return tool ? `Invoking ${tool}` : null + }, +}) +``` + +[ + +Learn about intent capture + +](/docs/mcp-analytics/intent.md) + +## Build your first dashboard + +MCP events work with PostHog insights, dashboards, alerts, and SQL. The [MCP Analytics view](/docs/mcp-analytics.md) provides built-in views during the beta. Start with these four queries: + +- ### Top tools per server + + Which tools do agents call most often? + +- ### Error rate per tool + + Which tools fail most often? Use `$exception` events to investigate errors. + +- ### Intent samples by source + + How much of your traffic supplies explicit context vs falls back to `intentFallback`? + +- ### Advertised tools that never get called + + Join `$mcp_tools_list` with `$mcp_tool_call` to find tools that agents never call. + +The tool quality tab shows error rates and latency percentiles for each tool. Select a tool to inspect its calls: + +![MCP Analytics tool quality tab showing calls and errors, success rate, latency percentiles, and a per-tool table](https://res.cloudinary.com/dmukukwp6/image/upload/q_auto,f_auto/mcp_tool_quality_light_f91f27f6e1.png) + +[ + +Copy-paste queries + +](/docs/mcp-analytics/queries.md) + +## Identify the user behind the agent + +By default, each event uses an SDK-generated session ID. Add an `identify` callback to associate calls with users, person properties, and groups: + +TypeScript + +```typescript +import { instrument, getRequestHeaders } from "@posthog/mcp" + +instrument(server, posthog, { + identify: async (request, extra) => { + const token = getRequestHeaders(extra)?.["authorization"] + const user = token ? await resolveUserFromToken(token) : null + return user ? { distinctId: user.id, properties: { name: user.name } } : null + }, +}) +``` + +The SDK emits `$identify` when it observes a new identity for a session. PostHog uses this event to associate earlier anonymous activity. See the [identity merge caveats](/docs/mcp-analytics/identifying-users.md#identity-merges) for stateless servers. + +[ + +Identify users + +](/docs/mcp-analytics/identifying-users.md) + +## Find capability gaps with \`reportMissing\` + +Enable `reportMissing: true` to register the `get_more_tools` virtual tool. Agents can call it to report requests your server cannot satisfy. Use these reports to prioritize capabilities: + +TypeScript + +```typescript +instrument(server, posthog, { + reportMissing: true, +}) +``` + +SQL + +[Run in PostHog](https://us.posthog.com/sql?open_query=SELECT%0A++properties.%24mcp_intent+++++++AS+unmet_request%2C%0A++properties.%24mcp_client_name++AS+client%2C%0A++count%28%29++++++++++++++++++++++AS+times_asked%0AFROM+events%0AWHERE+event+%3D+'%24mcp_missing_capability'%0A++AND+timestamp+%3E+now%28%29+-+INTERVAL+30+DAY%0AGROUP+BY+unmet_request%2C+client%0AORDER+BY+times_asked+DESC) + +```sql +SELECT + properties.$mcp_intent AS unmet_request, + properties.$mcp_client_name AS client, + count() AS times_asked +FROM events +WHERE event = '$mcp_missing_capability' + AND timestamp > now() - INTERVAL 30 DAY +GROUP BY unmet_request, client +ORDER BY times_asked DESC +``` + +[ + +Track missing capabilities + +](/docs/mcp-analytics/missing-capability.md) + +1/8 + +[**Add MCP Analytics to your server** ***Required***](#quest-item-add-mcp-analytics-to-your-server)[**See your first events** ***Required***](#quest-item-see-your-first-events)[**Ship safely** ***Required***](#quest-item-ship-safely)[**Compare tool quality by model** ***Recommended***](#quest-item-compare-tool-quality-by-model)[**Capture what the agent was trying to do** ***Recommended***](#quest-item-capture-what-the-agent-was-trying-to-do)[**Build your first dashboard** ***Recommended***](#quest-item-build-your-first-dashboard)[**Identify the user behind the agent** ***Recommended***](#quest-item-identify-the-user-behind-the-agent)[**Find capability gaps with \`reportMissing\`** ***Recommended***](#quest-item-find-capability-gaps-with-reportmissing) + +**Add MCP Analytics to your server** + +***Required*** + +### Still have questions? + +Ask PostHog AI + +### Was this page useful? + +HelpfulCould be better \ No newline at end of file diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/.gitignore b/apps/mcp-analytics/custom-dispatcher/hono-server/.gitignore new file mode 100644 index 000000000..4c49bd78f --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.gitignore @@ -0,0 +1 @@ +.env diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/package-lock.json b/apps/mcp-analytics/custom-dispatcher/hono-server/package-lock.json new file mode 100644 index 000000000..f950d4bc7 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/package-lock.json @@ -0,0 +1,635 @@ +{ + "name": "wb-mcp-hono-dispatcher", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wb-mcp-hono-dispatcher", + "version": "0.0.0", + "dependencies": { + "@hono/node-server": "^1.13.7", + "@posthog/mcp": "0.18.0", + "hono": "^4.6.14", + "posthog-node": "5.53.0" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@posthog/core": { + "version": "1.55.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.2.tgz", + "integrity": "sha512-2mYxGbDmTLUs096VbcZqSWo0lrI48/BrX65r54NynxxFq409BOx9w73HxOAtOKZ1vblkdV6/GxQTe7KDcIQidg==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.412.4" + } + }, + "node_modules/@posthog/mcp": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@posthog/mcp/-/mcp-0.18.0.tgz", + "integrity": "sha512-91A3jH0jpztnCKl92uksyhg+59dfp2VM2D/VID10Gp3yqXxgB8QZ/PjAwyRL7M3aLdaFTvr74gtNh0xtdI0rjA==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.55.1" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.26.0", + "@modelcontextprotocol/server": ">=2.0.0", + "posthog-node": "^5.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, + "@modelcontextprotocol/server": { + "optional": true + } + } + }, + "node_modules/@posthog/types": { + "version": "1.412.4", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.412.4.tgz", + "integrity": "sha512-Q7lV9O9TbLngjOYw1ucm3bS3tn48xb1B4ZQdDy8gv7yfKe99hCTeWYG4xZ36nJM80VxpTF9TriiJt5hngDOkBg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/posthog-node": { + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.53.0.tgz", + "integrity": "sha512-Ug0Jx80uRZzbC0cRbL1n224Q/hJRKfvkHMJXhuXfo2is8tdbsvA8T7cOvPBpgclS5u+qN+Whw0eMIGhiAK7e/Q==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.55.2" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/package.json b/apps/mcp-analytics/custom-dispatcher/hono-server/package.json index 48d679e66..043d94739 100644 --- a/apps/mcp-analytics/custom-dispatcher/hono-server/package.json +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/package.json @@ -10,7 +10,9 @@ }, "dependencies": { "@hono/node-server": "^1.13.7", - "hono": "^4.6.14" + "@posthog/mcp": "0.18.0", + "hono": "^4.6.14", + "posthog-node": "5.53.0" }, "devDependencies": { "tsx": "^4.19.2", diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/posthog-mcp-analytics-report.md b/apps/mcp-analytics/custom-dispatcher/hono-server/posthog-mcp-analytics-report.md new file mode 100644 index 000000000..e2b604844 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/posthog-mcp-analytics-report.md @@ -0,0 +1,38 @@ +# PostHog MCP Analytics Setup Report + +## Changes made + +- Detected a TypeScript Hono custom MCP dispatcher and used the custom-dispatcher (Path C) integration. +- Installed pinned `@posthog/mcp` `0.18.0` and `posthog-node` `5.53.0` dependencies. +- Created one module-scope `PostHogMCP` client configured from `POSTHOG_PROJECT_TOKEN` and `POSTHOG_HOST`. +- Prepared advertised tool schemas for intent and self-reported model capture, and stripped SDK-owned analytics arguments before invoking the existing tool handlers. +- Added `$mcp_initialize` capture for `2025-11-25` handshakes, `$mcp_tools_list` capture for listings, and `$mcp_tool_call` capture for successful and failed calls. +- Included tool name, description, sanitized parameters/response, duration, error state, intent, model metadata, protocol revision, MCP session header, user agent, and vendor client header when available. +- Added graceful PostHog shutdown on `SIGTERM`. +- Stored the supplied project token and host in the local `.env` file without hardcoding either value in source. +- Created an [MCP Server Analytics dashboard](https://us.posthog.com/project/483112/dashboard/2128926) with tool-call volume, error-rate, and p95 latency insights. + +## Files modified or created + +- `src/index.ts` — initialized `PostHogMCP`, instrumented the dispatcher, and added shutdown handling. +- `package.json` — added `@posthog/mcp` and `posthog-node`. +- `package-lock.json` — recorded the installed dependency graph. +- `.env` — added `POSTHOG_PROJECT_TOKEN` and `POSTHOG_HOST`. +- `posthog-mcp-analytics-report.md` — this report. + +## Verification + +- `npm run build` completed successfully (`tsc --noEmit`). +- Confirmed `tools/list` preparation advertises required `context` and `llm_model` fields. +- Confirmed `prepareToolCall` removes SDK-owned analytics fields before `runTool` receives arguments. +- Confirmed non-empty, non-`unknown` model values are captured as `$mcp_llm_model` with `$mcp_llm_model_source` set to `self_reported` (or `client_metadata` when recognized metadata is available). +- Dashboard queries are valid and ready to populate; no MCP analytics events had arrived during setup. + +## Manual next steps + +1. Configure `POSTHOG_PROJECT_TOKEN` and `POSTHOG_HOST` in the deployed server environment. The local `.env` file is for local configuration and must not be committed. +2. Ensure the process launcher loads the local `.env` file when running locally, or export the variables before `npm start`. +3. Restart the MCP server and send `tools/list` and `tools/call` requests. `$mcp_*` events will then appear in PostHog and populate the dashboard. +4. Keep `@posthog/mcp` pinned and review release notes before upgrades because the package is pre-1.0. + +See the [PostHog MCP analytics documentation](https://posthog.com/docs/mcp-analytics) for the event and dashboard reference. diff --git a/apps/mcp-analytics/custom-dispatcher/hono-server/src/index.ts b/apps/mcp-analytics/custom-dispatcher/hono-server/src/index.ts index 5d881a004..6cae07017 100644 --- a/apps/mcp-analytics/custom-dispatcher/hono-server/src/index.ts +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/src/index.ts @@ -1,6 +1,13 @@ import { serve } from '@hono/node-server' +import { PostHogMCP } from '@posthog/mcp' import { Hono } from 'hono' +const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN!, { + host: process.env.POSTHOG_HOST, + captureModel: true, + enableConversationId: false, +}) + // A custom MCP dispatcher: it speaks the MCP JSON-RPC protocol directly over // HTTP with no `@modelcontextprotocol/sdk` server object to wrap. The // `wizard mcp-analytics` flow should recognize this as path C and instrument it @@ -49,35 +56,95 @@ const app = new Hono() app.post('/mcp', async (c) => { const body = (await c.req.json()) as JsonRpcRequest + const initializeProtocolVersion = + body.method === 'initialize' && typeof body.params?.protocolVersion === 'string' + ? body.params.protocolVersion + : undefined + const analyticsContext = { + protocolVersion: c.req.header('MCP-Protocol-Version') ?? initializeProtocolVersion, + sessionId: c.req.header('Mcp-Session-Id'), + clientUserAgent: c.req.header('User-Agent'), + vendorClient: c.req.header('X-Anthropic-Client'), + } if (body.method === 'initialize') { - return c.json({ - jsonrpc: '2.0', - id: body.id, - result: { - protocolVersion: '2024-11-05', - capabilities: { tools: {} }, - serverInfo: { name: 'workbench-hono-dispatcher', version: '1.0.0' }, - }, - }) + const result = { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'workbench-hono-dispatcher', version: '1.0.0' }, + } + if (initializeProtocolVersion === '2025-11-25') { + const clientInfo = body.params?.clientInfo as Record | undefined + posthog.captureInitialize({ + ...analyticsContext, + clientName: typeof clientInfo?.name === 'string' ? clientInfo.name : undefined, + clientVersion: typeof clientInfo?.version === 'string' ? clientInfo.version : undefined, + parameters: body.params, + response: result, + }) + } + return c.json({ jsonrpc: '2.0', id: body.id, result }) } if (body.method === 'tools/list') { - return c.json({ jsonrpc: '2.0', id: body.id, result: { tools: TOOLS } }) + const tools = posthog.prepareToolList(TOOLS) + const result = { tools } + posthog.captureToolsList({ + ...analyticsContext, + toolNames: tools.map((tool) => tool.name), + parameters: body.params, + response: result, + isError: false, + }) + return c.json({ jsonrpc: '2.0', id: body.id, result }) } if (body.method === 'tools/call') { const params = body.params ?? {} const name = String(params.name) const args = (params.arguments as Record) ?? {} + const originalTool = TOOLS.find((tool) => tool.name === name) + const preparedCall = posthog.prepareToolCall(name, args, { + originalTool, + requestMeta: params._meta as Record | undefined, + sessionId: analyticsContext.sessionId, + }) + const startedAt = Date.now() try { - return c.json({ jsonrpc: '2.0', id: body.id, result: runTool(name, args) }) + const result = runTool(name, preparedCall.args ?? {}) + posthog.captureToolCall({ + ...analyticsContext, + sessionId: preparedCall.sessionId, + toolName: name, + toolDescription: originalTool?.description, + parameters: preparedCall.args, + response: result, + durationMs: Date.now() - startedAt, + isError: false, + intent: preparedCall.intent, + intentSource: preparedCall.intentSource, + llmModel: preparedCall.llmModel, + llmModelSource: preparedCall.llmModelSource, + }) + return c.json({ jsonrpc: '2.0', id: body.id, result }) } catch (err) { - return c.json({ - jsonrpc: '2.0', - id: body.id, - result: { isError: true, content: [{ type: 'text', text: String(err) }] }, + const result = { isError: true, content: [{ type: 'text', text: String(err) }] } + posthog.captureToolCall({ + ...analyticsContext, + sessionId: preparedCall.sessionId, + toolName: name, + toolDescription: originalTool?.description, + parameters: preparedCall.args, + response: result, + durationMs: Date.now() - startedAt, + isError: true, + error: err, + intent: preparedCall.intent, + intentSource: preparedCall.intentSource, + llmModel: preparedCall.llmModel, + llmModelSource: preparedCall.llmModelSource, }) + return c.json({ jsonrpc: '2.0', id: body.id, result }) } } @@ -89,3 +156,8 @@ app.post('/mcp', async (c) => { }) serve({ fetch: app.fetch, port: 3000 }) + +process.on('SIGTERM', async () => { + await posthog.shutdown() + process.exit(0) +})