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
7 changes: 7 additions & 0 deletions .changeset/quiet-ruby-mcp-analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-ruby': minor
---

Add `PostHog::MCP`, **experimental and unsupported** MCP analytics for servers built on the official `mcp` gem. This is not an officially supported PostHog SDK: no support is provided for it, and its API, its options, and the `$mcp_*` event schema it captures may change in a minor release. A warning is logged when you `require 'posthog/mcp'`. Docs: https://posthog.com/docs/mcp-analytics

`PostHog::MCP.instrument(server, client)` captures `$mcp_initialize`, `$mcp_tools_list`, `$mcp_tool_call`, prompt/resource events, `$mcp_missing_capability`, `$identify` and sibling `$exception` events (agent intent via an injected `context` argument, conversation ids, stateless `Mcp-Session-Id` tokens, sanitization and truncation). Also adds `PostHog::MCP::Client` for custom dispatchers, `PostHog::MCP::RackMiddleware`, and a private per-event `_lib`/`_lib_version` override in `Client#capture` so MCP events report `$lib: posthog-ruby-mcp` without relabeling the host client.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ gem 'irb'
group :development, :test do
gem 'activesupport', '~> 7.1'
gem 'commander', '~> 5.0'
gem 'mcp', '>= 1.4'
gem 'oj', '~> 3.17.6'
gem 'prettier'
gem 'railties', '~> 7.1'
Expand Down
14 changes: 14 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ GEM
temple (>= 0.8.2)
thor
tilt
hana (1.3.7)
hashdiff (1.2.1)
highline (3.0.1)
i18n (1.14.8)
Expand All @@ -75,12 +76,19 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.21.2)
json_schemer (2.5.0)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
simpleidn (~> 0.2)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mcp (1.5.0)
json_schemer (>= 2.4)
minitest (5.27.0)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
Expand Down Expand Up @@ -188,6 +196,7 @@ GEM
prism (~> 1.7)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
simpleidn (0.3.0)
stringio (3.2.0)
syntax_tree (6.3.0)
prettier_print (>= 1.2.0)
Expand Down Expand Up @@ -231,6 +240,7 @@ DEPENDENCIES
commander (~> 5.0)
concurrent-ruby
irb
mcp (>= 1.4)
oj (~> 3.17.6)
posthog-ruby!
prettier
Expand Down Expand Up @@ -265,16 +275,19 @@ CHECKSUMS
erb (4.0.4.1) sha256=47e2a70922ba4cec8c23c2304ad7e581665c37a297cb9a22a860162c7727ee24
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a
hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
highline (3.0.1) sha256=ca18b218fd581b1fae832f89bfeaf2b34d3a93429c44fd4411042ffce286f009
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918
mcp (1.5.0) sha256=6f97785fa6e069eb667aa3d50299851ea9aca904b4abe8306f156bb8fd9eaff0
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
Expand Down Expand Up @@ -320,6 +333,7 @@ CHECKSUMS
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
syntax_tree (6.3.0) sha256=56e25a9692c798ec94c5442fe94c5e94af76bef91edc8bb02052cbdecf35f13d
syntax_tree-haml (4.0.3) sha256=dcf676677dc28b0f4b908dd414c0e75721fbd6261a2d0f07b378246059196359
Expand Down
57 changes: 57 additions & 0 deletions examples/mcp_server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

# Minimal stdio MCP server instrumented with PostHog MCP analytics.
#
# PostHog::MCP is experimental and not officially supported; its API and event
# schema may change in a minor release. Docs: https://posthog.com/docs/mcp-analytics
#
# POSTHOG_API_KEY=phc_... bundle exec ruby examples/mcp_server.rb
#
# Then paste JSON-RPC lines on stdin, for example:
#
# {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",
# "capabilities":{},"clientInfo":{"name":"demo","version":"1.0"}}}
# {"jsonrpc":"2.0","id":2,"method":"tools/list"}
# {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"greet",
# "arguments":{"name":"Ada","context":"Greeting a user to test the demo server."}}}
#
# (each request on a single line). Without POSTHOG_API_KEY the client runs in test
# mode and the captured events are dumped to stderr on exit.

require 'bundler/setup'
require 'json'
require 'logger'
require 'mcp'
require 'posthog/mcp'

# stdout belongs to the MCP protocol; keep every log line on stderr.
PostHog::Logging.logger = Logger.new($stderr)

api_key = ENV.fetch('POSTHOG_API_KEY', nil)
posthog = PostHog::Client.new(api_key: api_key || 'phc_test', test_mode: api_key.nil?)

server = MCP::Server.new(name: 'posthog-demo', version: '0.1.0')
server.define_tool(name: 'greet', description: 'Greets someone by name',
input_schema: { properties: { name: { type: 'string' } }, required: ['name'] }) do |name:, **|
MCP::Tool::Response.new([{ type: 'text', text: "Hello, #{name}!" }])
end
server.define_tool(name: 'fail', description: 'Always raises, to demonstrate error capture') do |**|
raise 'Something went wrong'
end

PostHog::MCP.instrument(server, posthog, report_missing: true,
logger: ->(message) { warn "[mcp-analytics] #{message}" })

if api_key.nil?
at_exit do
while posthog.queued_messages.positive?
message = posthog.dequeue_last_message
warn JSON.pretty_generate(event: message[:event], distinct_id: message[:distinct_id],
properties: message[:properties])
end
end
else
at_exit { posthog.shutdown }
end

MCP::Server::Transports::StdioTransport.new(server).open
9 changes: 7 additions & 2 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,11 @@ def capture(attrs)

symbolize_keys! attrs
minimal_flag_called_event = attrs.delete(:_minimal_flag_called_event) == true
# Integrations that ride on a host application's client (e.g. PostHog::MCP)
# may relabel a single event's `$lib`/`$lib_version` without relabeling the
# client or its User-Agent.
lib_override = attrs.delete(:_lib)
lib_version_override = attrs.delete(:_lib_version)
enrich_capture_attrs_with_context(attrs)

# Precedence: an explicit `flags` snapshot always wins, regardless of
Expand Down Expand Up @@ -370,8 +375,8 @@ def capture(attrs)
end

attrs[:is_server] = @is_server
attrs[:lib] = @lib
attrs[:lib_version] = @lib_version
attrs[:lib] = lib_override || @lib
attrs[:lib_version] = (lib_version_override || @lib_version).to_s
message = FieldParser.parse_for_capture(attrs)
# Minimal events are built from the allowlist after full assembly so
# context properties and parser-added metadata can never leak in.
Expand Down
216 changes: 216 additions & 0 deletions lib/posthog/mcp.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# frozen_string_literal: true

require 'posthog'

require 'posthog/mcp/constants'
require 'posthog/mcp/log'
require 'posthog/mcp/ids'
require 'posthog/mcp/options'
require 'posthog/mcp/session_token'
require 'posthog/mcp/session'
require 'posthog/mcp/identity'
require 'posthog/mcp/tools'
require 'posthog/mcp/exceptions'
require 'posthog/mcp/sanitization'
require 'posthog/mcp/truncation'
require 'posthog/mcp/conversation_id'
require 'posthog/mcp/intent'
require 'posthog/mcp/schema_mutation'
require 'posthog/mcp/event_builder'
require 'posthog/mcp/sink'
require 'posthog/mcp/tracking_data'
require 'posthog/mcp/request_scope'
require 'posthog/mcp/analytics'
require 'posthog/mcp/instrumentation'
require 'posthog/mcp/server_extension'
require 'posthog/mcp/rack_middleware'
require 'posthog/mcp/client'

module PostHog
# PostHog MCP analytics for servers built on the official Ruby `mcp` gem.
#
# Wrap an `MCP::Server` so every tool call, handshake, listing, prompt,
# resource read, and failure is captured to PostHog as a `$mcp_*` event.
#
# @note Experimental and not officially supported: no support is provided for
# this integration, and its API and the captured event schema may change in a
# minor release. A warning is logged when this file is required.
# Docs: https://posthog.com/docs/mcp-analytics
#
# @example
# require 'posthog/mcp'
#
# posthog = PostHog::Client.new(api_key: 'phc_...', host: 'https://us.i.posthog.com')
# server = MCP::Server.new(name: 'my-server', version: '1.0.0', tools: [MyTool])
# analytics = PostHog::MCP.instrument(server, posthog)
#
# # With posthog-rails the client is resolved from PostHog.client:
# PostHog::MCP.instrument(server)
module MCP
EXPERIMENTAL_NOTICE =
'PostHog::MCP is experimental and not officially supported: no support is provided for it, and its ' \
'API and the captured $mcp_* event schema may change in a minor release. Docs: ' \
'https://posthog.com/docs/mcp-analytics. Feedback welcome at https://github.com/PostHog/posthog-ruby/issues.'

class << self
# Instrument an `MCP::Server`.
#
# @param server [MCP::Server] the server to wrap
# @param client [PostHog::Client, nil] the PostHog client to send through. Defaults to
# `PostHog.client` when the posthog-rails facade is loaded.
# @param options [PostHog::MCP::Options, nil] prebuilt options; otherwise pass keywords
# @param kwargs [Hash] {PostHog::MCP::Options} keywords (`identify:`, `before_send:`, ...)
# @return [PostHog::MCP::Analytics] handle for custom events; a no-op handle when
# instrumentation fails (logged, never raised)
# @raise [LoadError] when the `mcp` gem is not available
def instrument(server, client = nil, options: nil, **kwargs)
opts = options.is_a?(Options) ? options : Options.new(**kwargs)
ensure_mcp_sdk!
experimental_notice!(opts)

begin
unless server.is_a?(::MCP::Server)
raise TypeError, "Unsupported server type: #{server.class}. Pass an MCP::Server."
end

existing = tracking_data(server)
if existing
Log.debug(opts, 'instrument() - server already instrumented, skipping initialization')
return Analytics.new(server)
end

resolved_client = resolve_client(client)
Log.warn(opts, 'Warning: no PostHog client available; MCP events will not be sent.') if resolved_client.nil?
sink = resolved_client ? Sink.new(resolved_client) : nil
data = TrackingData.new(options: opts, sink: sink, server_name: safe_call(server, :name),
server_version: safe_call(server, :version))
install_extensions!
server.instance_variable_set(:@__posthog_mcp, data)
register_missing_capability_tool(server, data)
Analytics.new(server)
rescue StandardError => e
Log.warn(opts, "Warning: failed to instrument server - #{e.class}: #{e.message}")
NoopAnalytics.new
end
end

# @api private
# @return [PostHog::MCP::TrackingData, nil]
def tracking_data(server)
return nil unless server.instance_variable_defined?(:@__posthog_mcp)

server.instance_variable_get(:@__posthog_mcp)
end

# Encode a session token for a custom HTTP layer's `Mcp-Session-Id` response header.
#
# @param payload [PostHog::MCP::SessionTokenPayload, Hash]
# @return [String]
def encode_session_id(payload)
SessionToken.encode(payload)
end

# Decode an `Mcp-Session-Id` value; nil for anything that is not one of our tokens.
#
# @return [PostHog::MCP::SessionTokenPayload, nil]
def decode_session_id(value)
SessionToken.decode(value)
end

# Deterministic `$session_id` for a transport session id (stable across restarts).
#
# @return [String]
def derive_session_id_from_mcp_session(mcp_session_id)
Session.derive_session_id_from_mcp_session(mcp_session_id)
end

# Deterministic `$session_id` for an agent conversation handle.
#
# @return [String]
def derive_session_id_from_conversation(conversation_id)
Session.derive_session_id_from_conversation(conversation_id)
end

# The canned `get_more_tools` result for custom dispatchers.
#
# @return [Hash]
def get_more_tools_result # rubocop:disable Naming/AccessorMethodName -- public API name
Tools.result
end

# @api private
def mcp_sdk_available?
defined?(::MCP::Server) ? true : false
end

# @api private
def experimental_notice!(options = nil)
Log.debug(options, EXPERIMENTAL_NOTICE)
return if @experimental_notice_shown

@experimental_notice_shown = true
Kernel.warn("[posthog-ruby] #{EXPERIMENTAL_NOTICE}")
end

# @api private
def reset_for_tests!
@experimental_notice_shown = false
end

private

def ensure_mcp_sdk!
return if mcp_sdk_available?

raise LoadError, "PostHog::MCP.instrument needs the MCP SDK. Add `gem 'mcp', '>= 1.4'` to your Gemfile. " \
'(PostHog::MCP::Client for custom dispatchers works without it.)'
end

def resolve_client(client)
return client if client

PostHog.respond_to?(:client) ? PostHog.client : nil
rescue StandardError
nil
end

def safe_call(object, method_name)
object.respond_to?(method_name) ? object.public_send(method_name) : nil
rescue StandardError
nil
end

# Adds the `get_more_tools` virtual tool as a real server tool. An application
# tool that already uses the name wins and is tracked as an ordinary tool.
def register_missing_capability_tool(server, data)
return unless data.options.report_missing

name = Tools.missing_capability_tool_name(data.options)
return if server.tools.is_a?(Hash) && server.tools.key?(name)

data.virtual_tool = Tools.register(server, name, data.options)
rescue StandardError => e
Log.warn(data.options, "Warning: could not register the #{name} tool - #{e.class}: #{e.message}")
end

def install_extensions!
return if @extensions_installed

::MCP::Server.prepend(ServerExtension)
if defined?(::MCP::Server::Transports::StreamableHTTPTransport)
::MCP::Server::Transports::StreamableHTTPTransport.prepend(TransportExtension)
end
@extensions_installed = true
end
end
end
end

begin
require 'mcp'
rescue LoadError
# The `mcp` gem is a peer dependency of PostHog::MCP.instrument; PostHog::MCP::Client
# (custom dispatchers) works without it. `instrument` raises a LoadError with a hint.
end

PostHog::MCP.experimental_notice!
Loading
Loading