Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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