Ruby MCP Analytics installation
Contents
PostHog::MCP is experimental and not officially supported. 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. Don't build production reporting on it yet.
PostHog::MCP ships inside the posthog-ruby gem. It instruments an MCP::Server from the official Ruby MCP SDK (mcp gem >= 1.4), whether you register tools as MCP::Tool classes or with define_tool. It works over stdio and Streamable HTTP, and serves both the 2025-11-25 and 2026-07-28 protocol revisions.
You need Ruby 3.0+ and a PostHog project token. Dispatching MCP requests without an MCP::Server? See Custom dispatchers.
- 1
Install the gem
RequiredRubyPostHog::MCP.instrumentneeds themcpgem at runtime. You already have it, because you built your server with it. - 2
Wrap your server
RequiredRubyIf your app uses
posthog-railsand callsPostHog.init, leave out the client. The SDK usesPostHog.client:Rubyinstrumentreturns an analytics handle for custom events. On Ruby 3.2+,analytics.capturealso works from a thread or fiber that the tool starts. On Ruby 3.0 and 3.1, such an event gets its own$session_idon HTTP servers. - 3
Send queued events on shutdown
RequiredCaptured events go into the
posthog-rubyclient's queue. Callposthog.shutdownwhen the process stops:Ruby - 4
Check your first events
RequiredConnect an agent to your server and call a tool. Then open the activity feed and filter for
event = $mcp_tool_call. See the event reference for every property.
Log to stderr on stdio servers
A stdio MCP server uses $stdout for the protocol. The SDK's own messages go only to the logger: you pass. The experimental notice and configuration warnings go to stderr. Point the core SDK's logger away from stdout too:
Stateless and multi-pod servers
A stateless server creates a new instance for each request, often on a different pod. Without correlation, each request gets its own $session_id, and events after initialize lose the client name and version.
When MCP::Server::Transports::StreamableHTTPTransport runs with stateless: true, the SDK puts a token in the Mcp-Session-Id response header at initialize. The token carries the session ID and client metadata. Clients send it back, so any pod reads the same values. There's nothing to configure.
When you build the Rack app yourself, add the middleware once:
The middleware reads no request or response body. The decoded token is available to your app as env["posthog_mcp.session"].
Stateful HTTP servers need nothing. The SDK hashes the transport's session ID, so a session survives restarts. On 2026-07-28 requests, the SDK never sends Mcp-Session-Id, so use conversation IDs or identify to group calls there.
Configuration
Pass options as keyword arguments:
| Option | Default | What it does |
|---|---|---|
context | true | Add a context argument to capture agent intent. Pass { description: } to change its prompt. |
intent_fallback | – | (request, extra) -> String \| nil. Supplies intent when the agent sends no context. |
capture_model | true | Add an llm_model argument and capture the calling model. |
enable_conversation_id | true | Add a conversation_id argument to group calls across requests. |
identify | – | (request, extra) -> Hash \| nil, or a static Hash, with distinct_id:, properties:, and groups:. See Identifying users. |
report_missing | false | Add the get_more_tools tool for missing capabilities. |
missing_capability_tool_name | "get_more_tools" | Renames the report_missing tool. |
enable_exception_autocapture | true | Emit a $exception event for each failed tool call. |
before_send | – | (payload) -> payload \| nil. Change or drop each event before it's sent. |
event_properties | – | (request, extra) -> Hash. Adds properties to every event. |
logger | no-op | ->(message) { ... }. A stdio-safe sink. Never writes to stdout. |
Callbacks receive extra["headers"], a Hash with lowercase keys on HTTP transports and an empty Hash on stdio:
The SDK removes the arguments it adds before your tool's call runs, so def self.call(query:, server_context:) keeps working. A tool that declares context in its own input_schema keeps it. A tool whose input_schema uses oneOf, allOf, anyOf, or $ref gets no added arguments.
Events are truncated to the 32 KB message limit of posthog-ruby, because the client drops larger messages. MCP events report $lib: "posthog-ruby-mcp". The client you pass in keeps its own $lib for everything else it sends.
Custom dispatchers
Use this only when your app handles the MCP protocol itself: a Rack or Rails endpoint that parses the JSON-RPC body and routes tools/list and tools/call by hand, without an MCP::Server.
PostHog::MCP::Client is a PostHog::Client subclass. Create it once, and use it for everything else too. It needs nothing beyond posthog-ruby:
It also takes missing_capability_tool_name:, mcp_exception_autocapture:, and capture_model:. This path doesn't add conversation IDs.
1. Prepare the tool list
When you answer tools/list, pass your tool descriptors through prepare_tool_list. It returns new Hashes and leaves yours unchanged:
It adds a required context argument and an optional llm_model argument to every tool. With report_missing: true, it also adds the get_more_tools tool. Pass context: false or capture_model: false to skip an argument.
2. Prepare each tool call
Before the tool runs, pass its name, the raw arguments, and its own inputSchema through prepare_tool_call. The result has args without the added arguments, intent and intent_source, llm_model and llm_model_source, and is_missing_capability:
With input_schema:, a context or llm_model field that your tool declares stays in args. Without it, the SDK always removes both names.
3. Capture the call
After the tool runs, call capture_tool_call. On a failure, pass error: with the exception or a message. The SDK sets $mcp_error_type and $mcp_error_message, and emits $exception:
| Method | Event | Call it when |
|---|---|---|
capture_initialize(client_name:, client_version:, protocol_version:, ...) | $mcp_initialize | You answer a 2025-11-25 initialize handshake. |
capture_tools_list(tool_names:, ...) | $mcp_tools_list | You answer tools/list. |
capture_tool_call(name, ...) | $mcp_tool_call and $exception | A tool ran. |
capture_missing_capability(context:, ...) | $mcp_missing_capability | The agent called get_more_tools. |
4. Attribute the caller
Every capture method takes the same keywords. Pass them on every call:
| Keyword | Becomes | Where to get it |
|---|---|---|
distinct_id: | the event's person | Your auth, such as the OAuth subject. |
session_id: | $session_id | The session middleware, below. |
set_properties: | $set | Person properties, such as name or plan. |
groups: | $groups | { organization: org_id }. |
client_user_agent:, vendor_client: | $mcp_client_user_agent, $mcp_vendor_client | The User-Agent and X-Anthropic-Client headers. |
protocol_version: | $mcp_protocol_version | params.protocolVersion on initialize, or the MCP-Protocol-Version header. |
For session_id:, add use PostHog::MCP::RackMiddleware. When you accept an initialize, call the hook it leaves in env["posthog_mcp.mint"]:
The middleware sends the token in the Mcp-Session-Id header. On later requests, pass session_id: env["posthog_mcp.session"]&.session_id. The hook is nil when the client already sent a token back, and returns nil for a 2026-07-28 client. If you issue your own session header instead, pass PostHog::MCP.derive_session_id_from_mcp_session(your_id), so one connection always maps to one $session_id.
5. Flush
Call posthog.flush at the end of a short-lived request handler, or posthog.shutdown when the process stops.