From f713fa24ad92ceaae957aaba9e49a5b6d16c8ea0 Mon Sep 17 00:00:00 2001 From: "wizard-ci-bot[bot]" <254716194+wizard-ci-bot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:20:38 +0000 Subject: [PATCH] wizard-ci: custom-dispatcher/hono-server --- .../skills/mcp-analytics/.posthog-wizard | 0 .../.claude/skills/mcp-analytics/SKILL.md | 381 +++++++++++ .../mcp-analytics/references/COMMANDMENTS.md | 12 + .../references/conversation-id.md | 110 +++ .../mcp-analytics/references/custom-events.md | 96 +++ .../references/custom-servers.md | 193 ++++++ .../skills/mcp-analytics/references/events.md | 116 ++++ .../references/identifying-users.md | 115 ++++ .../mcp-analytics/references/installation.md | 433 ++++++++++++ .../skills/mcp-analytics/references/intent.md | 190 ++++++ .../skills/mcp-analytics/references/sdk-v2.md | 125 ++++ .../mcp-analytics/references/start-here.md | 222 ++++++ .../custom-dispatcher/hono-server/.gitignore | 1 + .../hono-server/package-lock.json | 635 ++++++++++++++++++ .../hono-server/package.json | 4 +- .../posthog-mcp-analytics-report.md | 44 ++ .../hono-server/src/index.ts | 49 +- 17 files changed, 2724 insertions(+), 2 deletions(-) create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/.posthog-wizard create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/SKILL.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/COMMANDMENTS.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/conversation-id.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-events.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-servers.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/events.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/identifying-users.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/installation.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/intent.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/sdk-v2.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/start-here.md create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/.gitignore create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/package-lock.json create mode 100644 apps/mcp-analytics/custom-dispatcher/hono-server/posthog-mcp-analytics-report.md 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..1ad7c0771 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/SKILL.md @@ -0,0 +1,381 @@ +--- +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 wrappers. 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 wrappers 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`; 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, +}) + +// 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() +// ...run the tool... +posthog.captureToolCall({ + toolName: request.params.name, + parameters: request.params.arguments, + response: result, + durationMs: Date.now() - start, + isError: false, + distinctId, // who the request is from, if known + sessionId, // your transport/session id, if you have one + protocolVersion, // read this from the current request +}) +``` + +Resolve `distinctId` / `sessionId` from whatever auth/session the dispatcher already has; omit them rather than inventing values. On `2026-07-28`, there is no initialize handshake or protocol session: don't fabricate `$mcp_initialize`, and pass the protocol version on each capture. `captureModel` and conversation-id injection aren't available on the custom-dispatcher path, so don't add `$mcp_llm_model` manually. 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. On `2026-07-28`, don't call `capture_initialize`; pass the current request's `protocol_version` on each other capture. 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 a TypeScript wrapping 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. +- 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 - docs +- `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 - docs +- `references/intent.md` - Capturing agent intent - docs +- `references/identifying-users.md` - Identifying users - docs +- `references/conversation-id.md` - Conversation ids - docs +- `references/events.md` - Event and property reference - docs +- `references/custom-events.md` - Custom events and metadata - docs +- `references/start-here.md` - Getting started with mcp analytics - docs +- `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..2615976e7 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/conversation-id.md @@ -0,0 +1,110 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Conversation IDs - Docs + +Copy page + +# Conversation IDs - Docs + +A PostHog `$session_id` is per MCP connection — it rotates when the protocol session does. That's the right granularity for "which TCP/WebSocket connection is this?" but it can split a single user conversation into multiple sessions when the client reconnects. + +`$mcp_conversation_id` is an opt-in property that lets you stitch those calls together at the conversation level instead. + +**Opt-in, with caveats** + +Conversation IDs are off by default and rely on the agent cooperating. Read the caveats below before enabling — there's a visible side effect on tool responses, and the value is agent-controlled. + +## Enabling + +TypeScript + +PostHog AI + +```typescript +instrument(server, posthog, { + enableConversationId: true, +}) +``` + +With this on, the SDK does three things: + +1. **Injects an optional `conversation_id` argument** into every tool's JSON Schema, with a description telling the agent to reuse the value the server returns. +2. **Mints a UUID** when the agent calls a tool without `conversation_id`, and returns it on the tool's response as a `{"conversation_id":"…"}` text block — data, not an instruction. +3. **Captures the supplied or minted value** on every event as `$mcp_conversation_id`, distinct from `$session_id`. + +The agent's `conversation_id` (when present) always wins. The SDK only mints when the agent doesn't supply one. + +## How it lands in events + +PostHog AI + +``` +{ + event: "$mcp_tool_call", + properties: { + "$session_id": "ses_2a3f…", // MCP connection + "$mcp_conversation_id": "c_8b1d…", // logical conversation + "$mcp_tool_name": "search_events", + ... + } +} +``` + +A new connection (new `$session_id`) made by the same agent re-using the same `conversation_id` will share `$mcp_conversation_id`. You can group by it in HogQL to see the whole conversation: + +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) + +PostHog AI + +```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 `conversation_id` parameter can't be added to a schema built from `oneOf` / `allOf` / `anyOf` / `$ref`, or to a tool with no input schema. Those tools log a warning and get no handle, so their calls won't correlate. [`identify`](/docs/mcp-analytics/identifying-users.md) covers them. + +A client working from a **stale cached tool listing** won't know to send the parameter either — `ttlMs` caching on `tools/list` makes that more likely over time. + +**The handle is visible in tool output** + +It's returned as a `{"conversation_id":"…"}` text block, so consumers that surface raw tool-call content to end users will show that JSON. It's deliberately data rather than an instruction — an imperative sentence in tool output is indistinguishable from prompt injection, and hardened clients block it. + +**Agent-controlled values** + +When the agent supplies a `conversation_id`, the SDK accepts any non-empty string. You can bind it to your own session scheme (chat id, JWT `jti`, request id) by having the agent send that value, but nothing prevents a misbehaving client from sending arbitrary strings. Don't use `$mcp_conversation_id` as a security boundary. + +**It's not a session id** + +`$session_id` is what PostHog's session-level joins and identity resolution use. `$mcp_conversation_id` is purely a logical grouping label — handy for joins, useless for everything else. + +## When to skip this + +If your MCP server runs over a long-lived connection that already aligns with what you'd call a "conversation" — for example, a stdio server attached to a single Claude Desktop chat — `$session_id` is already doing the right thing. Leave `enableConversationId` off. + +Turn it on when: + +- The same logical conversation crosses connections (HTTP/SSE clients that reconnect). +- You want to correlate MCP events with a conversation id you already own elsewhere (chat platform, support ticket, JWT) and you're happy to plumb that id through the agent. + +### 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..7dd481203 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-events.md @@ -0,0 +1,96 @@ +> 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 - Docs + +Copy page + +# Custom events and metadata - Docs + +Sometimes the events the SDK emits out of the box aren't enough. You might want to attach metadata to every event, or emit a fully custom event for something that isn't a tool call. The SDK gives you two hooks for that, in order of increasing invasiveness. + +## `eventProperties` — metadata on every event + +Pass an `eventProperties` callback to attach extra properties to every event the SDK emits. The callback runs per request, so values can depend on the current call (request id, transport, headers, env, region, deploy SHA, etc). + +TypeScript + +PostHog AI + +```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 + +PostHog AI + +```json +{ + "event": "$mcp_tool_call", + "properties": { + "$mcp_tool_name": "search_events", + "$app_version": "a1b2c3d", + "$mcp_region": "iad", + "request_id": "req_…", + "…" + } +} +``` + +For a "stamp on everything" use case (the closest analogue to `posthog.register(...)` in other SDKs), just return constants from the callback. The callback is per-event rather than session-persistent, so the values can also vary per request if you need them to. + +For group analytics, return `groups` from your [`identify`](/docs/mcp-analytics/identifying-users.md) callback rather than hand-writing the `$groups` key — the SDK stamps `$groups` onto every event for the session for you. + +Returned values must be JSON-serializable. Errors thrown from your callback are swallowed and surfaced to your `logger` — they never interrupt tool execution. + +## `analytics.capture()` — emit an arbitrary event + +When the built-in events don't cover what you need — for example, recording a feedback signal from your own UI, or capturing a domain event that isn't an MCP request — use the `capture()` method on the handle returned by `instrument()`. It writes onto the same queue as everything else, so it inherits the SDK's sanitization, identity, `beforeSend`, and `eventProperties` logic. `capture()` returns a promise you can `await`. + +You name the event. It's sent verbatim — it's your event, so it is **not** `$`\-prefixed. + +TypeScript + +PostHog AI + +```typescript +const analytics = instrument(server, posthog) +await analytics.capture({ + event: "feedback_submitted", + properties: { rating: 5 }, +}) +``` + +What lands in PostHog: + +- One event under the verbatim `event` name you passed, with your `properties` merged in. +- The session id, identity, and any `eventProperties` callback still apply. + +`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 to a specific tool call (just that one) | Not directly supported — the callbacks run on every event. The SDK doesn't currently expose a per-call hook. | + +### 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..81bc3b610 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/custom-servers.md @@ -0,0 +1,193 @@ +> 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 - Docs + +Copy page + +# Instrumenting a custom server - Docs + +[`instrument()`](/docs/mcp-analytics/installation.md) works by wrapping a `@modelcontextprotocol/sdk` `Server` or `McpServer` — it patches that object's request handlers. But not every MCP server is built that way. If you run a **custom dispatcher** — a [Hono](https://hono.dev/) or Express HTTP handler, a Cloudflare Worker / Vercel edge function, or anything that speaks the MCP protocol without the SDK's server abstraction — there's no object for `instrument()` to wrap. + +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 — `capture`, `identify`, `flush`, `shutdown`, and feature flags all work unchanged — with `captureToolCall` and `captureInitialize` added on top. You resolve identity and context per request and call those methods yourself. They build the same canonical `$mcp_*` events as `instrument()` (same sanitization, truncation, and `$exception` fan-out) and hand them to the inherited `capture()`, so nothing downstream (insights, dashboards, error tracking) can tell the difference. + +## 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?) | + +## 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 + +PostHog AI + +```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 +}) +``` + +Because it *is* a `PostHog` client, every option and method you already know is available — including `beforeSend` (which runs on the MCP events too) and `enableExceptionAutocapture` (set it to `false` to stop errored tool calls from fanning out a `$exception`). The wrapping-path hooks (`identify`, `context`, `intentFallback`, `eventProperties`) don't apply here: there's no wrapped server to run them against, so you pass identity and properties on each call instead. + +## 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 + +PostHog AI + +```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) + groups: { organization: user.orgId }, // → $groups + properties: { $mcp_client_name: "claude-code" }, // any extra props, spread verbatim +}) +// On the initialize handshake: +posthog.captureInitialize({ + clientName: "claude-code", + clientVersion: "1.2.3", + 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 lands on a real person. 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). | +| 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; not retained on the stored event, so query them 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 and `enableExceptionAutocapture` isn't `false`, the `error` you pass becomes the `$exception` sibling event (if you don't pass one, a generic exception is synthesized from the tool name). + +**Analytics never breaks your request** + +`captureToolCall` and `captureInitialize` are fire-and-forget (they enqueue on the client, like `posthog.capture()`) and never throw — a failure to record analytics can't take down your tool. In serverless or edge environments, flush at the end of the invocation so queued events aren't dropped (see below). + +## What you don't get (vs `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. +- **The injected `context` argument, `intentFallback`, `reportMissing`, and `conversation_id`** — these patch tool schemas and request handlers, which only the wrapping path can do. + +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 + +PostHog AI + +```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 + +PostHog AI + +```python +from posthog.mcp import PostHogMCP, get_more_tools_result +posthog = PostHogMCP("phc_your_project_api_key", host="https://us.i.posthog.com") +# Decorate your tools/list response so agents state their intent (and, optionally, +# advertise the get_more_tools virtual tool): +tools = posthog.prepare_tool_list(my_tools, report_missing=True) +# On an inbound tools/call, pull the intent and strip the injected `context`: +prepared = posthog.prepare_tool_call(name, arguments) +if prepared.is_missing_capability: + posthog.capture_missing_capability(context=prepared.intent, distinct_id=user_id) + return get_more_tools_result() +result = run_tool(name, prepared.args) +# Capture the call (fire-and-forget, like posthog.capture): +posthog.capture_tool_call( + name, + intent=prepared.intent, + intent_source=prepared.intent_source, + parameters=arguments, + response=result, + duration_ms=elapsed_ms, + is_error=False, + distinct_id=user_id, + session_id=mcp_session_id, + groups={"organization": org_id}, +) +# On the handshake: +posthog.capture_initialize(client_name="claude-code", client_version="1.2.3", distinct_id=user_id) +posthog.flush() # PostHogMCP is a posthog client — flush/shutdown it yourself +``` + +`PostHogMCP(api_key, missing_capability_tool_name="get_more_tools", mcp_exception_autocapture=True, **posthog_kwargs)` accepts the standard `posthog` client kwargs (e.g. `host`). Set `mcp_exception_autocapture=False` to stop a failed tool call from emitting a `$exception` sibling. As in TypeScript, the wrapping-path hooks (`identify`, `context`, `intent_fallback`, `event_properties`) don't apply here — pass identity and properties on each `capture_*` call. + +The `capture_*` methods also take `error_type` (a low-cardinality failure label for `$mcp_error_type`), `client_user_agent`, and `vendor_client` — the same fields documented in the [event reference](/docs/mcp-analytics/events.md#core-properties). + +### Stateless / multi-pod dispatchers + +On a stateless deployment (a fresh server per request, often across pods) there's no connection to carry a session, so `$session_id` fragments and the client name/version — sent only at `initialize` — go missing from later requests. Add the mint middleware to your ASGI app once. It mints a self-encoded token onto the `Mcp-Session-Id` response header at `initialize` and decodes the client's replay on every later request, so every pod recovers the same values with no shared store: + +Python + +PostHog AI + +```python +from posthog.mcp import PostHogMcpStatelessSessionMiddleware, get_mcp_session +app.add_middleware(PostHogMcpStatelessSessionMiddleware) +# ...then in your request handler, feed the recovered session into each capture. +# The token carries the client identity too — pass it as $mcp_client_* properties +# (capture_tool_call takes session_id directly, client name/version via properties): +sess = get_mcp_session(request) # None until the client replays the token +posthog.capture_tool_call( + name, + session_id=sess.session_id if sess else None, + intent=prepared.intent, + properties={ + "$mcp_client_name": sess.client_name if sess else None, + "$mcp_client_version": sess.client_version if sess else None, + }, +) +``` + +The token is unsigned and carries only what the client volunteered at `initialize` — treat `$session_id` and `$mcp_client_*` as analytics labels, not authentication. + +### 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..ebf8d89cb --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/events.md @@ -0,0 +1,116 @@ +> 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 - Docs + +Copy page + +# Event and property reference - Docs + +This page is the wire-level contract for the `@posthog/mcp` SDK. Every event the SDK emits and every property name it uses is listed here. 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 | +| $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_initialize | Every client/server handshake | $mcp_client_name, $mcp_client_version, $mcp_server_name, $mcp_server_version, $mcp_protocol_version | +| $mcp_resources_list | Every resources/list request | — | +| $mcp_resource_read | Every resources/read request | $mcp_resource_name, $mcp_parameters, $mcp_response | +| $mcp_prompts_list | Every prompts/list request | — | +| $mcp_prompt_get | Every prompts/get request | $mcp_resource_name (the prompt name) | +| (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 errors (unless enableExceptionAutocapture: false) | $exception_list, $exception_level, plus the same $mcp_* context as the main event | + +## Core properties + +Present on most `mcp_*` events. + +| Wire key | Type | Source | +| --- | --- | --- | +| $session_id | string | The MCP session id (ses_<32-hex>), resolved per request, first match winning: (1) the agent's conversation_id argument, when [enableConversationId](/docs/mcp-analytics/conversation-id.md) is on — the only id that survives reconnects, restarts, and per-request server instances; (2) a session id the request itself carried, on the 2025-11-25 revision; (3) the id the server instance already holds, rotated after 30 minutes of inactivity. Steps 1 and 3 are what apply on the 2026-07-28 revision, which removed protocol-level sessions. Derivation is deterministic and unsalted, so two pods that share no state agree on the same session. | +| $mcp_source | string | Always "posthog_mcp_analytics". Use this to filter out non-MCP events when querying mixed projects. | +| $mcp_resource_name | string | Tool, resource, or prompt name | +| $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 | +| $mcp_is_error | boolean | True if the tool threw or returned isError: true | +| $mcp_error_type | string | Low-cardinality failure category, so you can break errors down by cause without joining to the $exception sibling. Defaults to the thrown error's type; pass an explicit label to categorize failures yourself (e.g. validation, permission, timeout, rate_limited). Only set when $mcp_is_error is true. | +| $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 calling client's raw User-Agent. clientInfo can't tell a vendor's products apart — Anthropic reports claude-code from the CLI, the Agent SDK, the VS Code extension and the desktop app alike — and the surface only shows up here (claude-code/2.1.0 (cli) vs (sdk-ts)). HTTP transports only; stdio and in-memory servers have no headers. | +| $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 MCP spec revision governing the request (e.g. 2025-11-25). Resolved per request through the v2 request envelope, then params._meta, then the MCP-Protocol-Version header, then the server's own accessors — so on 2026-07-28, where the version travels per request rather than being negotiated once, one $session_id can legitimately span more than one value. Use it to track spec-revision adoption, or to break error rate and latency down by version. | +| $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_parameters | object | Sanitized request arguments. Excludes the SDK-injected context and conversation_id arguments. | +| $mcp_response | object | Sanitized tool result | +| $mcp_conversation_id | string | Present when enableConversationId is on. See [Conversation IDs](/docs/mcp-analytics/conversation-id.md). | + +## 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`), and `$mcp_protocol_version` for context. + +**Symbolicating minified MCP servers** + +Stack frames from a bundled/minified MCP server symbolicate the same way as any other PostHog backend SDK — upload your source maps with the [PostHog CLI](/docs/error-tracking/upload-source-maps.md). Source-context lines and project-relative path rewriting (the optional Node frame modifiers) aren't applied by the MCP SDK yet. + +## 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) | + +`$set` is sent on the wire to update the person profile, but isn't retained 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 don't each mint 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 + +PostHog AI + +```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..99cadb9cd --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/identifying-users.md @@ -0,0 +1,115 @@ +> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt + +# Identifying users - Docs + +Copy page + +# Identifying users - Docs + +By default, every MCP event is attributed to the connection's session id (`ses_…`). That gives you per-session analytics, but you can't yet say "Alice from Acme is calling this tool 200 times a day" — because the SDK doesn't know who Alice is. + +The `identify` option lets you teach it. + +## 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"`. + +That means events emitted before `identify` returns a user are session-scoped; once it returns a user, subsequent events attribute to that user and PostHog's standard identity merge takes over for prior anonymous activity. + +## Anonymous sessions don't mint 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. + +## Wiring `identify` + +`identify` is an async callback that returns either a `UserIdentity` or `null`. The SDK calls it on each request and caches the result per session, so a stable identity isn't re-resolved on every tool call. + +TypeScript + +PostHog AI + +```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`, not by reaching into `extra` — the two MCP SDK majors put them in different places, and a hand-written read that works on one returns `undefined` on the other, sending every event out anonymous. 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 + +A single MCP session typically emits a handful of events before any auth handshake completes — for example, `$mcp_initialize` may fire before you've resolved the user. Those early events go out anonymous, attributed to the session id. + +When `identify` eventually returns a user, the SDK emits `$identify` with `$anon_distinct_id` set to the prior session id. PostHog's identity-merging logic then attributes the anonymous events to the identified user. From that point on, events for that session go out under `distinctId` directly. + +This is the same merge model the [Node SDK](/docs/libraries/node.md) uses — if you've configured Person profile mode or have other strong opinions on identity in your PostHog project, the same rules 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) + +PostHog AI + +```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..4403696c1 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/installation.md @@ -0,0 +1,433 @@ +> 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 - Docs + +Copy page + +# Installing the MCP Analytics SDK - Docs + +**Beta SDK** + +`@posthog/mcp` is in beta (pre-1.0). The API may still change – including breaking changes in minor `0.x` releases – until `v1`, so pin a version while we iterate. + +## Requirements + +- Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), or Python 3.10+ — see [Python](#python) below +- An MCP server built on the MCP TypeScript SDK — either major, `@modelcontextprotocol/sdk` (v1) or `@modelcontextprotocol/{core,server,client}` (v2) — or the Python MCP SDK, also either major (`mcp>=1.26,<3`). 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 API key](/docs/getting-started/project-token.md) (`phc_…`) + +## AI wizard + +The fastest way to get set up is our wizard, which installs the package, adds your `posthog-node` client, and wires up the `instrument()` call for you (it also works for [LLM coding agents](/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt): + +`npx @posthog/wizard mcp-analytics` + +[Learn more](/wizard.md) + +Prefer to set things up manually, or want to understand each piece? Follow the steps below. + +## Install + +Terminal + +PostHog AI + +```bash +npm install @posthog/mcp posthog-node +# or pnpm add @posthog/mcp posthog-node +# or yarn add @posthog/mcp posthog-node +``` + +You bring your own [`posthog-node`](/docs/libraries/node.md) client (the same pattern as [`@posthog/ai`](/docs/ai-engineering.md)) and pass it to `instrument()` as the required second argument. You own its lifecycle — call `posthog.shutdown()` or `posthog.flush()` yourself. + +## Wrap your server + +`instrument(server, posthog, options?)` is the only function you need to call. The `posthog` client is a required positional argument; `options` is optional. It returns an analytics handle (used for [custom events](/docs/mcp-analytics/custom-events.md)). It's idempotent per server — calling it twice on the same server logs a warning and returns early. + +### Low-level `Server` + +If you registered your tools against the raw protocol `Server` from `@modelcontextprotocol/sdk/server/index.js`: + +TypeScript + +PostHog AI + +```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` + +If you use the typed `McpServer` wrapper, pass it in directly — the SDK will unwrap it and also install a proxy on `_registeredTools`, so any tool you register *after* `instrument()` is also wrapped: + +PostHog AI + +### 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 +}) +``` + +Everything past this point — options, callbacks, events — is identical on both majors. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md) for the two places they differ. + +### Next.js / Vercel (`mcp-handler`) + +[`mcp-handler`](https://github.com/vercel/mcp-handler) gives you a standard `McpServer` in its setup callback, so you instrument it the same way — one line, before or after you register tools: + +TypeScript + +PostHog AI + +```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`'s streamable-HTTP transport is **stateless**: it spins up a fresh server per request and issues no `Mcp-Session-Id`, so there's no connection for the SDK to derive a shared `$session_id` from — left alone, every request lands in its own session. + +The robust way to group is **by user**. Pass [`identify`](/docs/mcp-analytics/identifying-users.md) and return a `distinctId` from your auth (e.g. the OAuth subject) — that sets `distinct_id`, so a person's calls group together no matter how many stateless requests they span, and it requires nothing from the client: + +TypeScript + +PostHog AI + +```typescript +instrument(server, posthog, { + identify: (request, extra) => ({ distinctId: getUserId(extra) }), +}) +``` + +For finer, per-conversation grouping you can also enable [`enableConversationId`](/docs/mcp-analytics/conversation-id.md): the SDK adds a `conversation_id` argument, generates one when the client doesn't send it, and asks the agent to echo it on later calls, correlating them via `$mcp_conversation_id`. It's **best-effort** — it works by appending a short instruction to the tool result, which a cooperative agent echoes but some clients ignore or treat as untrusted server content (the same wariness they apply to prompt injection). Use it when you control the client or that trade-off is acceptable; otherwise stick with `identify`. + +#### Flushing + +`posthog-node` batches events, and a serverless function can freeze before they send. Flush at the end of the invocation — `await posthog.flush()`, or `ctx.waitUntil(posthog.flush())` to keep the runtime alive until it completes. + +### NestJS (`@rekog/mcp-nest`) + +With [`@rekog/mcp-nest`](https://github.com/rekog-labs/MCP-Nest) you don't construct the server yourself — `McpModule.forRoot(...)` does, and you define tools with `@Tool()` decorators. Instrument it through the module's `serverMutator` hook using `instrumentMutator`, which returns the server for you: + +TypeScript + +PostHog AI + +```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)` is shorthand for `(server) => { instrument(server, posthog); return server }`. It returns the *server*, not the analytics handle, so it slots straight into `serverMutator`. The tools mcp-nest registers after the mutator runs are still captured. + +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 + +PostHog AI + +```typescript +serverMutator: (server) => { + const analytics = instrument(server, posthog) + // ...use `analytics.capture(...)` elsewhere... + return server +} +``` + +## Stateless and multi-pod servers + +A stateless server keeps nothing between requests — a fresh server instance each time, often on a different pod. Left alone, every request becomes its own `$session_id`, and `$mcp_client_name` / `$mcp_client_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. At `initialize` it mints the `Mcp-Session-Id` response header as a token carrying the session id and client metadata. Clients replay that header on every subsequent request, so any pod reads the same values back — nothing changes on the client side. + +**This applies to 2025-11-25 traffic** + +The `2026-07-28` revision has no `initialize` and no `Mcp-Session-Id`, so none of this section reaches it. See [Sessions on 2026-07-28](/docs/mcp-analytics/sdk-v2.md#sessions-on-2026-07-28). + +### Streamable HTTP needs `enableJsonResponse: true` + +Minting only reaches the wire in JSON mode. In SSE (streaming) mode, `StreamableHTTPServerTransport` builds the response headers *before* your `initialize` handler runs, so the minted header never lands and the SDK silently falls back to a session per request: + +TypeScript + +PostHog AI + +```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 + +PostHog AI + +```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 construct the transport for you and don't expose `enableJsonResponse`, and a client that ignores the header falls back to a generated session per request either way. In both cases, group by user with [`identify`](/docs/mcp-analytics/identifying-users.md) — `distinct_id` groups a person's calls however many requests they span, and it requires nothing from the client. [Conversation IDs](/docs/mcp-analytics/conversation-id.md) give finer, per-conversation grouping when the agent cooperates. + +## Python + +A Python SDK ships inside the [`posthog`](/docs/libraries/python.md) package (the same way [`posthog.ai`](/docs/ai-engineering.md) does), so there's nothing extra to install: + +Terminal + +PostHog AI + +```bash +pip install posthog +``` + +`instrument()` needs the MCP SDK at runtime, but you already have it — you built your server with `mcp` or `fastmcp`, so it's treated as a peer dependency rather than bundled. Both `mcp` majors are supported (`mcp>=1.26,<3`), detected at runtime. (`PostHogMCP` for custom dispatchers needs nothing beyond `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 + +PostHog AI + +```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 + +PostHog AI + +```python +from posthog.mcp import instrument +from posthog.mcp.types import MCPAnalyticsOptions, UserIdentity +instrument(server, posthog, MCPAnalyticsOptions( + context=True, # inject the `context` intent argument (default) + report_missing=True, # register the get_more_tools virtual tool + enable_conversation_id=True, # stitch calls across reconnects + 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 every tool. | +| 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 | False | Inject an optional conversation_id argument to stitch calls. | +| 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 + +Same problem as [above](#stateless-and-multi-pod-servers) — a stateless deployment fragments `$session_id` and loses the client name/version after `initialize`. Python fixes it with the same self-encoded session token (minted onto the `Mcp-Session-Id` header, replayed by the client), but from an ASGI layer, so there's **no `enableJsonResponse` caveat** — JSON and SSE both work. + +On a **FastMCP** server (official `mcp.server.fastmcp` or jlowin's `fastmcp` 2.0) it's zero-config: `instrument()` wraps the server's `streamable_http_app()` / `sse_app()` factories (which `run()` uses too), so just make the server stateless: + +Python + +PostHog AI + +```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 + +PostHog AI + +```python +from posthog.mcp import PostHogMcpStatelessSessionMiddleware +app.add_middleware(PostHogMcpStatelessSessionMiddleware) +``` + +### Flushing on exit + +The `posthog` client batches events asynchronously and you own its lifecycle. On the `instrument()` path, auto-captured events are scheduled in the background — `await analytics.flush()` waits for in-flight events, then `posthog.flush()` / `posthog.shutdown()` sends them. Call this from your shutdown/`SIGTERM` handler so trailing events aren't dropped (see [`examples/mcp_analytics_demo.py`](https://github.com/PostHog/posthog-python/blob/main/examples/mcp_analytics_demo.py) for a runnable end-to-end example): + +Python + +PostHog AI + +```python +analytics = instrument(server, posthog) +# ... serve ... +await analytics.flush() # drain in-flight auto-capture events +posthog.shutdown() # flush + stop the posthog client +``` + +No server object to wrap (a custom HTTP/edge dispatcher)? Use `PostHogMCP`, a `posthog` client subclass (needs nothing beyond `posthog` — no MCP SDK) with `capture_tool_call()`, `capture_initialize()`, `capture_tools_list()`, `capture_missing_capability()`, `prepare_tool_list()`, and `prepare_tool_call()` — the Python equivalent of [Custom servers](/docs/mcp-analytics/custom-servers.md). + +**Python SDK is beta** + +The Python SDK is in beta (pre-1.0); the API may still change before `v1`, and some TypeScript-only features may land first. It emits the identical `$mcp_*` events documented on the [events](/docs/mcp-analytics/events.md) page. + +It supports both `mcp` majors and the `2026-07-28` protocol revision. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md) for what that revision changes. + +## 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. Wire your own to surface 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 every tool schema. See [Capturing agent intent](/docs/mcp-analytics/intent.md). | +| 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 | false | Inject an optional conversation_id argument into every tool. 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). | + +## Graceful shutdown + +The `posthog-node` client queues and batches events asynchronously, and you own its lifecycle. Call `posthog.shutdown()` from your `SIGTERM` / `beforeExit` handler so in-flight events aren't dropped: + +TypeScript + +PostHog AI + +```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) +}) +``` + +If you only want to drain the queue without tearing the client down, call `posthog.flush()` instead. + +In serverless or edge environments where `SIGTERM` isn't reliable, flush explicitly at the end of each invocation — `await posthog.flush()`, or `ctx.waitUntil(posthog.flush())` on platforms that support it — rather than relying on a shutdown signal. + +## What happens after install + +As soon as the wrapper is in place, every MCP request handled by the server emits a PostHog event: + +- `$mcp_tool_call` per tool invocation +- `$mcp_tools_list` per `tools/list` response +- `$mcp_initialize` per client handshake +- `$mcp_resource_read`, `$mcp_resources_list`, `$mcp_prompt_get`, `$mcp_prompts_list` as applicable +- `$exception` whenever a tool throws or returns `isError: true` + +All events share a `$session_id` derived from the MCP protocol session, so the same connection always maps to the same PostHog session — including on stateless deployments, where the SDK carries the session across pods itself (see [Stateless and multi-pod servers](#stateless-and-multi-pod-servers)). 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..47f773e42 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/intent.md @@ -0,0 +1,190 @@ +> 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 - Docs + +Copy page + +# Capturing agent intent - Docs + +Knowing *what* a tool was called is one thing. Knowing *why* the agent called it is what makes MCP Analytics useful for product decisions. + +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 + +When `context: true` (the default), the SDK adds a required `context` string to every tool's JSON Schema. It strips that argument before your handler runs, so your tool implementation never sees it. + +What the agent sees in the schema: + +JSON + +PostHog AI + +```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 + +PostHog AI + +```typescript +server.tool("search_events", schema, async (args) => { + // args.context has been stripped — only your real arguments are here +}) +``` + +What lands in PostHog as `$mcp_intent`: + +PostHog AI + +``` +"Finding the last 10 pageviews for user alice@example.com to triage a drop in conversion" +``` + +### Customising 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 + +PostHog AI + +```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` if you don't want the SDK to touch your tool schemas at all. You'll lose the agent-supplied intent, and you'll need to rely on `intentFallback` (or accept events without `$mcp_intent`). + +## The `intentFallback` callback + +The `context` argument is *advertised* as required in JSON Schema but isn't enforced at the SDK validation layer. A client that ignores the schema hint (raw cURL, in-house agents, schema-blind crawlers) will still succeed — the call lands in PostHog with `$mcp_intent` empty. + +`intentFallback` is the escape hatch. The SDK calls it whenever no `context` argument is present, takes whatever non-empty string you return, and stamps it 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 + +The cheapest pattern — synchronous, runs on every uncontextualized call. Good default: + +TypeScript + +PostHog AI + +```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 + +PostHog AI + +```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 + +Possible, but think twice. This callback sits on the hot path of every uncontextualized tool call — every LLM round-trip you add here adds latency to the agent's response. If you do this, cache aggressively and budget for failures: + +TypeScript + +PostHog AI + +```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) + +PostHog AI + +```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 of your callers are ignoring the schema hint — that's a signal to either improve the `context.description` copy or invest in a better `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** + +`context` is advertised as required in JSON Schema, but the SDK does not re-validate against Zod. A client that ignores the schema hint can send `arguments: {}` and the call still succeeds — landing in PostHog with `$mcp_intent` empty. That's exactly why `intentFallback` exists. + +**Skip \`intentFallback\` for tight internal servers** + +For a single, well-behaved internal client, the fallback is dead code. Don't add it unless you actually expect callers to skip the `context` 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..abffdbe62 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/sdk-v2.md @@ -0,0 +1,125 @@ +> 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 - Docs + +Copy page + +# MCP SDK v2 - Docs + +The MCP TypeScript SDK has two majors. `@posthog/mcp` supports both, and detects which one you're on at runtime — neither is a dependency of the package. The Python MCP SDK made the same split; see [Python](#python) below. + +| 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 | + +Use `@posthog/mcp` 0.11.2 or later on v2. Earlier versions captured nothing, silently. + +## Setup + +Same call as v1. The only difference is where `McpServer` comes from, and that v2 registers tools with `registerTool()` instead of the removed `server.tool()`: + +TypeScript + +PostHog AI + +```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) => { /* ... */ }) +``` + +The low-level `Server` works the same way. If you previously called `instrument(server.server)` to get past the old compatibility check, you can go back to `instrument(server)`. + +## If your callbacks read headers, change them + +**This fails silently** + +v1 puts headers at `extra.requestInfo.headers`. v2 puts the request at `extra.http.req`, a WHATWG `Request` whose headers only answer to `.get()`. A v1-shaped read returns `undefined` on v2 — so `identify()` returns `null` and **every event goes out anonymous, with no error anywhere.** + +Use the exported helper in `identify`, `intentFallback`, `eventProperties` and `beforeSend`. It handles both majors and returns a plain lowercase-keyed object: + +TypeScript + +PostHog AI + +```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 `mcp` majors too (`mcp>=1.26,<3`), detected at runtime. On 2.x, `FastMCP` was renamed — same `instrument()` call: + +Python + +PostHog AI + +```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 path — nothing to change there. + +The header gotcha above applies in Python too: the request context reaches your callbacks in a different shape on each major. Use the exported helper in `identify`, `intent_fallback`, and `event_properties` — it returns a lowercase-keyed dict on HTTP transports, `None` on stdio, and never raises: + +Python + +PostHog AI + +```python +from posthog.mcp import get_request_headers +def identify(request, extra): + headers = get_request_headers(extra) or {} + return resolve_user(headers.get("authorization")) +``` + +[Sessions on `2026-07-28`](#sessions-on-2026-07-28) work as described below: `enable_conversation_id=True` is the only shared `$session_id` on that revision, and both SDKs derive the same session id from the same `conversation_id`, so a mixed TypeScript-and-Python fleet agrees on sessions. + +## 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 to it — and left alone, **every request becomes its own `$session_id`**: + +- **[`enableConversationId: true`](/docs/mcp-analytics/conversation-id.md)** — the only way to get a shared `$session_id` on this revision. The SDK injects a `conversation_id` parameter, mints one when the agent doesn't send it, and derives `$session_id` from it, so a conversation's calls land in one session. Off by default; turn it on if you want sessions. +- **[`identify`](/docs/mcp-analytics/identifying-users.md)** — attributes calls to a person via `distinct_id`. Worth wiring up either way, but note it groups by **user**, not by session, and doesn't give you a `$session_id`. + +Note that revision is a property of each **request**, not of your server: a v2 server serves `2025-11-25` traffic too, and most clients still negotiate it. + +**Missing client name on 2025-11-25 traffic?** + +On that revision the client sends its name and version only at `initialize`. If your server builds a fresh instance per request, the SDK bridges this with a session token — but the token only reaches the client if the transport writes response headers *after* your handler runs. `@rekog/mcp-nest` with `enableJsonResponse: true` does; `createMcpHandler`'s legacy path doesn't, so expect `$mcp_client_name` and `$mcp_client_version` to be absent there. `$mcp_protocol_version` still arrives. + +## 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. | + +The first two make numbers wrong rather than missing, so check them before trusting a dashboard for task-based or multi-round-trip tools. + +### 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..d720044b0 --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/.claude/skills/mcp-analytics/references/start-here.md @@ -0,0 +1,222 @@ +> 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 - Docs + +Copy page + +# Getting started with MCP Analytics - Docs + +**MCP Analytics is in beta** + +`@posthog/mcp` is published as a `0.1.x` release on npm. We're building it in public – 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 @posthog/mcp to your MCP server + +MCP Analytics gives you visibility into how AI agents actually use the MCP server you ship. With one wrapper call you can track: + +- 🛠️ Every tool call (parameters, response, duration, errors) +- 🎯 Agent intent – the *why* behind each call, not just the *what* +- 🧭 Every `tools/list` so you can compare advertised vs called +- 🪪 The MCP client name and version +- 🧵 The full session, end to end +- 🚧 Capabilities the agent wished existed (with `reportMissing`) + +The SDK supports any TypeScript MCP server, on [either major](/docs/mcp-analytics/sdk-v2.md) of the MCP SDK. The fastest way to get set up is our wizard, which installs the package and wires up `instrument()` for you (it also works for [LLM coding agents](/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt): + +`npx @posthog/wizard mcp-analytics` + +[Learn more](/wizard.md) + +Prefer to do it by hand? Install it from npm and call `instrument()` once at startup. You pass your `posthog-node` client as the required second argument: + +Terminal + +PostHog AI + +```bash +npm install @posthog/mcp posthog-node +``` + +TypeScript + +PostHog AI + +```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", +}) +instrument(server, posthog) +``` + +[Full installation guide](/docs/mcp-analytics/installation.md) + +## See your first events + +Run your MCP server and connect an agent to it (Claude Desktop, Cursor, Codex, or your own). Within seconds of the first tool call, PostHog will receive `$mcp_tool_call`, `$mcp_tools_list`, and `$mcp_initialize` events – all prefixed with `$mcp_*` so they never collide with anything else in your project. + +Open the [activity feed](https://us.posthog.com/project/2/activity) and filter for `event = $mcp_tool_call`. You should see one row per agent tool invocation, each with `$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)![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_dark_e795d95547.png) + +[See every event the SDK emits](/docs/mcp-analytics/events.md) + +## Capture what the agent was trying to do + +The single most useful signal in MCP Analytics is **intent**: the user goal that led the agent to call this tool. The SDK injects a required `context` argument into every tool's schema and captures it as `$mcp_intent`. Your tool implementation never sees it. + +TypeScript + +PostHog AI + +```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 + +PostHog AI + +```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 + +Every event is a normal PostHog event, so insights, dashboards, alerts, and SQL all work without further setup – and the [MCP Analytics view](/docs/mcp-analytics.md) (in beta) gives you the most useful cuts out of the box. The four queries we suggest building (or reading straight from the dashboard) first: + +- ### Top tools per server + + Where is your agent traffic concentrated? Which tools earn their keep? + +- ### Error rate per tool + + Which tools throw most often? Pair with `$exception` events to triage. + +- ### Intent samples by source + + How much of your traffic supplies explicit context vs falls back to `intentFallback`? + +- ### Advertised tools that never get called + + Find dead surface area by joining `$mcp_tools_list` against `$mcp_tool_call`. + +The tool quality tab surfaces error rate and latency percentiles per tool, with a row to drill into for any single tool: + +![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)![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_dark_add2659eaa.png) + +[Copy-paste queries](/docs/mcp-analytics/queries.md) + +## Identify the user behind the agent + +By default each event is attributed to the MCP connection's session ID. To attribute calls to a real user – for per-user retention, group analytics, and person properties – wire an `identify` callback: + +TypeScript + +PostHog AI + +```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 a `$identify` event the first time it sees a new identity for a session, and PostHog's standard merge takes care of attributing prior anonymous activity. + +[Identify users](/docs/mcp-analytics/identifying-users.md) + +## Find capability gaps with \`reportMissing\` + +The most actionable signal for an MCP server owner is *the agent wanted to do something I don't support*. Enable `reportMissing: true` and the SDK registers a `get_more_tools` virtual tool. When the agent invokes it, you get a queryable feed of unmet asks – straight into your roadmap. + +TypeScript + +PostHog AI + +```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) + +PostHog AI + +```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) + +## Ship safely + +The SDK runs every event through automatic sanitization (image/audio/binary stubs, sensitive-key masking like `authorization`, `cookie`, `password`, PostHog key patterns) and truncation to fit ingestion limits. For full control, add a `beforeSend` hook that runs on each built PostHog payload right before it's sent – mutate and return the event to send it, or return a nullish value to drop it. + +TypeScript + +PostHog AI + +```typescript +instrument(server, posthog, { + beforeSend: (event) => { + if (event.event === "$exception") return null // drop exceptions + return event + }, +}) +``` + +[Privacy & redaction](/docs/mcp-analytics/privacy.md) + +--- + +That's it. You're ready to ship `@posthog/mcp` to production agents – within the beta caveats above. + +[Install MCP Analytics](/docs/mcp-analytics/installation.md) + +1/7 + +[**Add @posthog/mcp to your MCP server** ***Required***](#quest-item-add-posthogmcp-to-your-mcp-server)[**See your first events** ***Required***](#quest-item-see-your-first-events)[**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)[**Ship safely** ***Required***](#quest-item-ship-safely) + +**Add @posthog/mcp to your MCP 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..aa32d4118 --- /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.12.0", + "hono": "^4.6.14", + "posthog-node": "^5.51.6" + }, + "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.50.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", + "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.407.1" + } + }, + "node_modules/@posthog/mcp": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@posthog/mcp/-/mcp-0.12.0.tgz", + "integrity": "sha512-OZX+WAEKHs6P9AAarL9RIaGmepUuT/eC30A+ZCHxbMlUXF3DlULn8KLB3rmIyzvxPRvqRPSnFfEdE4owi49Fxw==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.49.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.407.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz", + "integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==", + "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.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/posthog-node": { + "version": "5.51.6", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.51.6.tgz", + "integrity": "sha512-r+Ge3p0OnOcOWeTnvKZmAtDwECeIoyNTyxBxuZc8wM6RHCQ9j2Xrhogrf39HSq1Y2gIOpWLqIDRmWDcu7xmqcA==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.50.0" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "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..2c0aafb6d 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.12.0", + "hono": "^4.6.14", + "posthog-node": "^5.51.6" }, "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..c3f49a88a --- /dev/null +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/posthog-mcp-analytics-report.md @@ -0,0 +1,44 @@ +# PostHog MCP Analytics — Setup Report + +## Instrumentation path + +**Path C — custom dispatcher.** This server is a Hono HTTP handler that speaks the MCP JSON-RPC protocol directly with no `@modelcontextprotocol/sdk` server object to wrap. `instrument()` does not apply here; the `PostHogMCP` client is used instead. + +## What changed + +### Files modified + +| File | Change | +|------|--------| +| `src/index.ts` | Added `PostHogMCP` client (module-scope), `captureInitialize` on `initialize` requests, `captureToolCall` on every `tools/call` (success and error), and a `SIGTERM` handler that calls `posthog.shutdown()` before exiting. | +| `package.json` | Added `@posthog/mcp@^0.12.0` and `posthog-node@^5.51.6`. | + +### Files created + +| File | Purpose | +|------|---------| +| `.env` | Stores `POSTHOG_PROJECT_TOKEN` and `POSTHOG_HOST`. Git-ignored automatically. | + +## How events are captured + +- **`$mcp_initialize`** — fired when an MCP client sends the `initialize` handshake, capturing `$mcp_client_name`, `$mcp_client_version`, and `$mcp_protocol_version`. +- **`$mcp_tool_call`** — fired after every `tools/call` dispatch, capturing `$mcp_tool_name`, `$mcp_parameters`, `$mcp_response`, `$mcp_duration_ms`, and `$mcp_is_error`. A failing call also fans out a `$exception` event via `enableExceptionAutocapture`. + +Events appear in PostHog under **Activity → Live events** as soon as the server handles its first request. See the [MCP analytics dashboard and event reference](https://posthog.com/docs/mcp-analytics) to set up insights. + +## Environment variables + +| Variable | Value | +|----------|-------| +| `POSTHOG_PROJECT_TOKEN` | `phx_API_KEY_IS_HARDCODED` | +| `POSTHOG_HOST` | `https://us.i.posthog.com` | + +Both are written to `.env`. Load that file before starting the server (e.g. `dotenv -e .env -- npm start`, or configure your runtime to load it automatically). + +## Next steps + +1. Start the server: `npm start` (ensure `.env` is loaded). +2. Send a test request to `POST /mcp` with `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0"}}}`. +3. Check [PostHog Live Events](https://us.posthog.com/project/483112) for `$mcp_initialize` and `$mcp_tool_call` events. + +> **Note:** `@posthog/mcp` is pre-1.0 (beta). Pin the version in `package.json` and check the [changelog](https://github.com/PostHog/posthog-js/releases) before upgrading. 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..6f40c59f3 100644 --- a/apps/mcp-analytics/custom-dispatcher/hono-server/src/index.ts +++ b/apps/mcp-analytics/custom-dispatcher/hono-server/src/index.ts @@ -1,11 +1,29 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' +import { PostHogMCP } from '@posthog/mcp' // 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 // with `PostHogMCP` (captureToolCall / captureInitialize), not `instrument()`. +if (!process.env.POSTHOG_PROJECT_TOKEN) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'POSTHOG_PROJECT_TOKEN variable required by PostHog is missing or un-configured, ' + + 'this causes events to be silently missed. ' + + 'This error stops appearing once POSTHOG_PROJECT_TOKEN is configured' + ) + } +} + +const posthog = process.env.POSTHOG_PROJECT_TOKEN + ? new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN, { + host: process.env.POSTHOG_HOST, + enableExceptionAutocapture: true, + }) + : null + type JsonRpcRequest = { jsonrpc: '2.0' id: number | string | null @@ -51,6 +69,14 @@ app.post('/mcp', async (c) => { const body = (await c.req.json()) as JsonRpcRequest if (body.method === 'initialize') { + const params = (body.params ?? {}) as Record + const clientInfo = (params.clientInfo ?? {}) as Record + const protocolVersion = String(params.protocolVersion ?? '2024-11-05') + posthog?.captureInitialize({ + clientName: String(clientInfo.name ?? ''), + clientVersion: String(clientInfo.version ?? ''), + protocolVersion, + }) return c.json({ jsonrpc: '2.0', id: body.id, @@ -70,9 +96,25 @@ app.post('/mcp', async (c) => { const params = body.params ?? {} const name = String(params.name) const args = (params.arguments as Record) ?? {} + const start = Date.now() try { - return c.json({ jsonrpc: '2.0', id: body.id, result: runTool(name, args) }) + const result = runTool(name, args) + posthog?.captureToolCall({ + toolName: name, + parameters: args, + response: result, + durationMs: Date.now() - start, + isError: false, + }) + return c.json({ jsonrpc: '2.0', id: body.id, result }) } catch (err) { + posthog?.captureToolCall({ + toolName: name, + parameters: args, + durationMs: Date.now() - start, + isError: true, + error: err instanceof Error ? err : new Error(String(err)), + }) return c.json({ jsonrpc: '2.0', id: body.id, @@ -88,4 +130,9 @@ app.post('/mcp', async (c) => { }) }) +process.on('SIGTERM', async () => { + await posthog?.shutdown() + process.exit(0) +}) + serve({ fetch: app.fetch, port: 3000 })