Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 143 additions & 1 deletion contents/docs/mcp-analytics/custom-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Because there's no wrapped server, `PostHogMCP` does **not** manage these for yo
- **Identity caching / `$identify` dedupe** — pass `distinctId` (and optional `setProperties`) on each call.
- **Automatic intent and missing-capability handling** — use `prepareToolList()` and `prepareToolCall()`, then pass their output to the matching capture method.
- **Conversation IDs** — pass your own stable `sessionId`. The custom dispatcher helpers don't inject or echo `conversation_id`.
- **Model capture** — `captureModel` and the injected `llm_model` argument are currently available only through `instrument()` on a supported TypeScript server wrapper. Don't add `$mcp_llm_model` manually.
- **Model capture** — `captureModel` and the injected `llm_model` argument are currently available only through `instrument()` on a supported TypeScript server wrapper (and, experimentally, `capture_model:` on Ruby). Don't add `$mcp_llm_model` manually.

The `2026-07-28` revision has no initialize handshake or protocol session. Don't fabricate `$mcp_initialize`. Capture each request's `protocolVersion`, and pass an authenticated user id or your own stable session id when you need correlation across requests.

Expand Down Expand Up @@ -222,3 +222,145 @@ 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.

## Ruby

<CalloutBox icon="IconFlask" title="Ruby SDK is experimental and unsupported" type="caution">

`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental and not officially supported**: we don't provide support for it, and its method signatures may change in a minor release. See the [Ruby section of the installation docs](/docs/mcp-analytics/installation#ruby).

</CalloutBox>

### Do you need this?

If your Ruby server is an `MCP::Server` from the official `mcp` gem, you don't: add `PostHog::MCP.instrument(server, posthog)` and every request is captured. See [Ruby](/docs/mcp-analytics/installation#ruby).

You need this section only when your app speaks the MCP protocol itself: a Rack or Rails endpoint that parses the JSON-RPC body, routes `tools/list` and `tools/call` by hand, and never builds an `MCP::Server`. There's no object to wrap, so you tell PostHog what happened.

### Set up

`PostHog::MCP::Client` is a `PostHog::Client` subclass. Create it once, where you create your PostHog client today, and use it for everything else too (`capture`, feature flags, `flush`). It needs nothing beyond `posthog-ruby`, no `mcp` gem.

```ruby
require "posthog/mcp"

posthog = PostHog::MCP::Client.new(
api_key: "phc_your_project_api_key",
host: "https://us.i.posthog.com" # or https://eu.i.posthog.com
)
```

Two constructor options are specific to MCP: `missing_capability_tool_name:` renames the `get_more_tools` virtual tool, and `mcp_exception_autocapture: false` turns off the `$exception` sibling event for failed calls.

### Step 1: Prepare the tool list

When you answer `tools/list`, pass your tool descriptors (the Hashes you return on the wire, with `name` and `inputSchema`) through `prepare_tool_list`. It returns new Hashes; your originals are not changed.

```ruby
def handle_tools_list
posthog.prepare_tool_list(MY_TOOLS, report_missing: true)
end
```

This does two things:

- It adds a required `context` string argument to every tool. The agent fills it with why it is calling the tool, and you capture that as `$mcp_intent` in step 2. See [Capturing agent intent](/docs/mcp-analytics/intent). Pass `context: false` to skip it, or `context: { description: "..." }` to change the prompt.
- With `report_missing: true`, it appends the `get_more_tools` virtual tool, so agents can tell you which capability they were missing. See [Missing capabilities](/docs/mcp-analytics/missing-capability).

### Step 2: Prepare each tool call

When you answer `tools/call`, pass the tool name and the raw arguments through `prepare_tool_call` **before** you run the tool. It returns a `PreparedToolCall` with:

- `args`: the arguments without the injected `context`, so your tool never sees it
- `intent` and `intent_source`: the agent's stated reason, ready to capture
- `is_missing_capability`: `true` when the agent called the `get_more_tools` virtual tool

```ruby
prepared = posthog.prepare_tool_call(name, arguments)

if prepared.is_missing_capability
posthog.capture_missing_capability(context: prepared.intent, distinct_id: user_id)
return PostHog::MCP.get_more_tools_result # the canned reply the agent expects
end

result = run_tool(name, prepared.args)
```

### Step 3: Capture what happened

After the tool runs, call `capture_tool_call`. Pass the prepared intent, the arguments and result, the duration, and whether it failed. On a failure pass `error:` (the exception, or a message); PostHog fills `$mcp_error_type`, `$mcp_error_message`, and emits the `$exception` sibling.

```ruby
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
begin
result = run_tool(name, prepared.args)
rescue StandardError => e
posthog.capture_tool_call(
name,
intent: prepared.intent,
intent_source: prepared.intent_source,
parameters: prepared.args,
duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000,
is_error: true,
error: e,
distinct_id: user_id
)
raise
end

posthog.capture_tool_call(
name,
intent: prepared.intent,
intent_source: prepared.intent_source,
parameters: prepared.args,
response: result,
duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000,
distinct_id: user_id
)
```

The other capture methods follow the same shape and map to the events in the [event reference](/docs/mcp-analytics/events):

| Method | Event | Call it when |
|---|---|---|
| `capture_initialize(client_name:, client_version:, protocol_version:, ...)` | `$mcp_initialize` | You answer an `initialize` handshake. Read the client name and version from `params.clientInfo`. |
| `capture_tools_list(tool_names:, ...)` | `$mcp_tools_list` | You answer `tools/list`. Pass the names you advertised. |
| `capture_tool_call(name, ...)` | `$mcp_tool_call` (+ `$exception`) | A tool ran, succeeded or failed. |
| `capture_missing_capability(context:, ...)` | `$mcp_missing_capability` | The agent called the `get_more_tools` virtual tool. |

### Step 4: Attribute the caller

Every capture method accepts the same attribution keywords. Pass them on every call so the events for one client group together:

| Keyword | Becomes | Where to get it |
|---|---|---|
| `distinct_id:` | the event's person | Your auth (OAuth subject, API key owner). See [Identifying users](/docs/mcp-analytics/identifying-users). |
| `session_id:` | `$session_id` | The `Mcp-Session-Id` header (see below). Without it, events are anonymous per request. |
| `set_properties:` | `$set` | Person properties such as name or plan. |
| `groups:` | `$groups` | `{ organization: org_id }` for [group analytics](/docs/product-analytics/group-analytics). |
| `client_user_agent:`, `vendor_client:` | `$mcp_client_user_agent`, `$mcp_vendor_client` | The `User-Agent` and `X-Anthropic-Client` request headers. |
| `protocol_version:` | `$mcp_protocol_version` | `params.protocolVersion` on `initialize`, or the `MCP-Protocol-Version` header. |

For `session_id:`, the simplest option is `use PostHog::MCP::RackMiddleware` in your Rack stack. The middleware reads no request or response body – you already parse the JSON-RPC body yourself – so when you answer an accepted `initialize`, call the mint hook it leaves in `env["posthog_mcp.mint"]`:

```ruby
session = env["posthog_mcp.mint"]&.call(
client_name: params["clientInfo"]["name"],
client_version: params["clientInfo"]["version"],
protocol_version: params["protocolVersion"]
)

posthog.capture_initialize(
client_name: params["clientInfo"]["name"],
client_version: params["clientInfo"]["version"],
protocol_version: params["protocolVersion"],
session_id: session&.session_id,
distinct_id: user_id
)
```

The middleware attaches the minted token to the `Mcp-Session-Id` response header, clients replay it on every request, and on those requests the decoded token is waiting in `env["posthog_mcp.session"]` – so everywhere else you just pass `session_id: env["posthog_mcp.session"]&.session_id`. The hook is absent (`nil`) when the client already replayed a token, and returns `nil` for a `2026-07-28` client, which must not be answered with an `Mcp-Session-Id`. If you issue your own session header instead, pass `PostHog::MCP.derive_session_id_from_mcp_session(your_id)` so the same connection always maps to the same `$session_id`.

### Step 5: Flush

`PostHog::MCP::Client` batches events in the background like any `posthog-ruby` client. Call `posthog.flush` at the end of a short-lived request handler, or `posthog.shutdown` when the process stops.
112 changes: 110 additions & 2 deletions contents/docs/mcp-analytics/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import WizardCommand from 'components/WizardCommand'

## Requirements

- Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), or Python 3.10+ see [Python](#python) below
- 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).)
- Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), Python 3.10+ see [Python](#python) below – or Ruby 3.0+ – see [Ruby](#ruby) below (experimental and unsupported)
- An MCP server built on either TypeScript SDK major `@modelcontextprotocol/sdk` (v1) or `@modelcontextprotocol/{core,server,client}` (v2) either official Python MCP SDK major (`mcp>=1.26,<3`), or the official Ruby MCP SDK (`mcp` gem `>= 1.4`, experimental and unsupported). jlowin's standalone `fastmcp` package is also supported. See [MCP SDK v2](/docs/mcp-analytics/sdk-v2). (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_…`)

## AI wizard
Expand Down Expand Up @@ -380,6 +380,114 @@ Self-reported model capture is currently TypeScript-only. Python doesn't inject

</CalloutBox>

## Ruby

<CalloutBox icon="IconFlask" title="Ruby SDK is experimental and unsupported" type="caution">

`PostHog::MCP` is **experimental and not officially supported**. We don't provide support for it, and the MCP analytics team doesn't maintain it. Its API, its options, and the `$mcp_*` events it captures may change in a minor `posthog-ruby` release, and the gem logs a warning when you require it.

Try it, and report bugs or send patches to [posthog-ruby](https://github.com/PostHog/posthog-ruby/issues) – but don't build production reporting on it yet. For a supported SDK, use [TypeScript](#typescript) or [Python](#python).

</CalloutBox>

A Ruby SDK ships inside the [`posthog-ruby`](/docs/libraries/ruby) gem, so there's nothing extra to install:

```ruby
gem "posthog-ruby"
```

`PostHog::MCP.instrument` needs the official [Ruby MCP SDK](https://github.com/modelcontextprotocol/ruby-sdk) at runtime, but you already have it – you built your server with the `mcp` gem (`>= 1.4`) – so it's treated as a peer dependency rather than bundled. (`PostHog::MCP::Client` for custom dispatchers needs nothing beyond `posthog-ruby`.)

`PostHog::MCP.instrument(server, client, **options)` wraps an `MCP::Server`, whether you register tools as `MCP::Tool` classes or with `define_tool`, and works over the stdio and Streamable HTTP transports:

```ruby
require "posthog/mcp"

posthog = PostHog::Client.new(
api_key: "phc_your_project_api_key",
host: "https://us.i.posthog.com" # or https://eu.i.posthog.com
)
server = MCP::Server.new(name: "my-server", version: "1.0.0", tools: [SearchEvents])

# register more tools, prompts, and resources as usual...

analytics = PostHog::MCP.instrument(server, posthog)
```

If your app already uses [`posthog-rails`](/docs/libraries/ruby) and calls `PostHog.init`, leave the client out and the SDK picks up `PostHog.client` for you:

```ruby
PostHog::MCP.instrument(server)
```

Options are keyword arguments:

```ruby
PostHog::MCP.instrument(
server, posthog,
context: true, # inject the `context` intent argument (default)
report_missing: true, # advertise the get_more_tools virtual tool
enable_conversation_id: true, # stitch calls across reconnects and pods
identify: ->(request, extra) { { distinct_id: "user_123", properties: { plan: "pro" } } }
)
```

| Option | Type | Default | What it does |
|---|---|---|---|
| `context` | `Boolean \| { description: }` | `true` | Inject the `context` intent argument into every tool. |
| `report_missing` | `Boolean` | `false` | Advertise the `get_more_tools` virtual tool. |
| `missing_capability_tool_name` | `String` | `"get_more_tools"` | Rename the virtual tool registered by `report_missing`. |
| `enable_conversation_id` | `Boolean` | `false` | Inject an optional `conversation_id` argument to stitch calls. |
| `enable_exception_autocapture` | `Boolean` | `true` | Emit a `$exception` sibling on failed calls. |
| `capture_model` | `Boolean \| { description: }` | `false` | Inject `llm_model` and capture `$mcp_llm_model`. |
| `identify` | `(request, extra) -> Hash \| nil`, or a static Hash | – | Map a request to one of your users (`distinct_id:`, `properties:`, `groups:`). |
| `intent_fallback` | `(request, extra) -> String \| nil` | – | Provide intent when the agent didn't pass `context`. |
| `before_send` | `(payload) -> payload \| nil` | – | Inspect, modify, or drop each event before send. |
| `event_properties` | `(request, extra) -> Hash` | – | Properties merged onto every event. |
| `logger` | `->(message) { ... }` | no-op | STDIO-safe log sink. Never writes to stdout. |

The injected arguments are stripped before your tool's `call` receives its keywords, so a tool declared as `def self.call(query:, server_context:)` keeps working. A tool that declares `context` in its own `input_schema` keeps it.

Prompt and resource traffic is captured too, as `$mcp_prompt_get`, `$mcp_prompts_list`, `$mcp_resource_read`, and `$mcp_resources_list`.

### `$lib` on Ruby events

MCP events report `$lib: "posthog-ruby-mcp"` so you can tell them apart from the rest of your traffic. This is set per event: the client you pass in keeps its own `$lib` (`posthog-ruby` or `posthog-rails`) for everything else it sends, so instrumenting an MCP server inside a Rails app doesn't relabel the app's other events.

### Stateless and multi-pod servers

A stateless server keeps nothing between requests, often on a different pod each time. Left alone, every request becomes its own `$session_id`, and the client name and version (only sent at `initialize`) go missing from every event after the handshake.

The SDK handles this with no session store and no sticky routing. When `MCP::Server::Transports::StreamableHTTPTransport` runs with `stateless: true`, the SDK mints the `Mcp-Session-Id` response header at `initialize` as a token carrying the session ID and client identity. Clients replay that header on every request, so any pod reads the same values back. Nothing changes on the client side, and there's nothing to configure.

When you build the Rack app yourself, add the middleware once:

```ruby
use PostHog::MCP::RackMiddleware
```

It reads neither the request nor the response body: it publishes the request's headers to the instrumented server below it and carries back the token that server minted once the handshake succeeded. The decoded token is exposed to your app as `env["posthog_mcp.session"]`. (Dispatching MCP requests by hand, with no `MCP::Server` to wrap? Then you mint it yourself – see [Custom servers](/docs/mcp-analytics/custom-servers#step-4-attribute-the-caller).)

Stateful HTTP servers need nothing: the transport's own session ID is hashed deterministically, so a session survives restarts. [Conversation IDs](/docs/mcp-analytics/conversation-id) work too and need no middleware at all.

### Flushing on exit

Captured events go straight into the `posthog-ruby` client's queue, so there's nothing to drain besides the client itself. Call `posthog.flush` or `posthog.shutdown` when your process stops:

```ruby
at_exit { posthog.shutdown }
```

### Logging on stdio servers

A stdio MCP server owns `$stdout` for the protocol. The integration's own messages go only to the `logger:` you pass (nowhere by default); the experimental notice and misconfiguration warnings go to stderr. Point the core SDK's logger away from stdout too:

```ruby
PostHog::Logging.logger = Logger.new($stderr)
```

Dispatching MCP requests without an `MCP::Server`? See [Custom servers](/docs/mcp-analytics/custom-servers#ruby).

## Configuration

The `posthog` client is passed as the required second positional argument — not in this options object. `instrument()` accepts these options as an optional third argument:
Expand Down
19 changes: 19 additions & 0 deletions contents/docs/mcp-analytics/sdk-v2.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ def identify(request, extra):

[Sessions on `2026-07-28`](#sessions-on-2026-07-28) work as described below. `enable_conversation_id=True` provides a shared `$session_id` on that revision, and both PostHog SDKs derive the same session id from the same conversation handle.

## Ruby

The official Ruby MCP SDK has a single major that serves both revisions: the `initialize` handshake for `2025-11-25` and earlier, and the per-request `_meta` envelope for `2026-07-28`. The experimental, [unsupported](/docs/mcp-analytics/installation#ruby) Ruby SDK handles both with the same call:

```ruby
server = MCP::Server.new(name: "my-server", version: "1.0.0", tools: [SearchEvents])
PostHog::MCP.instrument(server, posthog)
```

On `2026-07-28` requests it reads the client name, version, and protocol version off the envelope for every event and never answers with an `Mcp-Session-Id`, so [conversation IDs](/docs/mcp-analytics/conversation-id) or [`identify`](/docs/mcp-analytics/identifying-users) are what correlate calls there.

Callbacks (`identify`, `intent_fallback`, `event_properties`) receive `extra["headers"]`, a lowercase-keyed Hash on HTTP transports and an empty Hash on stdio, so header reads look the same on either revision:

```ruby
identify = lambda do |_request, extra|
resolve_user(extra["headers"]["authorization"])
end
```

## Sessions on `2026-07-28`

That revision removed the `initialize` handshake and the `Mcp-Session-Id` header, so the [stateless session token](/docs/mcp-analytics/installation#stateless-and-multi-pod-servers) doesn't apply to it — and left alone, **every request becomes its own `$session_id`**:
Expand Down
Loading