From 63b6e66542a627d9af40f24870481682f6542fab Mon Sep 17 00:00:00 2001
From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 23:30:38 +0000
Subject: [PATCH 1/2] docs(mcp-analytics): document the bring-your-own-SDK
install path
MCP Analytics gates on the event name and on $mcp_source, never on the
sending library, so a server in any language can populate the product.
The docs only described Node and Python, and the escape hatch for
hand-rolled dispatchers (PostHogMCP) exists in those two languages only.
Adds an "Any other language" section to the custom servers page with the
wire contract, a worked Elixir example, and the work the wrapping SDKs
do that a raw capture call does not. Links it from the installation
requirements and the event reference.
Generated-By: PostHog Desktop
Task-Id: ef53c211-3c99-4520-a0f8-052b0a4007de
---
.../docs/mcp-analytics/custom-servers.mdx | 100 +++++++++++++++++-
contents/docs/mcp-analytics/events.mdx | 2 +-
contents/docs/mcp-analytics/installation.mdx | 2 +
3 files changed, 102 insertions(+), 2 deletions(-)
diff --git a/contents/docs/mcp-analytics/custom-servers.mdx b/contents/docs/mcp-analytics/custom-servers.mdx
index 905d5123350d..57476490bef5 100644
--- a/contents/docs/mcp-analytics/custom-servers.mdx
+++ b/contents/docs/mcp-analytics/custom-servers.mdx
@@ -14,8 +14,9 @@ For those servers, use **`PostHogMCP`** instead. It's a subclass of the [`postho
|---|---|
| Built on `@modelcontextprotocol/sdk`'s `Server` / `McpServer` | [`instrument(server, posthog, options?)`](/docs/mcp-analytics/installation) |
| A custom HTTP/Hono/edge dispatcher with no server object to wrap | `new PostHogMCP(apiKey, options?)` |
+| A server in a language with no MCP Analytics SDK | [Capture the events yourself](#any-other-language) |
-The examples below are TypeScript. Python has the same helper with the same methods in snake_case — skip to [Python](#python).
+The examples below are TypeScript. Python has the same helper with the same methods in snake_case, so skip to [Python](#python). Neither language yours? See [Any other language](#any-other-language).
## Set up
@@ -222,3 +223,100 @@ posthog.capture_tool_call(
```
The token is unsigned and carries only what the client volunteered at `initialize` — treat `$session_id` and `$mcp_client_*` as analytics labels, not authentication.
+
+## Any other language
+
+MCP Analytics reads events, not SDKs. Every query behind the product filters on the event name and on `$mcp_source`. None of them looks at which library sent the data. A server written in Elixir, Go, Ruby, Rust, or anything else populates the same dashboards as a TypeScript one, as long as it captures the canonical `$mcp_*` events.
+
+Use this path when your language has no MCP Analytics SDK. If you write TypeScript or Python, use `PostHogMCP` above instead. It builds the same events, and it does the sanitization, truncation, and `$exception` fan-out for you.
+
+You need a way to send a PostHog event with arbitrary properties. A [PostHog library](/docs/libraries) for your language is the easy route. A plain HTTPS POST to the [capture API](/docs/api/capture) works too.
+
+### The minimum event
+
+One event puts your server on the dashboards: `$mcp_tool_call`, carrying `$mcp_source`. A call without that marker is invisible to every MCP Analytics view.
+
+| Property | Type | Why you need it |
+|---|---|---|
+| `$mcp_source` | string | Must be the exact string `"posthog_mcp_analytics"`. Every query filters on it. |
+| `$mcp_tool_name` | string | The tool the agent called. Groups the per-tool views. |
+| `$mcp_server_name` | string | Your server's name. The server list skips events that leave it empty. |
+| `$mcp_is_error` | boolean | Drives every error rate. Send `false` on success, not nothing. |
+| `$mcp_duration_ms` | number | Wall-clock milliseconds. Drives the latency percentiles. |
+| `$session_id` | string | Groups one client's calls into a session. Use a stable id per connection or per user. |
+
+Add the rest as you go. Each one turns on a view rather than a column:
+
+| Property | What it unlocks |
+|---|---|
+| `$mcp_tool_description` | The description the agent read, so you can see whether a rewrite changed behavior. |
+| `$mcp_parameters`, `$mcp_response` | Per-call inspection and response-size analysis. Redact these yourself. |
+| `$mcp_error_type`, `$mcp_error_message` | Failure buckets with a cause, instead of a count of empty rows. |
+| `$mcp_intent`, `$mcp_intent_source` | [Agent intent](/docs/mcp-analytics/intent) and intent clustering. |
+| `$mcp_client_name`, `$mcp_client_user_agent`, `$mcp_vendor_client` | The [harness breakdown](/docs/mcp-analytics/events#how-the-harness-label-is-resolved). Without them every call reads "Other". |
+| `$mcp_protocol_version` | Spec-revision adoption, and error rate broken down by revision. |
+
+The [event reference](/docs/mcp-analytics/events) documents every property and the other events you can send: `$mcp_tools_list` for advertised-but-never-called tools, `$mcp_initialize` for a `2025-11-25` handshake, and `$mcp_missing_capability` for gaps the agent reports.
+
+### Example: Elixir
+
+[`posthog`](/docs/libraries/elixir) captures any event with any properties, so a hand-rolled dispatcher needs no new dependency:
+
+```elixir
+defmodule MyServer.MCPAnalytics do
+ @server_name "my-elixir-server"
+ @server_version "1.0.0"
+
+ def tool_call(tool_name, prepared, result, duration_ms, req) do
+ PostHog.capture("$mcp_tool_call", %{
+ distinct_id: req.user_id,
+ "$session_id": req.session_id,
+ "$mcp_source": "posthog_mcp_analytics",
+ "$mcp_server_name": @server_name,
+ "$mcp_server_version": @server_version,
+ "$mcp_tool_name": tool_name,
+ "$mcp_parameters": redact(prepared.args),
+ "$mcp_response": redact(result),
+ "$mcp_duration_ms": duration_ms,
+ "$mcp_is_error": false,
+ "$mcp_intent": prepared.intent,
+ "$mcp_intent_source": "context_parameter",
+ "$mcp_protocol_version": req.protocol_version,
+ "$mcp_client_name": req.client_name,
+ "$mcp_client_user_agent": req.user_agent
+ })
+ end
+
+ def tool_error(tool_name, duration_ms, req, exception) do
+ PostHog.capture("$mcp_tool_call", %{
+ distinct_id: req.user_id,
+ "$session_id": req.session_id,
+ "$mcp_source": "posthog_mcp_analytics",
+ "$mcp_server_name": @server_name,
+ "$mcp_tool_name": tool_name,
+ "$mcp_duration_ms": duration_ms,
+ "$mcp_is_error": true,
+ "$mcp_error_type": "timeout",
+ "$mcp_error_message": Exception.message(exception)
+ })
+ end
+end
+```
+
+`PostHog.capture/2` reads `distinct_id` out of the properties map, so anonymous traffic can leave it out.
+
+### What you take on
+
+The wrapping SDKs do work that a raw capture call does not:
+
+- **Redaction and truncation.** `$mcp_parameters` and `$mcp_response` carry whatever your tools received and returned, including credentials an agent pasted into an argument. Strip them before you capture, and cap the size. Read [Privacy](/docs/mcp-analytics/privacy) for what the SDKs remove.
+- **Intent capture.** Add a required `context` string argument to each tool schema you advertise, remove it from the arguments before the tool runs, and send it as `$mcp_intent` with `$mcp_intent_source` set to `"context_parameter"`.
+- **Sessions.** Nothing derives `$session_id` for you. Use the transport session on `2025-11-25`, or the authenticated user id on a stateless server.
+- **Error tracking.** The `$exception` sibling event is not automatic. Report the failure through your language's [error tracking](/docs/error-tracking) integration if you want the stack trace grouped as an issue.
+- **Model capture.** Leave `$mcp_llm_model` alone. It is only trustworthy when an SDK owns the injected `llm_model` argument.
+
+
+
+Events are the contract, and it is stable, so a hand-rolled server keeps working. If you would rather have a real SDK for your language, open an issue on the [PostHog library](/docs/libraries) you use and describe the server you run.
+
+
diff --git a/contents/docs/mcp-analytics/events.mdx b/contents/docs/mcp-analytics/events.mdx
index 3238e4c029a7..11e8ebbf93e4 100644
--- a/contents/docs/mcp-analytics/events.mdx
+++ b/contents/docs/mcp-analytics/events.mdx
@@ -4,7 +4,7 @@ title: Event and property reference
import CalloutBox from "components/Docs/CalloutBox";
-This page is the wire-level contract for the MCP Analytics SDKs. TypeScript-only properties are marked below. All property keys are prefixed with `$mcp_*` so they never collide with PostHog autocapture, Web analytics, or other product events.
+This page is the wire-level contract for the MCP Analytics SDKs, and for any server that captures these events itself. PostHog gates on the event name and on `$mcp_source`, never on which library sent the data, so a server in a language with no MCP Analytics SDK populates the same views. See [Any other language](/docs/mcp-analytics/custom-servers#any-other-language). TypeScript-only properties are marked below. All property keys are prefixed with `$mcp_*` so they never collide with PostHog autocapture, Web analytics, or other product events.
## Events
diff --git a/contents/docs/mcp-analytics/installation.mdx b/contents/docs/mcp-analytics/installation.mdx
index 5ef650dceb80..c8266d11bf80 100644
--- a/contents/docs/mcp-analytics/installation.mdx
+++ b/contents/docs/mcp-analytics/installation.mdx
@@ -17,6 +17,8 @@ import WizardCommand from 'components/WizardCommand'
- An MCP server built on either TypeScript SDK major — `@modelcontextprotocol/sdk` (v1) or `@modelcontextprotocol/{core,server,client}` (v2) — or either official Python MCP SDK major (`mcp>=1.26,<3`). jlowin's standalone `fastmcp` package is also supported. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2). (Running a custom dispatcher with no server object to wrap? See [Custom servers](/docs/mcp-analytics/custom-servers).)
- A PostHog [project token](/docs/getting-started/project-token) (`phc_…`)
+Neither runtime is a hard requirement. MCP Analytics reads the events your server sends, not the library that sent them, so a server in any language can populate the whole product. See [Any other language](/docs/mcp-analytics/custom-servers#any-other-language).
+
## 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) like Cursor and Bolt):
From a291d63ed653bee4f8417d7494556ce3df0ca0d6 Mon Sep 17 00:00:00 2001
From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 23:32:02 +0000
Subject: [PATCH 2/2] docs(mcp-analytics): trim the duplicated error branch in
the Elixir example
Generated-By: PostHog Desktop
Task-Id: ef53c211-3c99-4520-a0f8-052b0a4007de
---
.../docs/mcp-analytics/custom-servers.mdx | 24 +++++++------------
1 file changed, 9 insertions(+), 15 deletions(-)
diff --git a/contents/docs/mcp-analytics/custom-servers.mdx b/contents/docs/mcp-analytics/custom-servers.mdx
index 57476490bef5..c2f308659e5b 100644
--- a/contents/docs/mcp-analytics/custom-servers.mdx
+++ b/contents/docs/mcp-analytics/custom-servers.mdx
@@ -286,25 +286,19 @@ defmodule MyServer.MCPAnalytics do
"$mcp_client_user_agent": req.user_agent
})
end
-
- def tool_error(tool_name, duration_ms, req, exception) do
- PostHog.capture("$mcp_tool_call", %{
- distinct_id: req.user_id,
- "$session_id": req.session_id,
- "$mcp_source": "posthog_mcp_analytics",
- "$mcp_server_name": @server_name,
- "$mcp_tool_name": tool_name,
- "$mcp_duration_ms": duration_ms,
- "$mcp_is_error": true,
- "$mcp_error_type": "timeout",
- "$mcp_error_message": Exception.message(exception)
- })
- end
end
```
`PostHog.capture/2` reads `distinct_id` out of the properties map, so anonymous traffic can leave it out.
+A failed call is the same event with three keys changed. `$mcp_error_type` is your own low-cardinality label, so the failures view groups by cause:
+
+```elixir
+"$mcp_is_error": true,
+"$mcp_error_type": "timeout",
+"$mcp_error_message": Exception.message(exception)
+```
+
### What you take on
The wrapping SDKs do work that a raw capture call does not:
@@ -317,6 +311,6 @@ The wrapping SDKs do work that a raw capture call does not:
-Events are the contract, and it is stable, so a hand-rolled server keeps working. If you would rather have a real SDK for your language, open an issue on the [PostHog library](/docs/libraries) you use and describe the server you run.
+The events are the contract and they do not churn, so a hand-rolled server keeps working. If you would rather have a real SDK, open an issue on the [PostHog library](/docs/libraries) you use and describe the server you run.