From 3955fbcc14fa79fd5491e3e839221b142d4544b1 Mon Sep 17 00:00:00 2001 From: Rafa Audibert Date: Tue, 8 Sep 2026 23:22:20 -0300 Subject: [PATCH 1/4] docs(mcp-analytics): add experimental Ruby SDK sections Add `## Ruby` sections to the MCP analytics installation, custom servers, and MCP SDK v2 pages for the new experimental `PostHog::MCP` integration in posthog-ruby, each opening with an experimental callout, and list Ruby in the installation requirements. Co-Authored-By: Claude Fable 5.1 --- .../docs/mcp-analytics/custom-servers.mdx | 69 +++++++++++- contents/docs/mcp-analytics/installation.mdx | 106 +++++++++++++++++- contents/docs/mcp-analytics/sdk-v2.mdx | 19 ++++ 3 files changed, 191 insertions(+), 3 deletions(-) diff --git a/contents/docs/mcp-analytics/custom-servers.mdx b/contents/docs/mcp-analytics/custom-servers.mdx index 905d5123350d..217e1c4639fc 100644 --- a/contents/docs/mcp-analytics/custom-servers.mdx +++ b/contents/docs/mcp-analytics/custom-servers.mdx @@ -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. @@ -222,3 +222,70 @@ 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 + + + +`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental**: method signatures may change in a minor release. It needs nothing beyond `posthog-ruby` — no `mcp` gem. + + + +The Ruby SDK ships the same custom-dispatcher path as `PostHog::MCP::Client`, a subclass of the [`posthog-ruby`](/docs/libraries/ruby) client. Method names are snake_case and arguments are keywords: + +```ruby +require "posthog/mcp" + +posthog = PostHog::MCP::Client.new(api_key: "phc_your_project_api_key", host: "https://us.i.posthog.com") + +# Advertise your tools with the injected `context` intent argument (and, optionally, +# the get_more_tools virtual tool). Tools are `tools/list` entries (Hashes). +tools = posthog.prepare_tool_list(my_tools, report_missing: true) + +def handle_tools_call(request, name, arguments) + # Pull the agent's intent off the call and strip the injected `context`: + prepared = posthog.prepare_tool_call(name, arguments) + + # Pass these on every capture — see "Attributing the caller" above: + common = { + distinct_id: user_id, + session_id: mcp_session_id, + client_user_agent: request.get_header("HTTP_USER_AGENT"), + vendor_client: request.get_header("HTTP_X_ANTHROPIC_CLIENT"), + groups: { organization: org_id } + } + + if prepared.is_missing_capability + posthog.capture_missing_capability(context: prepared.intent, **common) + return PostHog::MCP.get_more_tools_result + end + + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + begin + result = run_tool(name, prepared.args) + rescue StandardError => e + posthog.capture_tool_call( + name, + parameters: prepared.args, + duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000, + is_error: true, + error: e, # → $mcp_error_message, $mcp_error_type, and the $exception sibling + **common + ) + 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, + **common + ) + result +end +``` + +`PostHog::MCP.encode_session_id` and `PostHog::MCP.decode_session_id` implement the `Mcp-Session-Id` token for your HTTP layer, or add `PostHog::MCP::RackMiddleware` to a Rack stack to mint and decode it for you. `PostHog::MCP::Client` is a `posthog-ruby` client, so flush it yourself with `posthog.flush` or `posthog.shutdown`. diff --git a/contents/docs/mcp-analytics/installation.mdx b/contents/docs/mcp-analytics/installation.mdx index 5ef650dceb80..532f5ef25c8a 100644 --- a/contents/docs/mcp-analytics/installation.mdx +++ b/contents/docs/mcp-analytics/installation.mdx @@ -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) +- 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). 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 @@ -380,6 +380,108 @@ Self-reported model capture is currently TypeScript-only. Python doesn't inject +## Ruby + + + +`PostHog::MCP` is **experimental**. 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 tell us what breaks, but don't build production reporting on it yet. + + + +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, the snake_case equivalent of the TypeScript options: + +```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. + +Ruby also captures `$mcp_prompt_get`, `$mcp_prompts_list`, `$mcp_resource_read`, and `$mcp_resources_list`, which the other SDKs reserve but don't emit yet. + +### `$lib` on Ruby events + +MCP events from Ruby report `$lib: "posthog-ruby-mcp"`. Unlike the Node and Python SDKs, 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 + +Same problem as [above](#stateless-and-multi-pod-servers). Ruby fixes it with the same self-encoded `Mcp-Session-Id` token, wired into `MCP::Server::Transports::StreamableHTTPTransport` automatically: a server running with `stateless: true` mints the token at `initialize`, clients replay it, and any pod recovers `$session_id` and the client identity from the header. There's **no `enable_json_response` caveat** — the Ruby transport always answers stateless requests with JSON. + +When you build the Rack app yourself, add the middleware once. The decoded token is exposed as `env["posthog_mcp.session"]`: + +```ruby +use PostHog::MCP::RackMiddleware +``` + +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) +``` + +No server object to wrap? Use `PostHog::MCP::Client`, a `PostHog::Client` subclass with `capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability`, `prepare_tool_list`, and `prepare_tool_call` — the Ruby equivalent of [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: diff --git a/contents/docs/mcp-analytics/sdk-v2.mdx b/contents/docs/mcp-analytics/sdk-v2.mdx index 3de63cffebeb..354651dd6474 100644 --- a/contents/docs/mcp-analytics/sdk-v2.mdx +++ b/contents/docs/mcp-analytics/sdk-v2.mdx @@ -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 [Ruby SDK](/docs/mcp-analytics/installation#ruby) 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`**: From 06616c3fb0ea7d084d460473590a9ace1f8c1b8b Mon Sep 17 00:00:00 2001 From: Rafa Audibert Date: Tue, 8 Sep 2026 23:30:50 -0300 Subject: [PATCH 2/4] docs(mcp-analytics): use en dashes and ID in the Ruby sections Vale style fixes limited to the newly added Ruby sections. Co-Authored-By: Claude Fable 5.1 --- .../docs/mcp-analytics/custom-servers.mdx | 2 +- contents/docs/mcp-analytics/installation.mdx | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/contents/docs/mcp-analytics/custom-servers.mdx b/contents/docs/mcp-analytics/custom-servers.mdx index 217e1c4639fc..c24f626c043a 100644 --- a/contents/docs/mcp-analytics/custom-servers.mdx +++ b/contents/docs/mcp-analytics/custom-servers.mdx @@ -227,7 +227,7 @@ The token is unsigned and carries only what the client volunteered at `initializ -`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental**: method signatures may change in a minor release. It needs nothing beyond `posthog-ruby` — no `mcp` gem. +`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental**: method signatures may change in a minor release. It needs nothing beyond `posthog-ruby` – no `mcp` gem. diff --git a/contents/docs/mcp-analytics/installation.mdx b/contents/docs/mcp-analytics/installation.mdx index 532f5ef25c8a..fc4d676c88b0 100644 --- a/contents/docs/mcp-analytics/installation.mdx +++ b/contents/docs/mcp-analytics/installation.mdx @@ -13,8 +13,8 @@ import WizardCommand from 'components/WizardCommand' ## Requirements -- Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), Python 3.10+ — see [Python](#python) below — or Ruby 3.0+ — see [Ruby](#ruby) below (experimental) -- 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). 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) +- 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). 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 @@ -394,7 +394,7 @@ A Ruby SDK ships inside the [`posthog-ruby`](/docs/libraries/ruby) gem, so there 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` 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: @@ -438,10 +438,10 @@ PostHog::MCP.instrument( | `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. | +| `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. @@ -454,7 +454,7 @@ MCP events from Ruby report `$lib: "posthog-ruby-mcp"`. Unlike the Node and Pyth ### Stateless and multi-pod servers -Same problem as [above](#stateless-and-multi-pod-servers). Ruby fixes it with the same self-encoded `Mcp-Session-Id` token, wired into `MCP::Server::Transports::StreamableHTTPTransport` automatically: a server running with `stateless: true` mints the token at `initialize`, clients replay it, and any pod recovers `$session_id` and the client identity from the header. There's **no `enable_json_response` caveat** — the Ruby transport always answers stateless requests with JSON. +Same problem as [above](#stateless-and-multi-pod-servers). Ruby fixes it with the same self-encoded `Mcp-Session-Id` token, wired into `MCP::Server::Transports::StreamableHTTPTransport` automatically: a server running with `stateless: true` mints the token at `initialize`, clients replay it, and any pod recovers `$session_id` and the client identity from the header. There's **no `enable_json_response` caveat** – the Ruby transport always answers stateless requests with JSON. When you build the Rack app yourself, add the middleware once. The decoded token is exposed as `env["posthog_mcp.session"]`: @@ -462,7 +462,7 @@ When you build the Rack app yourself, add the middleware once. The decoded token use PostHog::MCP::RackMiddleware ``` -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. +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 @@ -480,7 +480,7 @@ A stdio MCP server owns `$stdout` for the protocol. The integration's own messag PostHog::Logging.logger = Logger.new($stderr) ``` -No server object to wrap? Use `PostHog::MCP::Client`, a `PostHog::Client` subclass with `capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability`, `prepare_tool_list`, and `prepare_tool_call` — the Ruby equivalent of [Custom servers](/docs/mcp-analytics/custom-servers#ruby). +No server object to wrap? Use `PostHog::MCP::Client`, a `PostHog::Client` subclass with `capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability`, `prepare_tool_list`, and `prepare_tool_call` – the Ruby equivalent of [Custom servers](/docs/mcp-analytics/custom-servers#ruby). ## Configuration From 2072f95539bc3b0c9fc2a547dfc93665df6e2daf Mon Sep 17 00:00:00 2001 From: Rafa Audibert Date: Tue, 8 Sep 2026 23:33:51 -0300 Subject: [PATCH 3/4] docs(mcp-analytics): make the Ruby sections self-contained Explain the custom-dispatcher path step by step and drop references to the other SDKs so the Ruby sections read on their own. Co-Authored-By: Claude Fable 5.1 --- .../docs/mcp-analytics/custom-servers.mdx | 143 ++++++++++++------ contents/docs/mcp-analytics/installation.mdx | 12 +- 2 files changed, 107 insertions(+), 48 deletions(-) diff --git a/contents/docs/mcp-analytics/custom-servers.mdx b/contents/docs/mcp-analytics/custom-servers.mdx index c24f626c043a..2f944110a7bf 100644 --- a/contents/docs/mcp-analytics/custom-servers.mdx +++ b/contents/docs/mcp-analytics/custom-servers.mdx @@ -227,65 +227,122 @@ The token is unsigned and carries only what the client volunteered at `initializ -`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental**: method signatures may change in a minor release. It needs nothing beyond `posthog-ruby` – no `mcp` gem. +`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental**: method signatures may change in a minor release. -The Ruby SDK ships the same custom-dispatcher path as `PostHog::MCP::Client`, a subclass of the [`posthog-ruby`](/docs/libraries/ruby) client. Method names are snake_case and arguments are keywords: +### 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") +posthog = PostHog::MCP::Client.new( + api_key: "phc_your_project_api_key", + host: "https://us.i.posthog.com" # or https://eu.i.posthog.com +) +``` -# Advertise your tools with the injected `context` intent argument (and, optionally, -# the get_more_tools virtual tool). Tools are `tools/list` entries (Hashes). -tools = posthog.prepare_tool_list(my_tools, report_missing: true) - -def handle_tools_call(request, name, arguments) - # Pull the agent's intent off the call and strip the injected `context`: - prepared = posthog.prepare_tool_call(name, arguments) - - # Pass these on every capture — see "Attributing the caller" above: - common = { - distinct_id: user_id, - session_id: mcp_session_id, - client_user_agent: request.get_header("HTTP_USER_AGENT"), - vendor_client: request.get_header("HTTP_X_ANTHROPIC_CLIENT"), - groups: { organization: org_id } - } - - if prepared.is_missing_capability - posthog.capture_missing_capability(context: prepared.intent, **common) - return PostHog::MCP.get_more_tools_result - end - - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - begin - result = run_tool(name, prepared.args) - rescue StandardError => e - posthog.capture_tool_call( - name, - parameters: prepared.args, - duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000, - is_error: true, - error: e, # → $mcp_error_message, $mcp_error_type, and the $exception sibling - **common - ) - raise - end +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, - response: result, duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000, - **common + is_error: true, + error: e, + distinct_id: user_id ) - result + 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 +) ``` -`PostHog::MCP.encode_session_id` and `PostHog::MCP.decode_session_id` implement the `Mcp-Session-Id` token for your HTTP layer, or add `PostHog::MCP::RackMiddleware` to a Rack stack to mint and decode it for you. `PostHog::MCP::Client` is a `posthog-ruby` client, so flush it yourself with `posthog.flush` or `posthog.shutdown`. +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. At `initialize` it mints an `Mcp-Session-Id` token, clients replay it on every request, and the decoded token is available as `env["posthog_mcp.session"]`, so `session_id: env["posthog_mcp.session"]&.session_id`. If you already issue your own session header, 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. diff --git a/contents/docs/mcp-analytics/installation.mdx b/contents/docs/mcp-analytics/installation.mdx index fc4d676c88b0..5eb9fbb2098c 100644 --- a/contents/docs/mcp-analytics/installation.mdx +++ b/contents/docs/mcp-analytics/installation.mdx @@ -418,7 +418,7 @@ If your app already uses [`posthog-rails`](/docs/libraries/ruby) and calls `Post PostHog::MCP.instrument(server) ``` -Options are keyword arguments, the snake_case equivalent of the TypeScript options: +Options are keyword arguments: ```ruby PostHog::MCP.instrument( @@ -446,15 +446,17 @@ PostHog::MCP.instrument( 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. -Ruby also captures `$mcp_prompt_get`, `$mcp_prompts_list`, `$mcp_resource_read`, and `$mcp_resources_list`, which the other SDKs reserve but don't emit yet. +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 from Ruby report `$lib: "posthog-ruby-mcp"`. Unlike the Node and Python SDKs, 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. +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 -Same problem as [above](#stateless-and-multi-pod-servers). Ruby fixes it with the same self-encoded `Mcp-Session-Id` token, wired into `MCP::Server::Transports::StreamableHTTPTransport` automatically: a server running with `stateless: true` mints the token at `initialize`, clients replay it, and any pod recovers `$session_id` and the client identity from the header. There's **no `enable_json_response` caveat** – the Ruby transport always answers stateless requests with JSON. +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. The decoded token is exposed as `env["posthog_mcp.session"]`: @@ -480,7 +482,7 @@ A stdio MCP server owns `$stdout` for the protocol. The integration's own messag PostHog::Logging.logger = Logger.new($stderr) ``` -No server object to wrap? Use `PostHog::MCP::Client`, a `PostHog::Client` subclass with `capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability`, `prepare_tool_list`, and `prepare_tool_call` – the Ruby equivalent of [Custom servers](/docs/mcp-analytics/custom-servers#ruby). +Dispatching MCP requests without an `MCP::Server`? See [Custom servers](/docs/mcp-analytics/custom-servers#ruby). ## Configuration From 4b9e2641915946d95ce80795869182eb83863a22 Mon Sep 17 00:00:00 2001 From: Rafa Audibert Date: Tue, 15 Sep 2026 19:19:10 -0300 Subject: [PATCH 4/4] docs(mcp-analytics): say the Ruby SDK is unsupported, document the mint hook The MCP analytics team doesn't maintain the Ruby SDK, so the callouts now say it is not officially supported and point at TypeScript or Python for a supported one. The custom-dispatcher section covers `env['posthog_mcp.mint']`, since the Rack middleware no longer parses request bodies to mint a session. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/mcp-analytics/custom-servers.mdx | 24 ++++++++++++++++--- contents/docs/mcp-analytics/installation.mdx | 14 +++++++---- contents/docs/mcp-analytics/sdk-v2.mdx | 2 +- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/contents/docs/mcp-analytics/custom-servers.mdx b/contents/docs/mcp-analytics/custom-servers.mdx index 2f944110a7bf..1593f38d9cc3 100644 --- a/contents/docs/mcp-analytics/custom-servers.mdx +++ b/contents/docs/mcp-analytics/custom-servers.mdx @@ -225,9 +225,9 @@ The token is unsigned and carries only what the client volunteered at `initializ ## Ruby - + -`PostHog::MCP::Client` ships in `posthog-ruby` and is **experimental**: method signatures may change in a minor release. +`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). @@ -341,7 +341,25 @@ Every capture method accepts the same attribution keywords. Pass them on every c | `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. At `initialize` it mints an `Mcp-Session-Id` token, clients replay it on every request, and the decoded token is available as `env["posthog_mcp.session"]`, so `session_id: env["posthog_mcp.session"]&.session_id`. If you already issue your own session header, pass `PostHog::MCP.derive_session_id_from_mcp_session(your_id)` so the same connection always maps to the same `$session_id`. +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 diff --git a/contents/docs/mcp-analytics/installation.mdx b/contents/docs/mcp-analytics/installation.mdx index 5eb9fbb2098c..dc09886a876b 100644 --- a/contents/docs/mcp-analytics/installation.mdx +++ b/contents/docs/mcp-analytics/installation.mdx @@ -13,8 +13,8 @@ import WizardCommand from 'components/WizardCommand' ## Requirements -- Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), Python 3.10+ – see [Python](#python) below – or Ruby 3.0+ – see [Ruby](#ruby) below (experimental) -- 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). 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 @@ -382,9 +382,11 @@ Self-reported model capture is currently TypeScript-only. Python doesn't inject ## Ruby - + -`PostHog::MCP` is **experimental**. 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 tell us what breaks, but don't build production reporting on it yet. +`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). @@ -458,12 +460,14 @@ A stateless server keeps nothing between requests, often on a different pod each 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. The decoded token is exposed as `env["posthog_mcp.session"]`: +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 diff --git a/contents/docs/mcp-analytics/sdk-v2.mdx b/contents/docs/mcp-analytics/sdk-v2.mdx index 354651dd6474..5908e449c816 100644 --- a/contents/docs/mcp-analytics/sdk-v2.mdx +++ b/contents/docs/mcp-analytics/sdk-v2.mdx @@ -89,7 +89,7 @@ def identify(request, extra): ## 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 [Ruby SDK](/docs/mcp-analytics/installation#ruby) handles both with the same call: +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])