diff --git a/.changeset/quiet-ruby-mcp-analytics.md b/.changeset/quiet-ruby-mcp-analytics.md new file mode 100644 index 0000000..3bcd298 --- /dev/null +++ b/.changeset/quiet-ruby-mcp-analytics.md @@ -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. diff --git a/Gemfile b/Gemfile index ab3232c..b3f10b7 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 6013246..a3ece9c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -231,6 +240,7 @@ DEPENDENCIES commander (~> 5.0) concurrent-ruby irb + mcp (>= 1.4) oj (~> 3.17.6) posthog-ruby! prettier @@ -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 @@ -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 diff --git a/examples/mcp_server.rb b/examples/mcp_server.rb new file mode 100644 index 0000000..a2768c8 --- /dev/null +++ b/examples/mcp_server.rb @@ -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 diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index ea349af..a9fae0f 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -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 @@ -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. diff --git a/lib/posthog/mcp.rb b/lib/posthog/mcp.rb new file mode 100644 index 0000000..d682966 --- /dev/null +++ b/lib/posthog/mcp.rb @@ -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! diff --git a/lib/posthog/mcp/README.md b/lib/posthog/mcp/README.md new file mode 100644 index 0000000..0e74144 --- /dev/null +++ b/lib/posthog/mcp/README.md @@ -0,0 +1,53 @@ +# PostHog MCP analytics for Ruby + +> **Experimental and unsupported.** `PostHog::MCP` is not an officially supported PostHog SDK: no +> support is provided for it, and its API, options, and the captured `$mcp_*` event schema may change +> in a minor release. A one-line warning is logged when you require it. Bug reports and patches are +> welcome at https://github.com/PostHog/posthog-ruby/issues, but don't build production reporting on +> it yet. + +Product analytics for [Model Context Protocol](https://modelcontextprotocol.io) servers built on the +official Ruby [`mcp`](https://rubygems.org/gems/mcp) gem. Wrap an `MCP::Server` so every tool call, +agent intent, handshake, listing, prompt, resource read, and failure is captured to PostHog. + +**Documentation: https://posthog.com/docs/mcp-analytics** — setup, every option, the event and +property catalog, sessions on stateless/multi-pod servers, conversation ids, intent, identifying +users, privacy, and custom dispatchers. That's the single source of truth; this directory +deliberately keeps no second copy of it. + +Install is just `gem 'posthog-ruby'`. `PostHog::MCP.instrument` needs the `mcp` gem (`>= 1.4`) at +runtime, but anyone wrapping a server already has it. `PostHog::MCP::Client` (custom dispatchers) +needs nothing beyond `posthog-ruby`. + +## Ruby-specific notes + +These are the few things that differ from what the docs describe for the other SDKs. + +* **`$lib` is per event.** MCP events report `$lib: "posthog-ruby-mcp"` while the client you pass in + keeps its own identity (`posthog-ruby` or `posthog-rails`) for everything else it sends, so + instrumenting a server inside a Rails app doesn't relabel the app's other events. +* **Truncation is tighter.** Events are truncated to fit the core client's 32KB per-message limit, + because posthog-ruby drops larger messages at batch time - truncating harder beats losing the + event. +* **Request scope needs Ruby 3.2+ to be inherited.** A custom event captured from a thread or fiber + a tool spawns is attributed to that request on 3.2+ (fiber storage); before 3.2 capture from the + tool body itself, or an HTTP server falls back to a standalone session. +* **Composed schemas are left alone.** A tool whose `input_schema` is `oneOf`/`allOf`/`anyOf` or a + `$ref` has nothing injected into it and nothing stripped from its calls. +* **stdio servers own `$stdout`.** The integration's own messages go only to the `logger:` you pass + (default: nowhere); the experimental notice and misconfiguration warnings go to stderr. Point the + core SDK's logger away from stdout too: `PostHog::Logging.logger = Logger.new($stderr)`. + +## Layout + +| File | What it does | +|---|---| +| `mcp.rb` (parent dir) | `PostHog::MCP.instrument` and the public helpers | +| `server_extension.rb` | Prepended onto `MCP::Server` and the Streamable HTTP transport | +| `instrumentation.rb` | Per-request dispatch: intent, identity, sessions, recording | +| `client.rb` | `PostHog::MCP::Client` for custom dispatchers | +| `rack_middleware.rb` | `Mcp-Session-Id` tokens for a custom Rack stack | +| `sanitization.rb`, `truncation.rb` | Redaction and payload budgets | + +Runnable example: [`examples/mcp_server.rb`](../../../examples/mcp_server.rb). Specs: +`spec/posthog/mcp/`. diff --git a/lib/posthog/mcp/analytics.rb b/lib/posthog/mcp/analytics.rb new file mode 100644 index 0000000..a06deb0 --- /dev/null +++ b/lib/posthog/mcp/analytics.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Handle returned by {PostHog::MCP.instrument}. Emits custom events onto the + # same pipeline as the auto-captured `$mcp_*` events. + # + # @note Experimental. + class Analytics + # @api private + def initialize(server) + @server = server + end + + # Capture a custom event scoped to the current MCP session. The event + # name is sent verbatim (a customer event, not `$`-prefixed). + # + # Inside a tool body the session and the identity are the ones pinned to + # the in-flight request by {Instrumentation}, so a concurrent request on + # the same session cannot reattribute this event. Over stdio, where a + # server only ever talks to one client, the session is the server's + # current one. On an HTTP server a call that has lost the request scope + # gets a standalone session rather than the server's, which may belong to + # another caller's request. + # + # @param event [String] event name + # @param properties [Hash] event properties + # @return [void] + # @raise [ArgumentError] when the event name is blank + def capture(event, properties = {}) + unless event.is_a?(String) && !event.strip.empty? + raise ArgumentError, 'capture() requires an event name, e.g. analytics.capture("feedback_submitted")' + end + + data = PostHog::MCP.tracking_data(@server) + return if data.nil? + + scope = RequestScope.current + scope = nil unless scope.is_a?(Hash) + Instrumentation.capture_event(data, { + 'session_id' => current_session_id(data, scope), + 'event_type' => EventType::CUSTOM, + 'event_name' => event, + 'timestamp' => Time.now.utc, + 'properties' => properties + }, actor: scoped_actor(scope)) + nil + end + + # Flush the underlying PostHog client. + # + # @return [void] + def flush + data = PostHog::MCP.tracking_data(@server) + client = data&.sink&.client + client.flush if client.respond_to?(:flush) + nil + end + + private + + # The session pinned to the in-flight request, when there is one. On a server + # that has served an HTTP request the server-wide session is not a safe + # fallback: it belongs to whichever request settled it last, which under + # concurrency is somebody else's. That happens when a tool hands its work to + # a thread or fiber it spawned on Ruby 3.0/3.1, where {RequestScope} is + # fiber-local and is not inherited. Fail closed with a standalone session + # rather than filing the event under another caller's identity; over stdio a + # server only ever talks to one client, so the fallback stays. + def current_session_id(data, scope) + scoped = scope ? scope[:session_id] : nil + return scoped if scoped + return data.session_id unless data.http_transport_seen + + warn_unscoped_capture(data) + Session.new_session_id + end + + # The identity the in-flight request resolved, which {Instrumentation} pinned + # on the scope before running the tool body. Absent outside a request it + # started, and only then is the session-keyed cache consulted instead. + def scoped_actor(scope) + scope&.key?(:actor) ? scope[:actor] : Instrumentation::UNRESOLVED_ACTOR + end + + def warn_unscoped_capture(data) + return if data.warned_unscoped_capture + + data.warned_unscoped_capture = true + Log.warn( + data.options, + 'Warning: analytics.capture() ran without the scope of the request that started it, so its event got ' \ + 'a standalone $session_id instead of the caller\'s. On Ruby 3.2+ the scope follows threads and fibers ' \ + 'a tool spawns; before that it does not, so capture custom events from the tool body itself.' + ) + end + end + + # Returned when instrumentation could not be set up; every call is a no-op. + # + # @api private + class NoopAnalytics < Analytics + def initialize + super(nil) + end + + def capture(_event = nil, _properties = {}) + nil + end + + def flush + nil + end + end + end +end diff --git a/lib/posthog/mcp/client.rb b/lib/posthog/mcp/client.rb new file mode 100644 index 0000000..266426b --- /dev/null +++ b/lib/posthog/mcp/client.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # A {PostHog::Client} with first-class MCP analytics for custom dispatchers + # (your own HTTP layer, no `MCP::Server` to wrap). The host resolves + # identity and context per request and calls the capture methods directly; + # events flow through the same sanitize -> truncate -> `$exception` fan-out + # pipeline as {PostHog::MCP.instrument}. Does not need the `mcp` gem. + # + # @note Experimental. + # + # @example + # posthog = PostHog::MCP::Client.new(api_key: 'phc_...', host: 'https://us.i.posthog.com') + # posthog.capture_tool_call('search_docs', duration_ms: 42, distinct_id: 'user_123') + class Client < PostHog::Client + # @param opts [Hash] {PostHog::Client} options plus: + # @option opts [String] :missing_capability_tool_name name of the virtual tool (default `get_more_tools`) + # @option opts [Boolean] :mcp_exception_autocapture emit a sibling `$exception` for failed calls (default true) + def initialize(opts = {}) + opts = opts.transform_keys(&:to_sym) + @missing_capability_tool_name = opts.delete(:missing_capability_tool_name) || Tools::GET_MORE_TOOLS_NAME + @mcp_exception_autocapture = opts.delete(:mcp_exception_autocapture) != false + super + @mcp_sink = Sink.new(self) + @mcp_options = Options.new( + enable_exception_autocapture: @mcp_exception_autocapture, + missing_capability_tool_name: @missing_capability_tool_name + ) + end + + # Capture a tool invocation. Emits `$mcp_tool_call` (+ `$exception` on error). + # + # @return [void] + def capture_tool_call(tool_name, intent: nil, intent_source: nil, parameters: nil, response: nil, + duration_ms: nil, is_error: false, error: nil, error_type: nil, category: nil, + tool_description: nil, protocol_version: nil, distinct_id: nil, session_id: nil, + client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil, + properties: nil, timestamp: nil, llm_model: nil, llm_model_source: nil) + event = base_event(EventType::MCP_TOOLS_CALL, distinct_id, session_id, set_properties, groups, properties, + timestamp, client_user_agent, vendor_client) + event['resource_name'] = tool_name + event['tool_description'] = tool_description + event['tool_category'] = category + event['protocol_version'] = protocol_version + event['parameters'] = parameters + event['response'] = response + event['duration'] = duration_ms + event['is_error'] = is_error == true + event['error_type'] = error_type + apply_intent(event, intent, intent_source) + apply_model(event, llm_model, llm_model_source) + if is_error + event['error'] = + Exceptions.capture_exception(error.nil? ? "Tool #{tool_name} returned an error" : error) + end + emit(event) + end + + # Capture the connection handshake. Emits `$mcp_initialize`. + # + # @return [void] + def capture_initialize(client_name: nil, client_version: nil, protocol_version: nil, parameters: nil, + response: nil, duration_ms: nil, distinct_id: nil, session_id: nil, + client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil, + properties: nil, timestamp: nil) + event = base_event(EventType::MCP_INITIALIZE, distinct_id, session_id, set_properties, groups, properties, + timestamp, client_user_agent, vendor_client) + event['client_name'] = client_name + event['client_version'] = client_version + event['protocol_version'] = protocol_version + event['parameters'] = parameters + event['response'] = response + event['duration'] = duration_ms + emit(event) + end + + # Capture a `tools/list` response. Emits `$mcp_tools_list` with `$mcp_listed_tool_names`. + # + # @return [void] + def capture_tools_list(tool_names: nil, parameters: nil, response: nil, duration_ms: nil, is_error: false, + error: nil, error_type: nil, protocol_version: nil, distinct_id: nil, session_id: nil, + client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil, + properties: nil, timestamp: nil) + event = base_event(EventType::MCP_TOOLS_LIST, distinct_id, session_id, set_properties, groups, properties, + timestamp, client_user_agent, vendor_client) + event['listed_tool_names'] = tool_names + event['protocol_version'] = protocol_version + event['parameters'] = parameters + event['response'] = response + event['duration'] = duration_ms + event['is_error'] = is_error == true + event['error_type'] = error_type + event['error'] = Exceptions.capture_exception(error.nil? ? 'tools/list failed' : error) if is_error + emit(event) + end + + # Capture a `get_more_tools` call as a missing-capability report. Emits + # `$mcp_missing_capability` with the agent's description as `$mcp_intent`. + # + # @return [void] + def capture_missing_capability(context: nil, parameters: nil, protocol_version: nil, distinct_id: nil, + session_id: nil, client_user_agent: nil, vendor_client: nil, + set_properties: nil, groups: nil, properties: nil, timestamp: nil, + llm_model: nil, llm_model_source: nil) + event = base_event(EventType::MCP_MISSING_CAPABILITY, distinct_id, session_id, set_properties, groups, + properties, timestamp, client_user_agent, vendor_client) + event['resource_name'] = @missing_capability_tool_name + event['protocol_version'] = protocol_version + event['parameters'] = parameters + apply_intent(event, context, 'context_parameter') + apply_model(event, llm_model, llm_model_source) + emit(event) + end + + # Inject the `context` argument (and, with `capture_model`, `llm_model`) into + # every tool descriptor (Hash with `inputSchema`) so agents state their intent, + # and optionally append the `get_more_tools` virtual tool. Returns a new Array + # of new Hashes. A tool whose schema is composed (oneOf/allOf/anyOf) or a + # `$ref` is passed through untouched. + # + # @param tools [Array] `tools/list` entries + # @return [Array] + def prepare_tool_list(tools, context: true, report_missing: false, capture_model: false) + options = Options.new(context: context, capture_model: capture_model) + prepared = tools.map do |tool| + next tool unless tool.is_a?(Hash) && (options.context_enabled? || options.capture_model_enabled?) + + name = SchemaMutation.fetch(tool, :name) || 'unknown' + next tool if name == @missing_capability_tool_name + + schema = SchemaMutation.fetch(tool, :inputSchema) + if options.context_enabled? + schema = SchemaMutation.add_context_parameter(schema, tool_name: name, + description: options.context_description) + end + if options.capture_model_enabled? + schema = SchemaMutation.add_model_parameter(schema, tool_name: name, + description: options.model_description) + end + tool.merge(SchemaMutation.key_for(tool, :inputSchema) => schema) + end + if report_missing && prepared.none? { |t| SchemaMutation.fetch(t, :name) == @missing_capability_tool_name } + prepared << Tools.descriptor(@missing_capability_tool_name, options) + end + prepared + end + + # Pull the agent's intent off the `context` argument and its self-reported + # model off `llm_model`, strip the arguments this integration injected, and + # flag the `get_more_tools` virtual tool. Hand `intent`/`intent_source` and + # `llm_model`/`llm_model_source` straight to {#capture_tool_call}. + # + # Pass the tool's own `inputSchema` (the same Hash you handed to + # {#prepare_tool_list}) so a field the tool declares itself is left in + # `args` and never read as analytics: only an injected argument is stripped + # and reported. A composed (oneOf/allOf/anyOf) or `$ref` schema is never + # injected into, so its fields are the tool's own and are left alone too. + # Without the schema there is no way to tell the two apart, so the injected + # names are always stripped. + # + # @param name [String] tool name + # @param args [Hash, nil] the call's arguments + # @param input_schema [Hash, nil] the tool's raw `inputSchema` + # @return [PreparedToolCall] + def prepare_tool_call(name, args = nil, input_schema: nil) + intent = tool_declares?(input_schema, 'context') ? nil : Intent.normalize(argument(args, 'context')) + model = if tool_declares?(input_schema, ModelCapture::PARAM_NAME) + nil + else + ModelCapture.normalize(argument(args, ModelCapture::PARAM_NAME)) + end + PreparedToolCall.new( + args: strip_injected(args, input_schema), + intent: intent, + intent_source: intent ? 'context_parameter' : nil, + llm_model: model, + llm_model_source: model ? 'self_reported' : nil, + is_missing_capability: name == @missing_capability_tool_name + ) + end + + private + + def base_event(event_type, distinct_id, session_id, set_properties, groups, properties, timestamp, + client_user_agent, vendor_client) + event = { + 'event_type' => event_type, + 'session_id' => session_id, + 'timestamp' => timestamp || Time.now.utc, + 'properties' => properties, + 'groups' => groups, + 'client_user_agent' => client_user_agent, + 'vendor_client' => vendor_client + } + event['identify_actor_given_id'] = distinct_id if distinct_id.is_a?(String) && !distinct_id.empty? + event['identify_actor_data'] = set_properties if set_properties.is_a?(Hash) && !set_properties.empty? + event + end + + def apply_model(event, llm_model, source) + model = ModelCapture.normalize(llm_model) + return unless model + + event['llm_model'] = model + event['llm_model_source'] = source || 'self_reported' + end + + def apply_intent(event, intent, source) + trimmed = intent.is_a?(String) ? intent.strip : '' + return if trimmed.empty? + + event['user_intent'] = trimmed + event['user_intent_source'] = source || 'context_parameter' + end + + def emit(event) + @mcp_sink.capture(event, @mcp_options) + nil + end + + # True when the argument belongs to the tool rather than to this layer: it + # declares the field itself, or its schema is one we never inject into. + def tool_declares?(input_schema, param) + return false unless input_schema + + !SchemaMutation.injectable?(input_schema) || SchemaMutation.declares_param?(input_schema, param) + end + + def argument(args, param) + return nil unless args.is_a?(Hash) + + args[param.to_sym] || args[param] + end + + def strip_injected(args, input_schema) + return args unless args.is_a?(Hash) + + keys = ['context', ModelCapture::PARAM_NAME].reject { |param| tool_declares?(input_schema, param) } + .flat_map { |param| [param.to_sym, param] } + .select { |key| args.key?(key) } + keys.empty? ? args : args.except(*keys) + end + end + end +end diff --git a/lib/posthog/mcp/constants.rb b/lib/posthog/mcp/constants.rb new file mode 100644 index 0000000..66404f6 --- /dev/null +++ b/lib/posthog/mcp/constants.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Value of `$mcp_source` on every primary `$mcp_*` event. + SOURCE = 'posthog_mcp_analytics' + + # `$lib` stamped on MCP analytics events (per event, never on the client). + LIB_NAME = 'posthog-ruby-mcp' + + # Generated (in-memory) sessions roll over after this much inactivity. + INACTIVITY_TIMEOUT_MINUTES = 30 + + # Header carrying the transport session id, and our self-encoded token. + MCP_SESSION_HEADER = 'mcp-session-id' + + # Description of the injected `context` argument. + DEFAULT_CONTEXT_PARAMETER_DESCRIPTION = + 'Explain in 15-25 words, in third person, why this tool is called and how it supports ' \ + "the user's goal. For analytics only. You MUST describe only the abstract purpose of the " \ + 'tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying ' \ + 'information from the user request or tool results, including names, emails, phone numbers, ' \ + 'IPs, IDs, or credentials. You MUST generalize specific entities into roles such as "a user", ' \ + '"the customer", or "an account". Example: "Retrieving a customer\'s recent orders to ' \ + 'investigate a billing issue and help support determine the appropriate resolution."' + + # Description of the injected `llm_model` argument. + DEFAULT_MODEL_PARAMETER_DESCRIPTION = + 'The exact model identifier you (the assistant) are running as, taken from your system ' \ + 'prompt or environment (e.g. "claude-opus-4-8", "gpt-5.2"). Used for analytics only. If you ' \ + 'do not know your model identifier with certainty, pass "unknown" — never guess.' + + # Description of the injected `conversation_id` argument. + DEFAULT_CONVERSATION_ID_DESCRIPTION = + "Echo the conversation_id from the server's previous response. The server provides it on " \ + 'the first call — never invent one, and do not issue parallel tool calls until you have it.' + + # PostHog-owned event names. All `$`-prefixed per the PostHog convention. + module Event + CUSTOM = '$mcp_custom' + EXCEPTION = '$exception' + IDENTIFY = '$identify' + INITIALIZE = '$mcp_initialize' + MISSING_CAPABILITY = '$mcp_missing_capability' + PROMPT_GET = '$mcp_prompt_get' + PROMPTS_LIST = '$mcp_prompts_list' + RESOURCE_READ = '$mcp_resource_read' + RESOURCES_LIST = '$mcp_resources_list' + TOOL_CALL = '$mcp_tool_call' + TOOLS_LIST = '$mcp_tools_list' + end + + # PostHog property wire keys emitted on MCP events. + module Property + CLIENT_NAME = '$mcp_client_name' + CLIENT_USER_AGENT = '$mcp_client_user_agent' + CLIENT_VERSION = '$mcp_client_version' + VENDOR_CLIENT = '$mcp_vendor_client' + PROTOCOL_VERSION = '$mcp_protocol_version' + CONVERSATION_ID = '$mcp_conversation_id' + DURATION_MS = '$mcp_duration_ms' + ERROR_MESSAGE = '$mcp_error_message' + ERROR_TYPE = '$mcp_error_type' + IS_ERROR = '$mcp_is_error' + INTENT = '$mcp_intent' + INTENT_SOURCE = '$mcp_intent_source' + LISTED_TOOL_NAMES = '$mcp_listed_tool_names' + LLM_MODEL = '$mcp_llm_model' + LLM_MODEL_SOURCE = '$mcp_llm_model_source' + PARAMETERS = '$mcp_parameters' + RESOURCE_NAME = '$mcp_resource_name' + RESPONSE = '$mcp_response' + SERVER_NAME = '$mcp_server_name' + SERVER_VERSION = '$mcp_server_version' + SESSION_ID = '$session_id' + SOURCE = '$mcp_source' + TOOL_CATEGORY = '$mcp_tool_category' + TOOL_DESCRIPTION = '$mcp_tool_description' + TOOL_NAME = '$mcp_tool_name' + end + + # Internal dispatch keys for the event pipeline (never sent on the wire). + # + # @api private + module EventType + CUSTOM = 'posthog:custom' + IDENTIFY = 'posthog:identify' + MCP_INITIALIZE = 'mcp:initialize' + MCP_MISSING_CAPABILITY = 'mcp:missing_capability' + MCP_PROMPTS_GET = 'mcp:prompts/get' + MCP_PROMPTS_LIST = 'mcp:prompts/list' + MCP_RESOURCES_LIST = 'mcp:resources/list' + MCP_RESOURCES_READ = 'mcp:resources/read' + MCP_TOOLS_CALL = 'mcp:tools/call' + MCP_TOOLS_LIST = 'mcp:tools/list' + + EVENT_NAME_BY_TYPE = { + CUSTOM => Event::CUSTOM, + IDENTIFY => Event::IDENTIFY, + MCP_INITIALIZE => Event::INITIALIZE, + MCP_MISSING_CAPABILITY => Event::MISSING_CAPABILITY, + MCP_PROMPTS_GET => Event::PROMPT_GET, + MCP_PROMPTS_LIST => Event::PROMPTS_LIST, + MCP_RESOURCES_LIST => Event::RESOURCES_LIST, + MCP_RESOURCES_READ => Event::RESOURCE_READ, + MCP_TOOLS_CALL => Event::TOOL_CALL, + MCP_TOOLS_LIST => Event::TOOLS_LIST + }.freeze + end + end +end diff --git a/lib/posthog/mcp/conversation_id.rb b/lib/posthog/mcp/conversation_id.rb new file mode 100644 index 0000000..a550ce8 --- /dev/null +++ b/lib/posthog/mcp/conversation_id.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'json' + +module PostHog + module MCP + # Optional `conversation_id` loop-back. When enabled, the SDK injects a + # `conversation_id` parameter into every tool, mints one when the agent does + # not supply it, hands it back on the response, and captures it as + # `$mcp_conversation_id`, stitching calls across reconnects and pods. + # + # @api private + module ConversationId + PARAM_NAME = 'conversation_id' + MINTED_CONVERSATION_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i + + MCP_INSTRUCTIONS_KEY = '_mcp_instructions' + INSTRUCTIONS_FIELD_DESCRIPTION = 'Server-issued metadata for this conversation.' + CONVERSATION_ID_FIELD_DESCRIPTION = 'The server-issued conversation identifier.' + + module_function + + def normalize(value) + return nil unless value.is_a?(String) + + trimmed = value.strip + trimmed.empty? ? nil : trimmed + end + + def extract(args) + return nil unless args.is_a?(Hash) + + normalize(args[PARAM_NAME] || args[PARAM_NAME.to_sym]) + end + + # @param supplied [String, nil] the `conversation_id` argument, and only + # when this layer owns it. A tool that declares `conversation_id` in its + # own schema is passed application data, which must not anchor analytics: + # two users sharing such a value would be stitched into one conversation. + # @return [Array(String, Boolean), Array(nil, false)] `[conversation_id, minted]` + def resolve(enabled, supplied, tool_name, missing_capability_tool_name) + return [nil, false] if !enabled || tool_name == missing_capability_tool_name + + value = normalize(supplied) + return [value.downcase, false] if value && MINTED_CONVERSATION_ID.match?(value) + + [Ids.uuid_v7, true] + end + + def prompt_back?(result) + result.is_a?(Hash) && (result[:content] || result['content']).is_a?(Array) + end + + # Plain data, not an instruction: an instruction-shaped block is what a + # client's prompt-injection filter strips. Compact JSON. + def build_prompt_back(conversation_id) + { type: 'text', text: JSON.generate({ conversation_id: conversation_id }) } + end + + # @return [Hash] a new result with the prompt-back appended (or the input unchanged) + def inject_prompt_back(result, conversation_id) + return result unless prompt_back?(result) + + key = result.key?(:content) ? :content : 'content' + result.merge(key => result[key] + [build_prompt_back(conversation_id)]) + end + + # Mirror the handle into `structuredContent` for tools whose output schema + # declared `_mcp_instructions`. Customer data wins when the key exists. + # + # @return [Array(Object, Boolean)] `[result, delivered]` + def mirror_instructions(result, conversation_id) + return [result, false] unless result.is_a?(Hash) + + key = %i[structuredContent structured_content].find { |k| result.key?(k) } || + %w[structuredContent structured_content].find { |k| result.key?(k) } + return [result, false] if key.nil? + + structured = result[key] + return [result, false] unless structured.is_a?(Hash) + return [result, false] if structured.key?(MCP_INSTRUCTIONS_KEY) || structured.key?(MCP_INSTRUCTIONS_KEY.to_sym) + + payload = { 'conversation_id' => conversation_id } + instructions_key = structured.keys.first.is_a?(Symbol) ? MCP_INSTRUCTIONS_KEY.to_sym : MCP_INSTRUCTIONS_KEY + [result.merge(key => structured.merge(instructions_key => payload)), true] + end + end + end +end diff --git a/lib/posthog/mcp/event_builder.rb b/lib/posthog/mcp/event_builder.rb new file mode 100644 index 0000000..56a8763 --- /dev/null +++ b/lib/posthog/mcp/event_builder.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Translates a processed internal event (string keys) into one or two + # PostHog payloads: the main `$mcp_*` event plus an optional `$exception` + # sibling. + # + # @api private + module EventBuilder + module_function + + # @return [Array] payloads `{'event', 'distinct_id', 'properties', 'timestamp'}` + def build(event, enable_exception_autocapture: true) + batch = [build_capture_event(event)] + if event['is_error'] && truthy?(event['error']) && enable_exception_autocapture != false + batch << build_exception_event(event) + end + batch + end + + def distinct_id(event) + present(event['identify_actor_given_id']) || present(event['session_id']) || 'anonymous' + end + + def timestamp(event) + event['timestamp'] || Time.now.utc + end + + def build_capture_event(event) + properties = { Property::SOURCE => SOURCE } + add_session_id(event, properties) + add_conversation_id(event, properties) + add_person_processing(event, properties) + add_groups(event, properties) + add_common_properties(event, properties) + add_custom_properties(event, properties) + + name = present(event['event_name']) || EventType::EVENT_NAME_BY_TYPE.fetch(event['event_type'], Event::CUSTOM) + { 'event' => name, 'distinct_id' => distinct_id(event), 'properties' => properties, + 'timestamp' => timestamp(event) } + end + + def add_session_id(event, properties) + session_id = event['session_id'] + properties[Property::SESSION_ID] = session_id if session_id.is_a?(String) && !session_id.empty? + end + + def add_conversation_id(event, properties) + conversation_id = event['conversation_id'] + properties[Property::CONVERSATION_ID] = conversation_id unless conversation_id.nil? || conversation_id == '' + end + + def add_groups(event, properties) + groups = event['groups'] + properties['$groups'] = groups if truthy?(groups) + end + + # Without a resolved identity the distinct id is just the session id, so + # processing a person profile would mint one anonymous person per session. + def add_person_processing(event, properties) + properties['$process_person_profile'] = false unless present(event['identify_actor_given_id']) + end + + def tool_call?(event) + event['event_type'] == EventType::MCP_TOOLS_CALL + end + + def add_common_properties(event, properties) + if present(event['resource_name']) + properties[Property::RESOURCE_NAME] = event['resource_name'] + properties[Property::TOOL_NAME] = event['resource_name'] if tool_call?(event) + end + if present(event['tool_description']) && tool_call?(event) + properties[Property::TOOL_DESCRIPTION] = + event['tool_description'] + end + if present(event['tool_category']) && tool_call?(event) + properties[Property::TOOL_CATEGORY] = event['tool_category'] + end + listed = event['listed_tool_names'] + if listed.is_a?(Array) && !listed.empty? && event['event_type'] == EventType::MCP_TOOLS_LIST + properties[Property::LISTED_TOOL_NAMES] = listed + end + properties[Property::DURATION_MS] = event['duration'] unless event['duration'].nil? + properties[Property::SERVER_NAME] = event['server_name'] if present(event['server_name']) + properties[Property::SERVER_VERSION] = event['server_version'] if present(event['server_version']) + properties[Property::CLIENT_NAME] = event['client_name'] if present(event['client_name']) + properties[Property::CLIENT_VERSION] = event['client_version'] if present(event['client_version']) + properties[Property::CLIENT_USER_AGENT] = event['client_user_agent'] if present(event['client_user_agent']) + properties[Property::VENDOR_CLIENT] = event['vendor_client'] if present(event['vendor_client']) + properties[Property::PROTOCOL_VERSION] = event['protocol_version'] if present(event['protocol_version']) + properties[Property::INTENT] = event['user_intent'] if present(event['user_intent']) + properties[Property::INTENT_SOURCE] = event['user_intent_source'] if present(event['user_intent_source']) + properties[Property::LLM_MODEL] = event['llm_model'] if present(event['llm_model']) + properties[Property::LLM_MODEL_SOURCE] = event['llm_model_source'] if present(event['llm_model_source']) + properties[Property::IS_ERROR] = event['is_error'] unless event['is_error'].nil? + add_error_details(event, properties) if event['is_error'] + properties[Property::PARAMETERS] = event['parameters'] unless event['parameters'].nil? + properties[Property::RESPONSE] = event['response'] unless event['response'].nil? + actor_data = event['identify_actor_data'] + properties['$set'] = actor_data.dup if actor_data.is_a?(Hash) && !actor_data.empty? + end + + # Surface the failure reason on the primary event itself, so dashboards + # need not join to the `$exception` sibling (which can be switched off). + def add_error_details(event, properties) + first = Exceptions.primary_exception(event['error']) + error_type = present(event['error_type']) || present(first['type']) + properties[Property::ERROR_TYPE] = error_type if error_type + message = first['value'] + properties[Property::ERROR_MESSAGE] = message if present(message) + end + + def add_custom_properties(event, properties) + custom = event['properties'] + return unless custom.is_a?(Hash) + + custom.each { |key, value| properties[key.to_s] = value } + end + + def build_exception_event(event) + properties = {} + add_session_id(event, properties) + add_conversation_id(event, properties) + add_person_processing(event, properties) + add_groups(event, properties) + + error = event['error'] + properties.merge!(error) if error.is_a?(Hash) + + if present(event['resource_name']) + properties[Property::RESOURCE_NAME] = event['resource_name'] + properties[Property::TOOL_NAME] = event['resource_name'] if tool_call?(event) + end + if present(event['tool_description']) && tool_call?(event) + properties[Property::TOOL_DESCRIPTION] = + event['tool_description'] + end + if present(event['tool_category']) && tool_call?(event) + properties[Property::TOOL_CATEGORY] = event['tool_category'] + end + properties[Property::SERVER_NAME] = event['server_name'] if present(event['server_name']) + properties[Property::SERVER_VERSION] = event['server_version'] if present(event['server_version']) + properties[Property::CLIENT_NAME] = event['client_name'] if present(event['client_name']) + properties[Property::CLIENT_VERSION] = event['client_version'] if present(event['client_version']) + properties[Property::PROTOCOL_VERSION] = event['protocol_version'] if present(event['protocol_version']) + + add_custom_properties(event, properties) + + { 'event' => Event::EXCEPTION, 'distinct_id' => distinct_id(event), 'properties' => properties, + 'timestamp' => timestamp(event) } + end + + def present(value) + return nil if value.nil? + return nil if value.respond_to?(:empty?) && value.empty? + return nil if value == false + + value + end + + def truthy?(value) + !present(value).nil? + end + end + end +end diff --git a/lib/posthog/mcp/exceptions.rb b/lib/posthog/mcp/exceptions.rb new file mode 100644 index 0000000..f28d5aa --- /dev/null +++ b/lib/posthog/mcp/exceptions.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'posthog/exception_capture' + +module PostHog + module MCP + # Builds PostHog error-tracking properties (`$exception_list` / + # `$exception_level`) from anything a tool can fail with, reusing + # {PostHog::ExceptionCapture} so MCP failures group like every other + # exception the Ruby SDK reports. + # + # @api private + module Exceptions + GENERIC_MECHANISM = { 'type' => 'generic', 'handled' => true }.freeze + + # Messages the Ruby `mcp` gem wraps around whatever a tool raised. + DISPATCH_WRAPPER_TYPE = 'MCP::Server::RequestHandlerError' + DISPATCH_WRAPPER_PREFIXES = ['Internal error calling tool', 'Internal error handling'].freeze + + module_function + + # @param error [Exception, String, Hash, Object] exception, message, or an + # `isError` tool result (`{content: [...], isError: true}`) + # @return [Hash] `{'$exception_list' => [...], '$exception_level' => 'error'}` + def capture_exception(error) + return from_message(call_tool_result_message(error)) if call_tool_result?(error) + return from_exception(error) if error.is_a?(Exception) + return from_message(error) if error.is_a?(String) + + from_message(safe_to_s(error)) + end + + def from_exception(error) + list = PostHog::ExceptionCapture.build_exception_list(error) || [] + original = error.respond_to?(:original_error) ? error.original_error : nil + if original.is_a?(Exception) && !chain_includes?(error, original) + list.concat(PostHog::ExceptionCapture.build_exception_list(original) || []) + end + list = [message_entry(error.class.to_s, error.message.to_s)] if list.empty? + { '$exception_list' => list, '$exception_level' => 'error' } + end + + def from_message(message) + { '$exception_list' => [message_entry('Error', message)], '$exception_level' => 'error' } + end + + def message_entry(type, message) + { 'mechanism' => GENERIC_MECHANISM.dup, 'type' => type, 'value' => message } + end + + # Whether an `$exception_list` entry is the gem's dispatch wrapper, whose + # message says nothing the tool name does not already say. + def dispatch_wrapper?(entry) + return false unless entry.is_a?(Hash) + + value = entry['value'].to_s + entry['type'] == DISPATCH_WRAPPER_TYPE && DISPATCH_WRAPPER_PREFIXES.any? { |p| value.start_with?(p) } + end + + # The `$exception_list` entry carrying the actual failure reason, stepping + # past consecutive dispatch wrappers. + def primary_exception(error) + return {} unless error.is_a?(Hash) + + list = error['$exception_list'] + return {} unless list.is_a?(Array) && !list.empty? + + index = 0 + index += 1 while index + 1 < list.length && list[index + 1].is_a?(Hash) && dispatch_wrapper?(list[index]) + list[index].is_a?(Hash) ? list[index] : {} + end + + def call_tool_result?(value) + return false unless value.is_a?(Hash) + + content = value['content'] || value[:content] + (value.key?('isError') || value.key?(:isError)) && content.is_a?(Array) + end + + def call_tool_result_message(result) + content = result['content'] || result[:content] || [] + texts = content.filter_map do |part| + next unless part.is_a?(Hash) + + type = part['type'] || part[:type] + text = part['text'] || part[:text] + text if type == 'text' && text.is_a?(String) + end + joined = texts.join(' ').strip + joined.empty? ? 'Unknown error' : joined + end + + def chain_includes?(error, target) + current = error.cause + seen = {}.compare_by_identity + while current && !seen.key?(current) + return true if current.equal?(target) + + seen[current] = true + current = current.cause + end + false + end + + def safe_to_s(value) + value.to_s + rescue StandardError + 'Unknown error' + end + end + end +end diff --git a/lib/posthog/mcp/identity.rb b/lib/posthog/mcp/identity.rb new file mode 100644 index 0000000..74c355c --- /dev/null +++ b/lib/posthog/mcp/identity.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require 'json' + +module PostHog + module MCP + # Bounded LRU of session identities, isolated per server. + # + # @api private + class IdentityCache + def initialize(max_size = 1000) + @cache = {} + @max_size = max_size + @mutex = Mutex.new + end + + def get(session_id) + @mutex.synchronize do + identity = @cache.delete(session_id) + next nil if identity.nil? + + @cache[session_id] = identity + end + end + + def set(session_id, identity) + @mutex.synchronize do + @cache.delete(session_id) + @cache.shift if @cache.length >= @max_size + @cache[session_id] = identity + end + end + + # Atomically read the cached identity, merge the new one, store it, and + # report whether it changed. Keeps concurrent requests for one session + # from interleaving their read-merge-write. + # + # @return [Array(UserIdentity, Boolean)] `[merged, changed]` + def merge!(session_id, identity) + @mutex.synchronize do + previous = @cache.delete(session_id) + merged = Identity.merge_identities(previous, identity) + changed = !(previous && Identity.identities_equal?(previous, merged)) + @cache.shift if @cache.length >= @max_size + @cache[session_id] = merged + [merged, changed] + end + end + + def has?(session_id) + @mutex.synchronize { @cache.key?(session_id) } + end + + def size + @mutex.synchronize { @cache.length } + end + end + + # Identity resolution: runs the `identify` option, dedupes against the + # per-server cache, and decides when a standalone `$identify` event fires. + # + # @api private + module Identity + module_function + + def identities_equal?(first, second) + return false if first.distinct_id != second.distinct_id + return false if sorted_json(first.groups || {}) != sorted_json(second.groups || {}) + + a_props = first.properties || {} + b_props = second.properties || {} + return false if a_props.keys.map(&:to_s).sort != b_props.keys.map(&:to_s).sort + + a_props.all? do |key, value| + other = if b_props.key?(key) + b_props[key] + else + b_props[key.is_a?(Symbol) ? key.to_s : key.to_s.to_sym] + end + sorted_json(value) == sorted_json(other) + end + end + + def merge_identities(previous, nxt) + return nxt if previous.nil? + + UserIdentity.new( + distinct_id: nxt.distinct_id, + properties: (previous.properties || {}).merge(nxt.properties || {}), + groups: nxt.groups.nil? ? previous.groups : nxt.groups + ) + end + + # Resolve the optional `identify` callback for one request: the identity the + # request's events belong to, plus an `$identify` event to emit only when + # that identity has materially changed. + # + # The actor is handed back rather than left for the caller to read out of + # {IdentityCache} later. The cache is keyed by session, and a request's + # events are built after its handler returns, so a concurrent request on + # the same session would otherwise decide who this one is attributed to. + # When resolution yields nothing the cache is read once, here, so whatever + # a request is attributed to it is attributed to consistently. + # + # @return [Array(Hash, UserIdentity), Array(nil, UserIdentity), Array(nil, nil)] `[event, actor]` + def identify_for_request(data, session_id, request, extra) + identify = data.options.identify + return [nil, nil] unless identify + + result = if identify.is_a?(UserIdentity) || identify.is_a?(Hash) + identify + else + Callbacks.call(identify, request, + extra) + end + identity = UserIdentity.coerce(result) + unless identity + Log.debug(data.options, "Warning: Supplied identify function returned null for session #{session_id}") + return [nil, data.identified_sessions.get(session_id)] + end + + merged, changed = data.identified_sessions.merge!(session_id, identity) + return [nil, merged] unless changed + + Log.debug(data.options, "Identified session #{session_id}") + [{ + 'session_id' => session_id, + 'resource_name' => request_resource_name(request), + 'event_type' => EventType::IDENTIFY, + 'parameters' => { 'request' => request, 'extra' => captured_extra(extra) }, + 'timestamp' => Time.now.utc + }, merged] + rescue StandardError => e + Log.debug(data.options, "Error: identify function threw while identifying session #{session_id} - #{e.message}") + [nil, data.identified_sessions.get(session_id)] + end + + # @return [Hash, nil] the `$identify` event alone; see {identify_for_request}. + def handle_identify(data, session_id, request, extra) + identify_for_request(data, session_id, request, extra).first + end + + def request_resource_name(request) + return 'Unknown' unless request.is_a?(Hash) + + params = request[:params] || request['params'] + return 'Unknown' unless params.is_a?(Hash) + + name = params[:name] || params['name'] + name.is_a?(String) ? name : 'Unknown' + end + + # Only JSON scalars from `extra` are captured; never opaque transport objects. + def captured_extra(extra) + return nil unless extra.is_a?(Hash) + + extra.select do |_, value| + value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false + end + end + + def sorted_json(value) + JSON.generate(sort_deep(value)) + rescue StandardError + value.to_s + end + + def sort_deep(value) + case value + when Hash then value.map { |k, v| [k.to_s, sort_deep(v)] }.sort_by(&:first).to_h + when Array then value.map { |v| sort_deep(v) } + when String, Numeric, true, false, nil then value + else value.to_s + end + end + end + + # Invokes user callbacks with `(request, extra)`, tolerating 1-arity lambdas. + # + # @api private + module Callbacks + module_function + + def call(callable, request, extra) + arity = callable.respond_to?(:arity) ? callable.arity : 2 + case arity + when 0 then callable.call + when 1 then callable.call(request) + else callable.call(request, extra) + end + end + end + end +end diff --git a/lib/posthog/mcp/ids.rb b/lib/posthog/mcp/ids.rb new file mode 100644 index 0000000..2674d1a --- /dev/null +++ b/lib/posthog/mcp/ids.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'securerandom' + +module PostHog + module MCP + # Id generation: `evt_` / `ses_` and the deterministic FNV-1a + # derivation used for session ids that must agree across servers and restarts. + # + # @api private + module Ids + module_function + + # RFC 9562 UUIDv7 (time-ordered), implemented inline so Ruby 3.0/3.1 work too. + # + # @return [String] lowercase, hyphenated uuid + def uuid_v7 + unix_ts_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) & ((1 << 48) - 1) + rand_a = SecureRandom.random_number(1 << 12) + rand_b = SecureRandom.random_number(1 << 62) + + value = unix_ts_ms << 80 + value |= 0x7 << 76 + value |= rand_a << 64 + value |= 0b10 << 62 + value |= rand_b + + hex = format('%032x', value) + "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}" + end + + # @param prefix [String] `'evt'` or `'ses'` + def new_prefixed_id(prefix) + "#{prefix}_#{uuid_v7}" + end + + # Deterministic id derived from an arbitrary string. FNV-1a 64-bit mixed + # twice to fill 32 hex chars. Not cryptographic; only stable and low-collision. + # + # Iterates code points; session/conversation ids are ASCII in practice. + def deterministic_prefixed_id(prefix, value) + "#{prefix}_#{fnv1a_hex(value)}#{fnv1a_hex("#{value}::salt")}" + end + + def fnv1a_hex(value) + h1 = 0x84222325 + h2 = 0xcbf29ce4 + value.to_s.each_codepoint do |c| + h1 = ((h1 ^ c) * 0x000001b3) & 0xffffffff + h2 = ((h2 ^ c) * 0x00000193) & 0xffffffff + end + format('%

08x%

08x', h1: h1, h2: h2) + end + end + end +end diff --git a/lib/posthog/mcp/instrumentation.rb b/lib/posthog/mcp/instrumentation.rb new file mode 100644 index 0000000..3736fac --- /dev/null +++ b/lib/posthog/mcp/instrumentation.rb @@ -0,0 +1,729 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # One JSON-RPC request's analytics lifecycle. Created by {ServerExtension} + # around the dispatch lambda `MCP::Server#handle_request` returns, so it + # sees the raw request, the params Hash the handler will receive (and may + # strip injected arguments from it), the session, the result, and any + # raised error. Everything it needs travels through its own instance + # variables; nothing is read from the gem's `@instrumentation_data`. + # + # Analytics failures are logged and swallowed: the handler's result or + # exception is always returned or re-raised unchanged. + # + # @api private + class Instrumentation + TRACKED_METHODS = { + 'initialize' => :initialize, + 'tools/list' => :tools_list, + 'tools/call' => :tools_call, + 'prompts/get' => :prompts_get, + 'prompts/list' => :prompts_list, + 'resources/read' => :resources_read, + 'resources/list' => :resources_list + }.freeze + + GENERIC_EVENT_TYPES = { + prompts_get: EventType::MCP_PROMPTS_GET, + prompts_list: EventType::MCP_PROMPTS_LIST, + resources_read: EventType::MCP_RESOURCES_READ, + resources_list: EventType::MCP_RESOURCES_LIST + }.freeze + + MODERN_PROTOCOL_REVISION = '2026-07-28' + REVISION_SHAPE = /\A\d{4}-\d{2}-\d{2}\z/ + DRAFT_REVISION = 'draft' + META_CLIENT_INFO_KEY = 'io.modelcontextprotocol/clientInfo' + META_PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion' + INJECTED_PARAMS = ['context', ConversationId::PARAM_NAME, ModelCapture::PARAM_NAME].freeze + + # Passed as `actor:` by a caller that has no request identity to offer, and + # is distinct from an explicit `nil` (a request that resolved to nobody). + UNRESOLVED_ACTOR = Object.new.freeze + + class << self + def tracked?(method) + TRACKED_METHODS.key?(method) + end + + # Enrich an event with session/identity/server metadata and hand it to + # the sink. + # + # @param actor [UserIdentity, nil, Object] the identity resolved for the + # request this event belongs to, pinned when the request started. Only + # {UNRESOLVED_ACTOR} falls back to the session-keyed cache, which a + # concurrent request on the same session may have moved on since. + def capture_event(data, input, actor: UNRESOLVED_ACTOR) + sink = data.sink + return nil if sink.nil? + + session_id = input['session_id'] || data.session_id + actor = (session_id ? data.identified_sessions.get(session_id) : nil) if actor.equal?(UNRESOLVED_ACTOR) + timestamp = input['timestamp'] || Time.now.utc + duration = input['duration'] + duration = (Time.now - timestamp) * 1000.0 if duration.nil? && input['timestamp'] + + full = input.merge( + 'session_id' => session_id, + 'event_type' => input['event_type'] || EventType::CUSTOM, + 'timestamp' => timestamp, + 'duration' => duration, + 'server_name' => data.server_name, + 'server_version' => data.server_version, + 'identify_actor_given_id' => actor&.distinct_id, + 'identify_actor_data' => actor ? (actor.properties || {}) : {}, + 'groups' => actor&.groups + ) + sink.capture(full, data.options) + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def legacy_era?(protocol_version) + return true unless protocol_version.is_a?(String) && !protocol_version.empty? + return false if protocol_version == DRAFT_REVISION + + !(REVISION_SHAPE.match?(protocol_version) && protocol_version >= MODERN_PROTOCOL_REVISION) + end + end + + def initialize(server, data, method:, request:, params:, session: nil, request_id: nil) + @server = server + @data = data + @options = data.options + @kind = TRACKED_METHODS.fetch(method) + @method = method + @request = request.is_a?(Hash) ? request : {} + @params = params.is_a?(Hash) ? params : {} + @session = session + @request_id = request_id + @scope = RequestScope.current + @headers = @scope ? (@scope[:headers] || {}) : {} + @token = SessionToken.decode(header_session_id) + @data.http_transport_seen = true if http? + @identified_in_request = {} + end + + # Runs the wrapped handler and records the request. + def dispatch(&handler) + @start = self.class.monotonic_now + return dispatch_kind(&handler) if @scope + + # Only the Streamable HTTP transport publishes a scope. Opening one for + # every other transport too (stdio, a custom dispatcher) means the session + # settled before the tool body runs is the one {Analytics#capture} reads + # inside it, whatever the request is anchored on. + RequestScope.with(headers: {}, transport: :other) do |scope| + @scope = scope + dispatch_kind(&handler) + end + end + + private + + def dispatch_kind(&handler) + case @kind + when :tools_call then dispatch_tool_call(&handler) + when :tools_list then dispatch_tools_list(&handler) + when :initialize then dispatch_initialize(&handler) + else dispatch_generic(&handler) + end + end + + # --- dispatchers ------------------------------------------------------- + + def dispatch_tool_call + name = fetch(@params, :name) + arguments = fetch(@params, :arguments) + arguments = nil unless arguments.is_a?(Hash) + original_arguments = arguments&.dup + missing_name = Tools.missing_capability_tool_name(@options) + owned = safely([]) { owned_params_for(name) } + stripped = safely({}) { strip_injected_arguments(arguments, owned) } + # Resolution reads the stripped values, never `original_arguments`: only an + # argument this layer injected is ours to interpret. A `conversation_id` or + # `context` the tool declares itself stays application data and is left to it. + conversation_id, minted = safely([nil, false]) do + ConversationId.resolve(@options.enable_conversation_id, stripped[ConversationId::PARAM_NAME], name, + missing_name) + end + request = request_with_arguments(original_arguments) + + if virtual_tool?(name) + # Run the gem's own handler so validation, in-flight tracking and + # cancellation behave exactly as for any other tool; only the event differs. + begin + result = yield + ensure + safely { record_missing_capability(name, original_arguments, request) } + end + return result + end + + safely { prime_session(request, minted ? nil : conversation_id) } + begin + result = yield + rescue StandardError => e + safely do + cid = minted ? nil : conversation_id + session_id = prepare_request(request, conversation_id: cid) + record_tool_call(session_id, name, request, error: e, conversation_id: cid, stripped: stripped) + end + raise + end + + delivered = false + # The handle appended below is unique per conversation, so an error read + # off the delivered result would give every identical failure a different + # `$mcp_error_message`. Grouping reads the result the tool returned. + error_source = result + unless no_response?(result) || conversation_id.nil? + safely do + if @data.tool_output_instructions[name] + result, delivered = ConversationId.mirror_instructions(result, conversation_id) + end + if minted + with_prompt_back = ConversationId.inject_prompt_back(result, conversation_id) + delivered ||= !with_prompt_back.equal?(result) + result = with_prompt_back + end + end + end + + safely do + cid = minted && !delivered ? nil : conversation_id + session_id = prepare_request(request, conversation_id: cid) + record_tool_call(session_id, name, request, result: result, conversation_id: cid, + stripped: stripped, error_source: error_source) + end + result + end + + def dispatch_tools_list + result = begin + yield + rescue StandardError => e + safely { record_tools_list(prepare_request(@request), names: [], error: e) } + raise + end + + return result if no_response?(result) + + names = [] + empty = false + safely do + tools = fetch(result, :tools) + if tools.is_a?(Array) + names = tools.map { |tool| fetch(tool, :name) }.compact + empty = tools.empty? + mutated = tools.map { |tool| mutate_tool(tool) } + result = result.merge(SchemaMutation.key_for(result, :tools) => mutated) + end + end + + safely do + session_id = prepare_request(@request) + record_tools_list(session_id, names: names, response: result, empty: empty) + end + result + end + + def dispatch_initialize + client_info = fetch(@params, :clientInfo) + client_name = client_info.is_a?(Hash) ? fetch(client_info, :name) : nil + client_version = client_info.is_a?(Hash) ? fetch(client_info, :version) : nil + requested_version = fetch(@params, :protocolVersion) + + result = begin + yield + rescue StandardError => e + safely do + session_id = prepare_request(@request, skip_initialize: true) + record_initialize(session_id, client_name, client_version, requested_version, error: e) + end + raise + end + + safely do + negotiated = (result.is_a?(Hash) ? fetch(result, :protocolVersion) : nil) || requested_version + minted = mint_session_token(client_name, client_version, negotiated, requested_version) + session_id = prepare_request(@request, skip_initialize: true, token: minted) + record_initialize(session_id, client_name, client_version, negotiated, response: result) + end + result + end + + def dispatch_generic + result = begin + yield + rescue StandardError => e + safely { record_generic(prepare_request(@request), error: e) } + raise + end + safely { record_generic(prepare_request(@request), result: result) } + result + end + + # --- recording --------------------------------------------------------- + + def record_tool_call(session_id, name, request, result: nil, error: nil, conversation_id: nil, stripped: {}, + error_source: nil) + event = base_event(EventType::MCP_TOOLS_CALL, session_id, request) + event['resource_name'] = name + event['tool_description'] = @data.tool_descriptions[name] + event['tool_category'] = @data.tool_categories[name] + event['parameters'] = Sanitization.build_captured_mcp_parameters(request) + event['conversation_id'] = conversation_id + event['is_error'] = false + + intent = Intent.resolve(@data, request, extra, stripped['context']) + if intent + event['user_intent'] = intent[0] + event['user_intent_source'] = intent[1] + end + if @options.capture_model_enabled? + model = ModelCapture.resolve(request, stripped[ModelCapture::PARAM_NAME]) + if model + event['llm_model'] = model[0] + event['llm_model_source'] = model[1] + end + end + + if error + event['is_error'] = true + event['error'] = Exceptions.capture_exception(error) + elsif !result.nil? && !no_response?(result) + event['response'] = result + source = error_source.nil? ? result : error_source + if tool_result_error?(source) + event['is_error'] = true + event['error'] = Exceptions.capture_exception(Sanitization.stringify_keys(source)) + end + end + + finish_event(event, request) + end + + def record_missing_capability(name, arguments, request) + session_id = prepare_request(request) + event = base_event(EventType::MCP_MISSING_CAPABILITY, session_id, request) + event.delete('duration') + event['resource_name'] = name + event['parameters'] = Sanitization.build_captured_mcp_parameters(request) + context = arguments.is_a?(Hash) ? (arguments[:context] || arguments['context']) : nil + if context.is_a?(String) && !context.strip.empty? + event['user_intent'] = context.strip + event['user_intent_source'] = 'context_parameter' + end + if @options.capture_model_enabled? + model = ModelCapture.resolve(request, self_reported_model(arguments)) + if model + event['llm_model'] = model[0] + event['llm_model_source'] = model[1] + end + end + finish_event(event, request) + end + + # The virtual tool declares `llm_model` itself, so the argument is never + # stripped and is read straight off the call. + def self_reported_model(arguments) + return nil unless arguments.is_a?(Hash) + + arguments[ModelCapture::PARAM_NAME] || arguments[ModelCapture::PARAM_NAME.to_sym] + end + + def record_tools_list(session_id, names:, response: nil, empty: false, error: nil) + event = base_event(EventType::MCP_TOOLS_LIST, session_id, @request) + event['listed_tool_names'] = names + event['parameters'] = Sanitization.build_captured_mcp_parameters(@request) + event['response'] = response unless response.nil? || no_response?(response) + event['is_error'] = !error.nil? || empty + event['timestamp'] = Time.now.utc + if error + event['error'] = Exceptions.capture_exception(error) + elsif empty + event['error'] = Exceptions.capture_exception('tools/list returned no tools') + end + finish_event(event, @request) + end + + def record_initialize(session_id, client_name, client_version, protocol_version, response: nil, error: nil) + @data.mark_session_initialized(session_id) + event = base_event(EventType::MCP_INITIALIZE, session_id, @request) + event['client_name'] = client_name + event['client_version'] = client_version + event['protocol_version'] = protocol_version + event['parameters'] = Sanitization.build_captured_mcp_parameters(@request) + event['response'] = response unless response.nil? || no_response?(response) + if error + event['is_error'] = true + event['error'] = Exceptions.capture_exception(error) + end + finish_event(event, @request) + end + + def record_generic(session_id, result: nil, error: nil) + event = base_event(GENERIC_EVENT_TYPES.fetch(@kind), session_id, @request) + event['resource_name'] = generic_resource_name + event['parameters'] = Sanitization.build_captured_mcp_parameters(@request) + event['response'] = result unless result.nil? || no_response?(result) + event['is_error'] = !error.nil? + event['error'] = Exceptions.capture_exception(error) if error + finish_event(event, @request) + end + + def base_event(event_type, session_id, request) + event = { + 'event_type' => event_type, + 'session_id' => session_id, + 'duration' => duration_ms, + 'client_name' => nil, + 'client_version' => nil, + 'protocol_version' => protocol_version + } + name, version = client_identity(request) + event['client_name'] = name + event['client_version'] = version + event + end + + def finish_event(event, request) + props = resolve_event_properties(request) + event['properties'] = props unless props.nil? + TransportIdentity.stamp(event, @headers) + self.class.capture_event(@data, event, actor: actor_for(event['session_id'])) + end + + # The identity this request resolved for the session the event is filed + # under. {#prepare_request} always runs first for that session, so the key + # is present; a missing one means nobody identified and stays nil. + def actor_for(session_id) + @identified_in_request[session_id] + end + + def resolve_event_properties(request) + callback = @options.event_properties + return nil unless callback + + result = Callbacks.call(callback, request, extra) + result.is_a?(Hash) && !result.empty? ? result : nil + rescue StandardError => e + Log.debug(@options, "event_properties callback error: #{e.message}") + nil + end + + # --- session / identity ----------------------------------------------- + + # Settle session and identity before the tool body runs, and pin the session + # to the request scope. {Analytics#capture} reads both from there, so a + # custom event emitted inside a tool belongs to its caller rather than to + # whichever request finished last (or is running concurrently), and carries + # the same identified person as the `$mcp_tool_call` that follows it. + # + # `conversation_id` is passed when the agent echoed one back: that anchor is + # already known, so priming resolves the same session the tool call will be + # recorded under. A minted handle is not known to have reached the agent + # until the call returns, so it stays out of here. {#prepare_request} runs + # again after the call; the second run is idempotent. + def prime_session(request, conversation_id) + session_id = prepare_request(request, conversation_id: conversation_id) + @scope[:session_id] = session_id if @scope.is_a?(Hash) + end + + # Resolve the session id, run identify, then lazily emit initialize. + def prepare_request(request, conversation_id: nil, skip_initialize: false, token: nil) + token ||= @token + session_id, source = Session.resolve(@data, mcp_session_id(token), token: token, + conversation_id: conversation_id) + warn_stateless_session_not_wired if source == 'generated' && http? + + # A tool call prepares twice: once before the body to pin the session, and + # once after it, when the conversation anchor is known. A customer's + # `identify` callback runs once per session per request, so preparing + # twice never asks it the same question twice. + unless @identified_in_request.key?(session_id) + identify_event, actor = Identity.identify_for_request(@data, session_id, request, extra) + # Pin what this request resolved. Every event it emits afterwards - + # including one a tool body captures through {Analytics} - is attributed + # to this, never to a re-read of the cache another request may have + # overwritten while the handler ran. + @identified_in_request[session_id] = actor + @scope[:actor] = actor if @scope.is_a?(Hash) + self.class.capture_event(@data, identify_event, actor: actor) if identify_event + end + maybe_emit_initialize(session_id, request) unless skip_initialize + session_id + end + + def maybe_emit_initialize(session_id, request) + # Claiming is atomic: two threads opening the same session concurrently + # must not both get past the check and emit an initialize each. + return unless @data.claim_session_initialized(session_id) + + name, version = client_identity(request) + event = { + 'event_type' => EventType::MCP_INITIALIZE, + 'session_id' => session_id, + 'client_name' => name, + 'client_version' => version, + 'protocol_version' => protocol_version, + 'timestamp' => Time.now.utc + } + props = resolve_event_properties({ method: 'initialize', params: {} }) + event['properties'] = props unless props.nil? + TransportIdentity.stamp(event, @headers) + self.class.capture_event(@data, event, actor: actor_for(session_id)) + end + + # Era is decided by the version the client *asked for*: + # a client declaring the 2026-07-28 revision or later must not be answered + # with an `Mcp-Session-Id`, even though this gem counter-offers a legacy version. + def mint_session_token(client_name, client_version, protocol_version, requested_version) + return nil unless http? && @scope + return nil if header_session_id || @session&.session_id + return nil unless self.class.legacy_era?(requested_version) + + payload = SessionTokenPayload.new( + session_id: Session.new_session_id, + client_name: client_name.is_a?(String) ? client_name : nil, + client_version: client_version.is_a?(String) ? client_version : nil, + protocol_version: protocol_version.is_a?(String) ? protocol_version : nil + ) + @scope[:mint] = SessionToken.encode(payload) + payload + end + + def warn_stateless_session_not_wired + return if @data.warned_no_stateless_session + + @data.warned_no_stateless_session = true + Log.warn( + @options, + 'Warning: an MCP request arrived over streamable HTTP with no session id, so PostHog generated a ' \ + 'per-process $session_id that will fragment across requests and pods. In stateless mode the ' \ + 'client must replay the Mcp-Session-Id header PostHog::MCP mints at initialize; for a custom Rack ' \ + 'stack add PostHog::MCP::RackMiddleware. Enabling conversation ids ' \ + '(PostHog::MCP.instrument(server, enable_conversation_id: true)) also anchors the session without ' \ + 'any middleware. See https://posthog.com/docs/mcp-analytics/installation#ruby.' + ) + end + + # --- request context --------------------------------------------------- + + def header_session_id + SessionToken.read_header(@headers) + end + + # The transport's own session id (never our token). + def mcp_session_id(token) + transport_id = @session.respond_to?(:session_id) ? @session.session_id : nil + return transport_id if transport_id.is_a?(String) && !transport_id.empty? + + token ? nil : header_session_id + end + + def http? + @scope.is_a?(Hash) && @scope[:transport] == :http + end + + def envelope_meta + meta = fetch(@params, :_meta) + meta.is_a?(Hash) ? meta : nil + end + + def client_identity(request) + info = envelope_client_info || session_client || server_client + info = fetch(request_params(request), :clientInfo) if info.nil? && @kind == :initialize + name = info.is_a?(Hash) ? fetch(info, :name) : nil + version = info.is_a?(Hash) ? fetch(info, :version) : nil + name ||= @token&.client_name + version ||= @token&.client_version + [name, version] + end + + def envelope_client_info + meta = envelope_meta + return nil unless meta + + meta[META_CLIENT_INFO_KEY] || meta[META_CLIENT_INFO_KEY.to_sym] + end + + def session_client + @session.respond_to?(:client) ? @session.client : nil + end + + def server_client + @server.instance_variable_defined?(:@client) ? @server.instance_variable_get(:@client) : nil + end + + def protocol_version + meta = envelope_meta + from_meta = meta ? (meta[META_PROTOCOL_VERSION_KEY] || meta[META_PROTOCOL_VERSION_KEY.to_sym]) : nil + return from_meta if from_meta.is_a?(String) + + from_session = @session.respond_to?(:protocol_version) ? @session.protocol_version : nil + return from_session if from_session.is_a?(String) + + if @server.instance_variable_defined?(:@client_protocol_version) + from_server = @server.instance_variable_get(:@client_protocol_version) + return from_server if from_server.is_a?(String) + end + + @headers['mcp-protocol-version'] || @token&.protocol_version + end + + def extra + @extra ||= { + 'session_id' => (@session.respond_to?(:session_id) ? @session.session_id : nil) || header_session_id, + 'request_id' => @request_id, + 'protocol_version' => protocol_version, + 'headers' => @headers, + 'session' => @session + } + end + + def request_params(request) + params = request.is_a?(Hash) ? (request[:params] || request['params']) : nil + params.is_a?(Hash) ? params : {} + end + + def request_with_arguments(arguments) + params = @params.merge(SchemaMutation.key_for(@params, :arguments) => arguments) + @request.merge(SchemaMutation.key_for(@request, :params) => params) + end + + def generic_resource_name + case @kind + when :prompts_get then fetch(@params, :name) + when :resources_read then fetch(@params, :uri) + end + end + + def duration_ms + (self.class.monotonic_now - @start) * 1000.0 + end + + # --- tools ------------------------------------------------------------- + + # True only for the `get_more_tools` class {Tools.register} added; an + # application tool that shares the name is an ordinary tool. + def virtual_tool?(name) + return false if @data.virtual_tool.nil? + + tools = @server.respond_to?(:tools) ? @server.tools : nil + tools.is_a?(Hash) && tools[name].equal?(@data.virtual_tool) + end + + # Injected argument names the analytics layer owns for this tool: the ones + # it injected at tools/list, or (never listed) the ones the tool's own + # schema does not declare. A composed or referenced schema is never + # injected into, so nothing in it is ours to strip either - the same guard + # {SchemaMutation.add_parameter} uses, so a call before the first + # tools/list behaves exactly like one after it. + def owned_params_for(name) + cached = @data.tool_owned_params[name] + return cached if cached + + tools = @server.respond_to?(:tools) ? @server.tools : nil + tool = tools.is_a?(Hash) ? tools[name] : nil + schema = tool.respond_to?(:input_schema) ? tool.input_schema&.to_h : nil + return [] unless SchemaMutation.injectable?(schema) + + owned = [] + owned << 'context' if @options.context_enabled? && !SchemaMutation.declares_param?(schema, 'context') + owned << ConversationId::PARAM_NAME if @options.enable_conversation_id && + !SchemaMutation.declares_param?(schema, ConversationId::PARAM_NAME) + owned << ModelCapture::PARAM_NAME if @options.capture_model_enabled? && + !SchemaMutation.declares_param?(schema, ModelCapture::PARAM_NAME) + owned + end + + # Remove SDK-owned arguments in place before the tool receives them as + # keywords (an unknown keyword would raise). Returns the stripped values. + def strip_injected_arguments(arguments, owned) + stripped = {} + return stripped unless arguments.is_a?(Hash) + + owned.each do |param| + [param.to_sym, param].each do |key| + next unless arguments.key?(key) + + value = arguments.delete(key) + stripped[param] = value if stripped[param].nil? + end + end + stripped + end + + def mutate_tool(tool) + return tool unless tool.is_a?(Hash) + + name = fetch(tool, :name) + return tool if virtual_tool?(name) + + schema = fetch(tool, :inputSchema) + owned = [] + if @options.context_enabled? + updated = SchemaMutation.add_context_parameter( + schema, tool_name: name, description: @options.context_description, options: @options + ) + owned << 'context' unless updated.equal?(schema) + schema = updated + end + if @options.enable_conversation_id + updated = SchemaMutation.add_conversation_id_parameter(schema, tool_name: name, options: @options) + owned << ConversationId::PARAM_NAME unless updated.equal?(schema) + schema = updated + end + if @options.capture_model_enabled? + updated = SchemaMutation.add_model_parameter( + schema, tool_name: name, description: @options.model_description, options: @options + ) + owned << ModelCapture::PARAM_NAME unless updated.equal?(schema) + schema = updated + end + + mutated = tool.merge(SchemaMutation.key_for(tool, :inputSchema) => schema) + declared = nil + if @options.enable_conversation_id + output_schema = fetch(tool, :outputSchema) + new_output, declared = SchemaMutation.add_output_instructions(output_schema, tool_name: name, + options: @options) + mutated = mutated.merge(SchemaMutation.key_for(tool, :outputSchema) => new_output) if declared && new_output + end + + meta = fetch(tool, :_meta) + category = meta.is_a?(Hash) ? (meta[:category] || meta['category']) : nil + @data.remember_tool(name, description: fetch(tool, :description), category: category, owned_params: owned, + output_instructions: declared) + mutated + end + + # --- helpers ----------------------------------------------------------- + + def fetch(hash, key) + SchemaMutation.fetch(hash, key) + end + + def tool_result_error?(result) + result.is_a?(Hash) && (result[:isError] == true || result['isError'] == true) + end + + def no_response?(result) + defined?(::JsonRpcHandler::NO_RESPONSE) && result.equal?(::JsonRpcHandler::NO_RESPONSE) + end + + def safely(fallback = nil) + yield + rescue StandardError => e + Log.debug(@options, + "PostHog MCP analytics step failed (event dropped, request unaffected): #{e.class}: #{e.message}") + fallback + end + end + end +end diff --git a/lib/posthog/mcp/intent.rb b/lib/posthog/mcp/intent.rb new file mode 100644 index 0000000..a2eb83b --- /dev/null +++ b/lib/posthog/mcp/intent.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Resolve `$mcp_intent` from the agent-supplied `context` argument (source + # `context_parameter`) or the customer's `intent_fallback` (source `inferred`). + # + # @api private + module Intent + module_function + + def normalize(intent) + return nil unless intent.is_a?(String) + + trimmed = intent.strip + trimmed.empty? ? nil : trimmed + end + + # @param context [String, nil] the `context` argument, and only when this + # layer owns it. A tool that declares `context` in its own schema is + # passed application data, which is never the agent's stated intent. + # @return [Array(String, String), nil] `[intent, source]` + def resolve(data, request, extra, context = nil) + params = request[:params] || request['params'] || {} + name = params[:name] || params['name'] + missing_name = Tools.missing_capability_tool_name(data.options) + intent = normalize(context) + return [intent, 'context_parameter'] if data.options.context_enabled? && name != missing_name && intent + + run_fallback(data, request, extra) + end + + def run_fallback(data, request, extra) + fallback = data.options.intent_fallback + return nil unless fallback + + intent = normalize(Callbacks.call(fallback, request, extra)) + intent ? [intent, 'inferred'] : nil + rescue StandardError => e + Log.debug(data.options, "intent_fallback callback error: #{e.message}") + nil + end + end + + # Model capture (`capture_model`). MCP does not standardize model identity: + # some clients expose it through vendor metadata, others let the agent + # self-report through the injected `llm_model` argument. Client metadata wins; + # `$mcp_llm_model_source` preserves provenance. Both are unverified. + # + # @api private + module ModelCapture + PARAM_NAME = 'llm_model' + CODEX_TURN_METADATA_KEY = 'x-codex-turn-metadata' + + module_function + + def normalize(model) + return nil unless model.is_a?(String) + + trimmed = model.strip + trimmed.empty? || trimmed.casecmp('unknown').zero? ? nil : trimmed + end + + # @param request [Hash] JSON-RPC-shaped request (params may carry `_meta`) + # @param self_reported [String, nil] the stripped `llm_model` argument, if the SDK owned it + # @return [Array(String, String), nil] `[model, source]` + def resolve(request, self_reported) + params = request[:params] || request['params'] || {} + meta = params[:_meta] || params['_meta'] + codex = meta.is_a?(Hash) ? (meta[CODEX_TURN_METADATA_KEY] || meta[CODEX_TURN_METADATA_KEY.to_sym]) : nil + if codex.is_a?(Hash) + model = normalize(codex['model'] || codex[:model]) + return [model, 'client_metadata'] if model + end + + model = normalize(self_reported) + model ? [model, 'self_reported'] : nil + end + end + + # Raw transport headers stamped per event (HTTP only, never cached). + # + # @api private + module TransportIdentity + CLIENT_USER_AGENT_HEADER = 'user-agent' + VENDOR_CLIENT_HEADER = 'x-anthropic-client' + + module_function + + def stamp(event, headers) + return event unless headers.is_a?(Hash) + + user_agent = headers[CLIENT_USER_AGENT_HEADER] + vendor = headers[VENDOR_CLIENT_HEADER] + event['client_user_agent'] = user_agent if user_agent.is_a?(String) && !user_agent.empty? + event['vendor_client'] = vendor if vendor.is_a?(String) && !vendor.empty? + event + end + end + end +end diff --git a/lib/posthog/mcp/log.rb b/lib/posthog/mcp/log.rb new file mode 100644 index 0000000..7e8e49c --- /dev/null +++ b/lib/posthog/mcp/log.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Logging for the MCP integration. + # + # Debug-level chatter goes only to the `logger:` option (a no-op by default), + # because a stdio MCP server owns `$stdout` for the protocol and the core + # SDK's default logger writes there. Warnings additionally reach the app's + # logger when Rails is loaded (where {PostHog::Logging.logger} wraps + # `Rails.logger`) and stderr otherwise, so misconfiguration is never silent + # and never corrupts a stdio transport. + # + # @api private + module Log + module_function + + def debug(options, message) + sink = options.respond_to?(:logger) ? options.logger : nil + sink&.call(message) + rescue StandardError + nil + end + + def warn(options, message) + debug(options, message) + if defined?(::Rails) + PostHog::Logging.logger.warn(message) + else + Kernel.warn("[posthog-ruby] #{message}") + end + rescue StandardError + nil + end + end + end +end diff --git a/lib/posthog/mcp/options.rb b/lib/posthog/mcp/options.rb new file mode 100644 index 0000000..ac472ec --- /dev/null +++ b/lib/posthog/mcp/options.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Description override for the injected `context` argument. + # + # @!attribute description + # @return [String, nil] + ContextOptions = Struct.new(:description, keyword_init: true) + + # Description override for the injected `llm_model` argument. + # + # @!attribute description + # @return [String, nil] + ModelOptions = Struct.new(:description, keyword_init: true) + + # Resolved identity for a session. `distinct_id` becomes the event's + # distinct id, `properties` go to `$set`, `groups` (`{group_type => group_key}`) + # are stamped on every event as `$groups`. + UserIdentity = Struct.new(:distinct_id, :properties, :groups, keyword_init: true) do + # @api private + def self.coerce(value) + case value + when UserIdentity then value + when Hash + distinct_id = value[:distinct_id] || value['distinct_id'] || value[:distinctId] || value['distinctId'] + return nil if distinct_id.nil? || distinct_id.to_s.empty? + + new( + distinct_id: distinct_id.to_s, + properties: value[:properties] || value['properties'], + groups: value[:groups] || value['groups'] + ) + end + end + end + + # Result of {PostHog::MCP::Client#prepare_tool_call}: the intent and the + # self-reported model pulled off the call, the arguments with the injected + # `context` and `llm_model` stripped, and whether the call targeted the + # `get_more_tools` virtual tool. + PreparedToolCall = Struct.new(:args, :intent, :intent_source, :llm_model, :llm_model_source, + :is_missing_capability, keyword_init: true) + + # Configuration for {PostHog::MCP.instrument}. + # + # @note Experimental: option names may change in a future minor release. + class Options + # @return [#call, nil] STDIO-safe log sink receiving single String messages. Default: no-op. + attr_reader :logger + # @return [Boolean] Register the `get_more_tools` virtual tool. Default false. + attr_reader :report_missing + # @return [String] Name of the virtual tool. Default `get_more_tools`. + attr_reader :missing_capability_tool_name + # @return [Boolean] Inject `conversation_id` and anchor `$session_id` on it. Default false. + attr_reader :enable_conversation_id + # @return [Boolean] Emit a sibling `$exception` event for failed calls. Default true. + attr_reader :enable_exception_autocapture + # @return [Boolean, ContextOptions] Inject the required `context` argument. Default true. + attr_reader :context + # @return [Boolean, ModelOptions] Capture `$mcp_llm_model`. Default false. + attr_reader :capture_model + # @return [#call, UserIdentity, Hash, nil] `(request, extra) -> UserIdentity | Hash | nil`, or a static identity. + attr_reader :identify + # @return [#call, nil] `(request, extra) -> String | nil`, consulted when no `context` arg was passed. + attr_reader :intent_fallback + # @return [#call, nil] `(payload) -> payload | nil`; runs once per emitted payload, nil drops it. + attr_reader :before_send + # @return [#call, nil] `(request, extra) -> Hash | nil`, spread flat onto every auto-captured event. + attr_reader :event_properties + + def initialize(logger: nil, report_missing: false, missing_capability_tool_name: nil, + enable_conversation_id: false, enable_exception_autocapture: true, context: true, + capture_model: false, identify: nil, intent_fallback: nil, before_send: nil, + event_properties: nil) + @logger = logger + @report_missing = report_missing == true + @missing_capability_tool_name = missing_capability_tool_name + @enable_conversation_id = enable_conversation_id == true + @enable_exception_autocapture = enable_exception_autocapture != false + @context = normalize_context(context) + @capture_model = normalize_model(capture_model) + @identify = identify + @intent_fallback = intent_fallback + @before_send = before_send + @event_properties = event_properties + end + + # @return [Boolean] + def context_enabled? + @context != false + end + + # @return [String, nil] + def context_description + @context.is_a?(ContextOptions) ? @context.description : nil + end + + # @return [Boolean] + def capture_model_enabled? + @capture_model != false + end + + # @return [String, nil] + def model_description + @capture_model.is_a?(ModelOptions) ? @capture_model.description : nil + end + + private + + def normalize_context(context) + case context + when false, nil then context.nil? + when Hash then ContextOptions.new(description: context[:description] || context['description']) + else context + end + end + + def normalize_model(capture_model) + case capture_model + when Hash then ModelOptions.new(description: capture_model[:description] || capture_model['description']) + when true, ModelOptions then capture_model + else false + end + end + end + end +end diff --git a/lib/posthog/mcp/rack_middleware.rb b/lib/posthog/mcp/rack_middleware.rb new file mode 100644 index 0000000..e9573b4 --- /dev/null +++ b/lib/posthog/mcp/rack_middleware.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Rack middleware for stateless / multi-pod MCP servers built on a custom + # Rack stack (not the `mcp` gem's Streamable HTTP transport, which + # {PostHog::MCP.instrument} wires automatically). + # + # It publishes the request's headers to {RequestScope}, so an instrumented + # `MCP::Server` dispatched anywhere below it sees the HTTP context the gem's + # own transport would have given it, and it carries the `Mcp-Session-Id` + # token minted at `initialize` back onto the response. Clients replay that + # token on every request, so any pod recovers `$session_id` and the client + # identity from the header alone. + # + # Neither the request nor the response body is read here. The token comes + # from whoever handled the request and already knows it succeeded: the + # instrumented server, or - for a hand-rolled dispatcher built on + # {PostHog::MCP::Client} - a call to the mint hook this middleware exposes as + # `env['posthog_mcp.mint']`. + # + # The decoded token (replayed or freshly minted) is exposed to the app as + # `env['posthog_mcp.session']` ({SessionTokenPayload}). + # + # @note Experimental. `PostHog::MCP` is not officially supported; see the + # docs at https://posthog.com/docs/mcp-analytics. + # + # @example An instrumented server behind a custom Rack stack + # use PostHog::MCP::RackMiddleware + # + # @example A hand-rolled dispatcher minting the session itself + # session = env['posthog_mcp.mint']&.call( + # client_name: info['name'], client_version: info['version'], protocol_version: params['protocolVersion'] + # ) + # analytics.capture_initialize(session_id: session&.session_id, ...) + class RackMiddleware + ENV_KEY = 'posthog_mcp.session' + MINT_ENV_KEY = 'posthog_mcp.mint' + + def initialize(app) + @app = app + end + + def call(env) + replayed = SessionToken.decode(SessionToken.read_header(MCP_SESSION_HEADER => env['HTTP_MCP_SESSION_ID'])) + env[ENV_KEY] = replayed if replayed + + RequestScope.with(headers: RequestScope.headers_from_env(env), transport: :http) do |scope| + env[MINT_ENV_KEY] = mint_hook(scope, env) unless replayed + status, headers, body = @app.call(env) + settle_token(scope[:mint], status, headers, env, replayed) + [status, headers, body] + ensure + env.delete(MINT_ENV_KEY) + end + end + + private + + # A token is minted only once the handshake produced an `InitializeResult`, + # so a rejected `initialize` - including a JSON-RPC error riding on a 200 - + # mints nothing. The status check covers a hand-rolled dispatcher that + # minted and then failed the request: the client never gets a session it + # cannot use, and `env` stops advertising one. + def settle_token(token, status, headers, env, replayed) + return if token.nil? + + if success?(status) && attachable?(headers) + headers[MCP_SESSION_HEADER] = token + env[ENV_KEY] = SessionToken.decode(token) + elsif !replayed + env.delete(ENV_KEY) + end + end + + def success?(status) + status.to_i.between?(200, 299) + end + + def attachable?(headers) + headers.respond_to?(:key?) && headers.keys.none? { |key| key.to_s.casecmp?(MCP_SESSION_HEADER) } + end + + # `env['posthog_mcp.mint']`, for a dispatcher that handles `initialize` + # itself: call it once the handshake is accepted to get the session this + # request belongs to. Only present when the client replayed no token, and + # nil for a modern-era client (protocol revision 2026-07-28 or later), which + # must not be answered with an `Mcp-Session-Id`. + # + # @return [Proc] `(client_name:, client_version:, protocol_version:) -> SessionTokenPayload | nil` + def mint_hook(scope, env) + lambda do |client_name: nil, client_version: nil, protocol_version: nil| + next env[ENV_KEY] if scope[:mint] + next nil unless Instrumentation.legacy_era?(protocol_version) + + payload = SessionTokenPayload.new( + session_id: Session.new_session_id, + client_name: string_or_nil(client_name), + client_version: string_or_nil(client_version), + protocol_version: string_or_nil(protocol_version) + ) + scope[:mint] = SessionToken.encode(payload) + env[ENV_KEY] = payload + end + end + + def string_or_nil(value) + value.is_a?(String) && !value.empty? ? value : nil + end + end + end +end diff --git a/lib/posthog/mcp/request_scope.rb b/lib/posthog/mcp/request_scope.rb new file mode 100644 index 0000000..b39281a --- /dev/null +++ b/lib/posthog/mcp/request_scope.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Request-scoped hand-off from the Streamable HTTP transport to the server + # extension. The `mcp` gem re-parses the JSON body between + # `StreamableHTTPTransport#handle_request` and `Server#handle_json`, so the + # HTTP headers cannot travel with the request object; this is the one place + # the integration relies on ambient state. + # + # Uses fiber storage (`Fiber[]`, Ruby 3.2+), which is per fiber, isolated per + # Ractor, and inherited by fibers/threads a tool spawns. Falls back to + # fiber-local `Thread.current[]` on Ruby 3.0/3.1. + # + # @api private + module RequestScope + KEY = :posthog_mcp_request_scope + FIBER_STORAGE = Fiber.respond_to?(:[]) && Fiber.respond_to?(:[]=) + + # Rack `env` keys for the headers the integration reads, by header name. + HEADER_ENV_KEYS = { + 'user-agent' => 'HTTP_USER_AGENT', + 'x-anthropic-client' => 'HTTP_X_ANTHROPIC_CLIENT', + 'mcp-session-id' => 'HTTP_MCP_SESSION_ID', + 'mcp-protocol-version' => 'HTTP_MCP_PROTOCOL_VERSION' + }.freeze + + module_function + + # @return [Hash, nil] `{headers:, transport:, mint:, session_id:}` for the in-flight HTTP request, + # plus `actor:` once {Instrumentation} has resolved identity for it. + # `session_id` is the `$session_id` {Instrumentation} settled on before running the + # tool body, and `actor` the identity it resolved, so a custom event captured inside + # the tool is attributed to this request - to the right session and the right person - + # even while another request on the same server is in flight. `actor` is absent until + # the request resolves identity, which is what tells {Analytics} it has none to use. + def current + FIBER_STORAGE ? Fiber[KEY] : Thread.current[KEY] + end + + def current=(value) + if FIBER_STORAGE + Fiber[KEY] = value + else + Thread.current[KEY] = value + end + end + + # @param headers [Hash{String => String}] lowercase header names + # @param transport [Symbol] `:http` for the Streamable HTTP transport, `:other` + # for a transport that publishes no headers (stdio, a custom dispatcher) + # @param env [Hash] a Rack `env` + # @return [Hash{String => String}] the headers {Instrumentation} reads, lowercase + def headers_from_env(env) + return {} unless env.is_a?(Hash) + + HEADER_ENV_KEYS.each_with_object({}) do |(name, env_key), acc| + value = env[env_key] + acc[name] = value if value.is_a?(String) && !value.empty? + end + end + + def with(headers:, transport: :http) + previous = current + self.current = { headers: headers, transport: transport, mint: nil, session_id: nil } + yield current + ensure + self.current = previous + end + end + end +end diff --git a/lib/posthog/mcp/sanitization.rb b/lib/posthog/mcp/sanitization.rb new file mode 100644 index 0000000..bf8aec1 --- /dev/null +++ b/lib/posthog/mcp/sanitization.rb @@ -0,0 +1,504 @@ +# frozen_string_literal: true + +require 'json' +require 'set' +require 'uri' + +module PostHog + module MCP + # Event sanitization: redact non-text response content blocks, large base64 + # strings, PostHog tokens, credential-looking words, and sensitive keys. + # Pure functions that return new objects without mutating the input; run + # before truncation. Hash keys are strings. + # + # @api private + module Sanitization + INJECTED_ARGUMENT_NAMES = %w[context conversation_id llm_model].freeze + REDACTED_VALUE = '[redacted]' + CIRCULAR_VALUE = '[Circular ~]' + BINARY_REDACTED_VALUE = '[binary data redacted - not supported by PostHog MCP analytics]' + BINARY_RESOURCE_REDACTED_VALUE = '[binary resource content redacted - not supported by PostHog MCP analytics]' + BASE64_PATTERN = %r{\A[A-Za-z0-9+/\n\r]+=*\z} + BASE64URL_PATTERN = /\A[A-Za-z0-9_-]+={0,2}\z/ + BASE64URL_SPECIFIC_CHAR_PATTERN = /[-_]/ + BASE64_DATA_URL_PREFIX_PATTERN = /\Adata:[^,\s]*;base64,/i + BASE64_DATA_URL_PAYLOAD_PATTERN = %r{\A[A-Za-z0-9+/_-]+={0,2}\z} + SIZE_GATE = 10_240 + # Source lines an in-app stack frame carries around the raise. + SOURCE_CONTEXT_FIELDS = %w[pre_context context_line post_context].freeze + POSTHOG_TOKEN_PATTERN = /\bph[a-z]_[A-Za-z0-9_-]{20,}\b/ + SENSITIVE_KEY_PATTERN = /\A(authorization|cookie|set-cookie|x-api-key|api[-_]?key|api[-_]?token| + access[-_]?token|refresh[-_]?token|token|password|secret|client[-_]?secret|private[-_]?key)\z/ix + + # PII redaction for the agent-narrated intent string only. Ordered so an + # earlier pass never eats digits a later pass needs. `\d`, `\w` and `\b` + # are ASCII-only in Ruby, which is what these patterns assume. + UNICODE_SPACE_PATTERN = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/ + EMAIL_PATTERN = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/ + IPV4_PATTERN = /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/ + IPV6_PATTERN = / + \b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b + |(?= SIZE_GATE + + BASE64_PATTERN.match?(value) || + base64_data_url?(value) || + (BASE64URL_SPECIFIC_CHAR_PATTERN.match?(value) && BASE64URL_PATTERN.match?(value)) + end + + def sanitize_string(value) + return BINARY_REDACTED_VALUE if binary_like?(value) + + value = value.gsub(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE) + redact_secret_tokens(SecretDetection.redact_private_key_blocks(value)) + end + + # Redact credential-looking words, leaving surrounding text intact. + def redact_secret_tokens(value) + return (SecretDetection.secret?(value) ? REDACTED_VALUE : value) unless value.include?(' ') + + value.split(' ', -1).map { |word| SecretDetection.secret?(word) ? REDACTED_VALUE : word }.join(' ') + end + + def passes_luhn?(digits) + total = 0 + double = false + (digits.length - 1).downto(0) do |index| + digit = digits.getbyte(index) - 48 + return false if digit.negative? || digit > 9 + + if double + digit *= 2 + digit -= 9 if digit > 9 + end + total += digit + double = !double + end + (total % 10).zero? + end + + # Within a card candidate, redact every run of whole separator-delimited + # digit groups whose joined digits are 13-19 long and pass Luhn. + def redact_card_in_match(text) + groups = [] + text.scan(DIGIT_GROUP_PATTERN) do + groups << [Regexp.last_match[0], Regexp.last_match.begin(0), Regexp.last_match.end(0)] + end + output = +'' + cursor = 0 + first = 0 + while first < groups.length + digits = +'' + matched_last = -1 + (first...groups.length).each do |last| + digits << groups[last][0] + break if digits.length > 19 + + matched_last = last if digits.length >= 13 && passes_luhn?(digits) + end + if matched_last >= 0 + output << text[cursor...groups[first][1]] << REDACTED_VALUE + cursor = groups[matched_last][2] + first = matched_last + 1 + else + first += 1 + end + end + output << text[cursor..] + end + + # Redact structured personal identifiers (emails, IPs, cards, US SSNs, + # phone numbers) from free text. Intended for `$mcp_intent` only. + def redact_pii(value) + return value unless value.is_a?(String) + + result = value.gsub(UNICODE_SPACE_PATTERN, ' ') + result = result.gsub(EMAIL_PATTERN, REDACTED_VALUE) + result = result.gsub(IPV4_PATTERN, REDACTED_VALUE) + result = result.gsub(IPV6_PATTERN, REDACTED_VALUE) + result = result.gsub(CREDIT_CARD_CANDIDATE_PATTERN) { |match| redact_card_in_match(match) } + result = result.gsub(US_SSN_PATTERN, REDACTED_VALUE) + result = result.gsub(PHONE_NANP_PATTERN, REDACTED_VALUE) + result.gsub(PHONE_INTL_PATTERN, REDACTED_VALUE) + end + + def sanitize_captured_value(value) + case value + when nil then nil + when String then sanitize_string(value) + when Array then value.map { |item| sanitize_captured_value(item) } + when Hash + value.to_h do |key, nested| + [key.to_s, redact_key?(key) ? REDACTED_VALUE : sanitize_captured_value(nested)] + end + else value + end + end + + # Sanitize an event's response, parameters, intent and error. Returns a + # new shallow copy; does not mutate the input. + def sanitize_event(event) + result = event.dup + result['response'] = sanitize_response(result['response']) unless result['response'].nil? + result['parameters'] = sanitize_captured_value(result['parameters']) unless result['parameters'].nil? + unless result['user_intent'].nil? + result['user_intent'] = redact_pii(sanitize_captured_value(result['user_intent'])) + end + result['error'] = sanitize_exception_values(result['error']) unless result['error'].nil? + result + end + + def sanitize_exception_values(error) + return error unless error.is_a?(Hash) + + list = error['$exception_list'] + return error unless list.is_a?(Array) + + error.merge('$exception_list' => list.map { |exception| sanitize_exception_entry(exception) }) + end + + # An in-app frame carries the source lines around the raise. They are the + # most useful part of a stack trace and also the part most likely to hold a + # hard-coded credential, and a caller can make a tool fail on demand, so + # they get the same redaction as every other captured string. + def sanitize_exception_entry(exception) + return exception unless exception.is_a?(Hash) + + entry = exception.merge('value' => sanitize_captured_value(exception['value'])) + stacktrace = entry['stacktrace'] + frames = stacktrace.is_a?(Hash) ? stacktrace['frames'] : nil + return entry unless frames.is_a?(Array) + + entry.merge('stacktrace' => stacktrace.merge('frames' => frames.map { |frame| sanitize_frame(frame) })) + end + + def sanitize_frame(frame) + return frame unless frame.is_a?(Hash) && SOURCE_CONTEXT_FIELDS.any? { |field| frame.key?(field) } + + sanitized = frame.dup + SOURCE_CONTEXT_FIELDS.each do |field| + next unless sanitized.key?(field) + + value = sanitized[field] + sanitized[field] = if value.is_a?(Array) + value.map { |line| sanitize_source_line(line) } + else + sanitize_source_line(value) + end + end + sanitized + end + + # Redact secrets in a line of source without disturbing its shape. The + # generic string path splits on whitespace and rejoins with single spaces, + # which would flatten the indentation that makes a stack trace readable, so + # replacement happens per non-space run and leaves the gaps untouched. + def sanitize_source_line(line) + return line unless line.is_a?(String) + return BINARY_REDACTED_VALUE if binary_like?(line) + + redacted = SecretDetection.redact_private_key_blocks(line.gsub(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)) + redacted.gsub(/\S+/) { |word| SecretDetection.secret?(word) ? REDACTED_VALUE : word } + end + + def sanitize_response(response) + unless response.is_a?(Hash) || response.is_a?(Array) || response.is_a?(String) + return sanitize_captured_value(response) + end + + sanitized = sanitize_captured_value(response) + return sanitized unless sanitized.is_a?(Hash) + + result = sanitized.dup + result['content'] = sanitize_content_blocks(result['content']) if result['content'].is_a?(Array) + result['messages'] = sanitize_prompt_messages(result['messages']) if result['messages'].is_a?(Array) + result['contents'] = sanitize_resource_contents(result['contents']) if result['contents'].is_a?(Array) + structured = result['structuredContent'] + if structured.is_a?(Hash) || structured.is_a?(Array) + result['structuredContent'] = + sanitize_captured_value(structured) + end + result + end + + def sanitize_content_blocks(blocks) + blocks.map { |block| sanitize_content_block(block) } + end + + # A `prompts/get` result carries its blocks under `messages[].content`, + # either as a single block or as an array of them. + def sanitize_prompt_messages(messages) + messages.map do |message| + next message unless message.is_a?(Hash) && message.key?('content') + + content = message['content'] + sanitized = case content + when Array then sanitize_content_blocks(content) + when Hash then sanitize_content_block(content) + else content + end + message.merge('content' => sanitized) + end + end + + # A `resources/read` result carries its payloads under `contents[]`, where a + # binary resource is a `blob` rather than a typed content block. + def sanitize_resource_contents(contents) + contents.map do |entry| + next entry unless entry.is_a?(Hash) && entry.key?('blob') + + entry.merge('blob' => BINARY_RESOURCE_REDACTED_VALUE) + end + end + + def sanitize_content_block(block) + return block unless block.is_a?(Hash) + + case block['type'] + when 'text', 'resource_link' then sanitize_captured_value(block) + when 'image' then text_block('[image content redacted - not supported by PostHog MCP analytics]') + when 'audio' then text_block('[audio content redacted - not supported by PostHog MCP analytics]') + when 'resource' + resource = block['resource'] + if resource.is_a?(Hash) && resource.key?('blob') + text_block(BINARY_RESOURCE_REDACTED_VALUE) + else + sanitize_captured_value(block) + end + else + text_block("[unsupported content type \"#{block['type']}\" redacted - " \ + 'not supported by PostHog MCP analytics]') + end + end + + def text_block(text) + { 'type' => 'text', 'text' => text } + end + + # Build the sanitized `$mcp_parameters` payload from a JSON-RPC request, + # dropping the SDK-injected arguments (they surface as dedicated properties). + def build_captured_mcp_parameters(request) + request = stringify_keys(request) + return { 'request' => sanitize_captured_value(request) } unless request.is_a?(Hash) + + captured = {} + %w[id jsonrpc method].each do |key| + captured[key] = sanitize_captured_value(request[key]) if request.key?(key) + end + captured['params'] = build_captured_params(request['params']) if request.key?('params') + { 'request' => captured } + end + + def build_captured_params(params) + return sanitize_captured_value(params) unless params.is_a?(Hash) + + params.to_h do |key, value| + [key, key == 'arguments' ? build_captured_arguments(value) : sanitize_captured_value(value)] + end + end + + def build_captured_arguments(arguments) + return sanitize_captured_value(arguments) unless arguments.is_a?(Hash) + + arguments.each_with_object({}) do |(key, value), captured| + next if INJECTED_ARGUMENT_NAMES.include?(key) + + captured[key] = sanitize_captured_value(value) + end + end + + # Last-resort credential detection for bare words. + # + # @api private + module SecretDetection + MIN_LENGTH = 16 + MIN_ENTROPY_BITS = 3.8 + MIN_CHAR_CLASSES = 3 + HEX_DIGITS = '0123456789abcdefABCDEF'.chars.to_set.freeze + REJECT_CHARS = "()[]{}<>'\"`,;".chars.to_set.freeze + UUID_RE = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/ + PATH_WORD_RE = /\A[a-z][a-z.]*\z/ + # A private key is redacted as a whole block, before anything is split into + # words. The body would mostly be caught word by word anyway - each base64 + # line is high entropy on its own - but that is a heuristic, and a line + # that happens to look like a path (two lowercase `/`-separated segments) + # slips through it. Key material should not ride on a heuristic. + # + # Matched non-greedily, so several blocks in one value are handled + # separately, and terminated at end-of-string so a truncated block still + # loses its body. Covers the `RSA`/`EC`/`OPENSSH`/`ENCRYPTED` variants and + # the PGP `BLOCK` spelling. + PEM_PRIVATE_KEY_HINT = 'PRIVATE KEY' + PEM_PRIVATE_KEY_BLOCK = / + -----BEGIN[A-Z0-9\ ]*\ PRIVATE\ KEY(?:\ BLOCK)?----- + .*? + (?:-----END[A-Z0-9\ ]*\ PRIVATE\ KEY(?:\ BLOCK)?-----|\z) + /mx + KNOWN_SECRET_MAX_SCAN_LENGTH = 200 + KNOWN_SECRET_RE = Regexp.union( + /sk-ant-[A-Za-z0-9_-]{16,}/, + /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/, + /hf_[A-Za-z0-9]{34}/, + /AKIA[0-9A-Z]{16}/, + /(?:ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|ACCA)[0-9A-Z]{16}/, + /AIza[A-Za-z0-9_-]{35}/, + /ya29\.[A-Za-z0-9_-]{20,}/, + /do[opr]_v1_[a-f0-9]{64}/, + /(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/, + /sq0[a-z]{3}-[A-Za-z0-9_-]{22,43}/, + /gh[pousr]_[A-Za-z0-9]{36}/, + /github_pat_[A-Za-z0-9_]{20,}/, + /gl(?:pat|ptt|rt|soat)-[A-Za-z0-9_-]{20}/, + /glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8}/, + /xox[abeoprs]-[A-Za-z0-9-]{10,}/, + /xapp-[0-9]-[A-Za-z0-9-]{10,}/, + /SK[0-9a-fA-F]{32}/, + /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/, + /key-[0-9a-f]{32}/, + /[0-9a-f]{32}-us[0-9]{1,2}/, + /npm_[A-Za-z0-9]{36}/, + /pypi-AgEI[A-Za-z0-9_-]{50,}/, + /dapi[0-9a-f]{32}/, + /dp\.pt\.[A-Za-z0-9]{40,}/, + /PMAK-[a-f0-9]{24}-[a-f0-9]{34}/, + /lin_api_[A-Za-z0-9]{40}/, + /ntn_[A-Za-z0-9]{40,}/, + /shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}/, + /NR(?:AK|JS|II|MA|RA)-[A-Za-z0-9]{27}/, + /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/ + ) + + module_function + + # Whole private-key blocks are handled by {redact_private_key_blocks} before + # a value is ever split, so there is no marker check here: the marker + # contains a space and could never match a single word anyway. + def secret?(value) + return false unless value.is_a?(String) && !value.empty? + + n = value.length + return false if n < MIN_LENGTH + return true if high_entropy_secret?(value) + return KNOWN_SECRET_RE.match?(value) if n <= KNOWN_SECRET_MAX_SCAN_LENGTH + + false + rescue StandardError + false + end + + # @return [String] the value with every `-----BEGIN … PRIVATE KEY-----` + # block replaced, leaving surrounding text intact + def redact_private_key_blocks(value) + return value unless value.include?(PEM_PRIVATE_KEY_HINT) + + value.gsub(PEM_PRIVATE_KEY_BLOCK, REDACTED_VALUE) + end + + def path_or_url?(value) + return true if value.include?('://') || value.include?('\\') + return false unless value.include?('/') + + value.split('/').count { |segment| !segment.empty? && PATH_WORD_RE.match?(segment) } >= 2 + end + + def high_entropy_secret?(value) + return false if value.include?(' ') || path_or_url?(value) || UUID_RE.match?(value) + + counts = value.each_char.tally + distinct = counts.keys + return false if distinct.any? { |ch| REJECT_CHARS.include?(ch) } + + has_lower = has_upper = has_digit = has_symbol = false + hex_only = true + distinct.each do |ch| + return false if ch.match?(/\s/) + + case ch + when /[[:lower:]]/ + has_lower = true + hex_only = false unless HEX_DIGITS.include?(ch) + when /[[:upper:]]/ + has_upper = true + hex_only = false unless HEX_DIGITS.include?(ch) + when /[[:digit:]]/ + has_digit = true + else + has_symbol = true + hex_only = false + end + end + return false if hex_only + return false if [has_lower, has_upper, has_digit, has_symbol].count(true) < MIN_CHAR_CLASSES + + n = value.length.to_f + entropy = counts.values.sum do |occurrences| + p = occurrences / n + -p * Math.log2(p) + end + entropy >= MIN_ENTROPY_BITS + end + end + end + end +end diff --git a/lib/posthog/mcp/schema_mutation.rb b/lib/posthog/mcp/schema_mutation.rb new file mode 100644 index 0000000..f5d4f7b --- /dev/null +++ b/lib/posthog/mcp/schema_mutation.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Injects analytics parameters (`context`, `conversation_id`, `llm_model`) + # into advertised tool input schemas and declares `_mcp_instructions` on + # output schemas. Always returns new hashes: the `mcp` gem's `Tool.to_h` + # shares its nested schema hashes with the tool class, so in-place writes + # would leak into the tool permanently. + # + # Works on symbol- or string-keyed schemas and writes back in the input's key style. + # + # @api private + module SchemaMutation + COMPLEX_KEYS = %w[oneOf allOf anyOf].freeze + + module_function + + def fetch(hash, name) + return nil unless hash.is_a?(Hash) + + hash.key?(name.to_sym) ? hash[name.to_sym] : hash[name.to_s] + end + + def key_for(hash, name) + return name.to_sym if hash.key?(name.to_sym) + return name.to_s if hash.key?(name.to_s) + + hash.keys.first.is_a?(String) ? name.to_s : name.to_sym + end + + def declares_param?(schema, name) + properties = fetch(schema, :properties) + properties.is_a?(Hash) && (properties.key?(name.to_sym) || properties.key?(name.to_s)) + end + + def complex?(schema) + COMPLEX_KEYS.any? { |key| truthy?(fetch(schema, key)) } + end + + # Whether an analytics parameter may be injected into (and therefore owned + # in) this input schema. A composed (oneOf/allOf/anyOf) or referenced + # ($ref) schema can declare the property out of band, and a sibling + # property next to a reference to a closed object makes the schema + # unsatisfiable, so those are left alone entirely. + def injectable?(schema) + return true unless schema.is_a?(Hash) + + !complex?(schema) && !truthy?(fetch(schema, :$ref)) + end + + # Key style of `hash`, falling back to `parent`'s when `hash` is empty. + def string_keys?(hash, parent) + source = hash.empty? ? parent : hash + source.keys.first.is_a?(String) + end + + def truthy?(value) + !(value.nil? || value == false || (value.respond_to?(:empty?) && value.empty?)) + end + + def deep_dup(value) + case value + when Hash then value.to_h { |k, v| [k, deep_dup(v)] } + when Array then value.map { |v| deep_dup(v) } + else value + end + end + + # Add a string property to an object schema. Returns the input unchanged + # (logging a warning) when the property exists or the schema is composed + # or referenced. + # + # @return [Hash] new schema + def add_parameter(schema, name, description, tool_name:, required:, options: nil, label: name) + if declares_param?(schema, name) + Log.debug(options, + "WARN: Tool \"#{tool_name}\" already has '#{name}' parameter. Skipping #{label} injection.") + return schema + end + unless injectable?(schema) + Log.debug(options, + "WARN: Tool \"#{tool_name}\" has a composed schema (oneOf/allOf/anyOf/$ref). " \ + "Skipping #{label} injection.") + return schema + end + + if schema.nil? || (schema.respond_to?(:empty?) && schema.empty?) + schema = { type: 'object', properties: {}, + required: [] } + end + schema = deep_dup(schema) + properties_key = key_for(schema, :properties) + schema[properties_key] = {} unless schema[properties_key].is_a?(Hash) + + # `additionalProperties: false` stays: the injected name is listed under + # `properties`, so it is still accepted, and relaxing the constraint would + # advertise a looser schema than the dispatcher actually validates against. + property_key = string_keys?(schema[properties_key], schema) ? name.to_s : name.to_sym + schema[properties_key][property_key] = { type: 'string', description: description } + + if required + required_key = key_for(schema, :required) + if schema[required_key].is_a?(Array) + schema[required_key] << name.to_s unless schema[required_key].map(&:to_s).include?(name.to_s) + else + schema[required_key] = [name.to_s] + end + end + schema + end + + def add_context_parameter(schema, tool_name:, description: nil, required: true, options: nil) + add_parameter(schema, 'context', description || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION, + tool_name: tool_name, required: required, options: options, label: 'context') + end + + def add_conversation_id_parameter(schema, tool_name:, options: nil) + add_parameter(schema, ConversationId::PARAM_NAME, DEFAULT_CONVERSATION_ID_DESCRIPTION, + tool_name: tool_name, required: false, options: options, label: 'conversation_id') + end + + def add_model_parameter(schema, tool_name:, description: nil, required: true, options: nil) + add_parameter(schema, ModelCapture::PARAM_NAME, description || DEFAULT_MODEL_PARAMETER_DESCRIPTION, + tool_name: tool_name, required: required, options: options, label: 'llm_model') + end + + # Whether `_mcp_instructions` can safely be declared on this output schema. + def declarable_output?(schema) + return false unless schema.is_a?(Hash) + return false if truthy?(fetch(schema, :$ref)) || complex?(schema) + + properties = fetch(schema, :properties) + return false if !properties.nil? && !properties.is_a?(Hash) + + !truthy?(properties) || !declares_param?(schema, ConversationId::MCP_INSTRUCTIONS_KEY) + end + + def our_declaration?(declaration) + declaration.is_a?(Hash) && fetch(declaration, :description) == ConversationId::INSTRUCTIONS_FIELD_DESCRIPTION + end + + # Declare an optional `_mcp_instructions` on the output schema. + # + # @return [Array(Hash, Boolean)] `[schema, declared]` + def add_output_instructions(schema, tool_name:, options: nil) + return [schema, false] if schema.nil? || (schema.respond_to?(:empty?) && schema.empty?) + + key = ConversationId::MCP_INSTRUCTIONS_KEY + unless declarable_output?(schema) + properties = fetch(schema, :properties) + if properties.is_a?(Hash) && declares_param?(schema, key) + return [schema, true] if our_declaration?(fetch(properties, key)) + + Log.debug(options, + "WARN: Tool \"#{tool_name}\" already declares '#{key}' in its output schema. Leaving it alone.") + else + Log.debug(options, "WARN: Tool \"#{tool_name}\" has a complex output schema (oneOf/allOf/anyOf/$ref). " \ + "Skipping '#{key}' declaration; its session handle stays content-only.") + end + return [schema, false] + end + + schema = deep_dup(schema) + properties_key = key_for(schema, :properties) + schema[properties_key] = {} unless schema[properties_key].is_a?(Hash) + property_key = string_keys?(schema[properties_key], schema) ? key : key.to_sym + schema[properties_key][property_key] = { + type: 'object', + description: ConversationId::INSTRUCTIONS_FIELD_DESCRIPTION, + properties: { + conversation_id: { type: 'string', description: ConversationId::CONVERSATION_ID_FIELD_DESCRIPTION } + } + } + [schema, true] + end + end + end +end diff --git a/lib/posthog/mcp/server_extension.rb b/lib/posthog/mcp/server_extension.rb new file mode 100644 index 0000000..771d399 --- /dev/null +++ b/lib/posthog/mcp/server_extension.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Prepended onto `MCP::Server` once. Wraps the dispatch lambda that + # `handle_request` returns for instrumented servers so {Instrumentation} + # runs around the real handler with the raw request, params, and session. + # Uninstrumented servers fall straight through to `super`. + # + # @api private + module ServerExtension + private + + def handle_request(request, method, session: nil, related_request_id: nil) + handler = super + data = PostHog::MCP.tracking_data(self) + return handler unless data && handler.is_a?(Proc) && Instrumentation.tracked?(method) + + lambda do |params| + instrumentation = Instrumentation.new( + self, data, + method: method, request: request, params: params, session: session, request_id: related_request_id + ) + instrumentation.dispatch { handler.call(params) } + end + end + end + + # Prepended onto `MCP::Server::Transports::StreamableHTTPTransport` once. + # Publishes the HTTP headers of the in-flight request to {RequestScope} and + # adds the `Mcp-Session-Id` token minted by {Instrumentation} on stateless + # `initialize` responses. + # + # @api private + module TransportExtension + def handle_request(request) + server = instance_variable_defined?(:@server) ? @server : nil + return super unless server && PostHog::MCP.tracking_data(server) + + env = request.respond_to?(:env) ? request.env : {} + + RequestScope.with(headers: RequestScope.headers_from_env(env)) do |scope| + response = super + mint = scope[:mint] + if mint && response.is_a?(Array) && response[1].is_a?(Hash) && + response[1].keys.none? { |key| key.to_s.casecmp?(MCP_SESSION_HEADER) } + response[1][MCP_SESSION_HEADER] = mint + end + response + end + end + end + end +end diff --git a/lib/posthog/mcp/session.rb b/lib/posthog/mcp/session.rb new file mode 100644 index 0000000..937b327 --- /dev/null +++ b/lib/posthog/mcp/session.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # Session id resolution, in priority order: the + # agent's echoed `conversation_id` handle first, then our self-encoded + # session token, then the transport's MCP session id, then this server's own + # memory (which rolls over after {INACTIVITY_TIMEOUT_MINUTES} of inactivity). + # + # @api private + module Session + module_function + + def new_session_id + Ids.new_prefixed_id('ses') + end + + def derive_session_id_from_mcp_session(mcp_session_id) + Ids.deterministic_prefixed_id('ses', mcp_session_id) + end + + # Deterministic, so every server that sees the same handle derives the same session. + def derive_session_id_from_conversation(conversation_id) + Ids.deterministic_prefixed_id('ses', conversation_id) + end + + # @return [Array(String, String)] `[session_id, source]` where source is one of + # `conversation`, `token`, `mcp`, `generated` + def resolve(data, mcp_session_id, token: nil, conversation_id: nil) + return [derive_session_id_from_conversation(conversation_id), 'conversation'] if present?(conversation_id) + + data.synchronize do + now = Time.now + + if token + data.session_id = token.session_id + data.session_source = 'token' + data.last_activity = now + return [data.session_id, 'token'] + end + + if present?(mcp_session_id) + data.session_id = derive_session_id_from_mcp_session(mcp_session_id) + data.last_mcp_session_id = mcp_session_id + data.session_source = 'mcp' + data.last_activity = now + return [data.session_id, 'mcp'] + end + + if data.session_source == 'mcp' && data.last_mcp_session_id + data.last_activity = now + return [data.session_id, 'mcp'] + end + + stale = (now - data.last_activity) > (INACTIVITY_TIMEOUT_MINUTES * 60) + if data.session_source != 'generated' || stale || data.session_id.nil? + data.session_id = new_session_id + data.session_source = 'generated' + end + data.last_activity = now + [data.session_id, 'generated'] + end + end + + def present?(value) + value.is_a?(String) && !value.empty? + end + private_class_method :present? + end + end +end diff --git a/lib/posthog/mcp/session_token.rb b/lib/posthog/mcp/session_token.rb new file mode 100644 index 0000000..4904d78 --- /dev/null +++ b/lib/posthog/mcp/session_token.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'base64' +require 'json' + +module PostHog + module MCP + # What a self-encoded `Mcp-Session-Id` token carries. + # + # @!attribute session_id + # @return [String] PostHog session id (`ses_...`) -> `$session_id` + # @!attribute client_name + # @return [String, nil] MCP client name -> `$mcp_client_name` + # @!attribute client_version + # @return [String, nil] MCP client version -> `$mcp_client_version` + # @!attribute protocol_version + # @return [String, nil] MCP protocol version -> `$mcp_protocol_version` + SessionTokenPayload = Struct.new(:session_id, :client_name, :client_version, :protocol_version, keyword_init: true) + + # Self-encoded session tokens for stateless / multi-pod MCP servers. + # + # A stateless server keeps nothing between requests, so every request would + # start a new session and the client identity (only sent at `initialize`) + # would be lost. Clients replay the `Mcp-Session-Id` header on every request, + # so at `initialize` we mint that header as an unsigned base64url(JSON) token + # with short keys (`sid`, `cn`, `cv`, `pv`). + # + # @api private + module SessionToken + MAX_TOKEN_LENGTH = 4096 + MAX_SESSION_ID_LENGTH = 128 + MAX_CLIENT_FIELD_LENGTH = 200 + BASE64URL_PATTERN = /\A[A-Za-z0-9_-]+={0,2}\z/ + + module_function + + # @param payload [SessionTokenPayload, Hash] + # @return [String] token for the `Mcp-Session-Id` response header + # @raise [ArgumentError] when `session_id` is missing or empty + def encode(payload) + payload = SessionTokenPayload.new(**payload) if payload.is_a?(Hash) + session_id = payload.session_id + unless session_id.is_a?(String) && !session_id.empty? + raise ArgumentError, 'encode_session_id requires a non-empty `session_id` (use new_session_id())' + end + + wire = { 'sid' => session_id } + wire['cn'] = payload.client_name[0, MAX_CLIENT_FIELD_LENGTH] if present_string?(payload.client_name) + wire['cv'] = payload.client_version[0, MAX_CLIENT_FIELD_LENGTH] if present_string?(payload.client_version) + wire['pv'] = payload.protocol_version[0, MAX_CLIENT_FIELD_LENGTH] if present_string?(payload.protocol_version) + Base64.urlsafe_encode64(JSON.generate(wire), padding: false) + end + + # Decode an `Mcp-Session-Id` value. Returns nil for anything that is not one + # of our tokens (transport UUIDs, JWTs, garbage) and never raises. + # + # @return [SessionTokenPayload, nil] + def decode(value) + return nil unless value.is_a?(String) && !value.empty? && value.length <= MAX_TOKEN_LENGTH + return nil unless BASE64URL_PATTERN.match?(value) + + parsed = begin + JSON.parse(Base64.urlsafe_decode64(value.delete('='))) + rescue ArgumentError, JSON::ParserError, EncodingError + nil + end + return nil unless parsed.is_a?(Hash) + + sid = parsed['sid'] + return nil unless sid.is_a?(String) && !sid.empty? && sid.length <= MAX_SESSION_ID_LENGTH + + payload = SessionTokenPayload.new(session_id: sid) + payload.client_name = parsed['cn'][0, MAX_CLIENT_FIELD_LENGTH] if present_string?(parsed['cn']) + payload.client_version = parsed['cv'][0, MAX_CLIENT_FIELD_LENGTH] if present_string?(parsed['cv']) + payload.protocol_version = parsed['pv'][0, MAX_CLIENT_FIELD_LENGTH] if present_string?(parsed['pv']) + payload + end + + # Read the `mcp-session-id` value off a headers Hash (case-insensitive keys, + # array values, trimmed). Returns nil when absent or blank. + def read_header(headers) + return nil unless headers.respond_to?(:each_pair) + + value = headers[MCP_SESSION_HEADER] + if value.nil? + headers.each_pair do |key, candidate| + next unless key.is_a?(String) && key.downcase == MCP_SESSION_HEADER + + value = candidate + break + end + end + value = value.first if value.is_a?(Array) + return nil unless value.is_a?(String) + + trimmed = value.strip + trimmed.empty? ? nil : trimmed + end + + def present_string?(value) + value.is_a?(String) && !value.empty? + end + private_class_method :present_string? + end + end +end diff --git a/lib/posthog/mcp/sink.rb b/lib/posthog/mcp/sink.rb new file mode 100644 index 0000000..d52083f --- /dev/null +++ b/lib/posthog/mcp/sink.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # The capture pipeline: stringify keys -> sanitize -> truncate -> fan out into + # `$mcp_*` / `$exception` payloads -> `before_send` -> `PostHog::Client#capture`. + # + # Wraps a host-supplied client and never owns its lifecycle. Errors at any + # stage are logged and the event dropped, never re-raised into tool code. + # + # @api private + class Sink + attr_reader :client + + def initialize(client) + @client = client + end + + # @param event [Hash] internal event (symbol or string keys) + # @param options [Options, nil] + # @return [Array] the payloads handed to the client (for tests) + def capture(event, options = nil) + processed = process(event, options) + return [] if processed.nil? + + processed.each { |payload| dispatch(payload) } + processed + rescue StandardError => e + Log.debug(options, "Failed to capture PostHog event: #{e.message}") + [] + end + + # Runs the full transform and returns the payloads that survived `before_send`. + def process(event, options = nil) + processed = Sanitization.stringify_keys(event) + begin + processed = Sanitization.sanitize_event(processed) + rescue StandardError => e + Log.debug(options, "Failed to sanitize event: #{e.message}") + return nil + end + begin + processed = Truncation.truncate_event(processed) + rescue StandardError => e + Log.debug(options, "Failed to truncate event: #{e.message}") + return nil + end + processed['id'] = Ids.new_prefixed_id('evt') unless processed['id'].is_a?(String) && !processed['id'].empty? + + autocapture = options.nil? || options.enable_exception_autocapture + payloads = EventBuilder.build(processed, enable_exception_autocapture: autocapture) + apply_before_send(payloads, options) + end + + private + + def apply_before_send(payloads, options) + before_send = options&.before_send + return payloads unless before_send + + payloads.filter_map do |payload| + begin + result = before_send.call(payload) + rescue StandardError => e + Log.debug(options, "before_send threw for event #{payload['event']}; dropping it: #{e.message}") + next nil + end + next nil unless result.is_a?(Hash) + + retruncate(result, options) + end + end + + # The size budget was applied before `before_send` ran, so a hook that + # enriches an event can push it back over the transport's per-message + # limit, which drops it at batch time. Shrink it again instead. + def retruncate(payload, options) + Truncation.truncate_payload(payload) + rescue StandardError => e + Log.debug(options, "Failed to truncate event after before_send: #{e.message}") + payload + end + + def dispatch(payload) + properties = payload['properties'] || payload[:properties] || {} + @client.capture( + distinct_id: payload['distinct_id'] || payload[:distinct_id], + event: payload['event'] || payload[:event], + properties: properties, + timestamp: payload['timestamp'] || payload[:timestamp] || Time.now.utc, + uuid: Ids.uuid_v7, + _lib: LIB_NAME, + _lib_version: PostHog::VERSION + ) + end + end + end +end diff --git a/lib/posthog/mcp/tools.rb b/lib/posthog/mcp/tools.rb new file mode 100644 index 0000000..00afd2d --- /dev/null +++ b/lib/posthog/mcp/tools.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # The `get_more_tools` virtual tool: advertised to agents so they can report + # a capability the server does not offer yet. Calling it emits + # `$mcp_missing_capability`, not `$mcp_tool_call`. + # + # @api private + module Tools + GET_MORE_TOOLS_NAME = 'get_more_tools' + + RESULT_TEXT = + 'Unfortunately, we have shown you the full tool list. We have noted your feedback ' \ + 'and will work to improve the tool list in the future.' + + module_function + + # @return [String] configured virtual tool name, falling back to the default + def missing_capability_tool_name(options = nil) + name = options.respond_to?(:missing_capability_tool_name) ? options.missing_capability_tool_name : nil + name.is_a?(String) && !name.empty? ? name : GET_MORE_TOOLS_NAME + end + + # The advertised descriptor (a `tools/list` entry, symbol keys like `MCP::Tool#to_h`). + # With `capture_model` on, the `llm_model` argument is advertised here too, so + # a missing-capability report carries the model that asked for it. The virtual + # tool is deliberately left out of the `conversation_id` loop-back: it reports + # a gap in the tool list rather than taking part in a tool conversation. + def descriptor(name = GET_MORE_TOOLS_NAME, options = nil) + spec = { + name: name, + description: 'Check for additional tools whenever your task might benefit from specialized ' \ + 'capabilities - even if existing tools could work as a fallback.', + inputSchema: { + type: 'object', + properties: { + context: { + type: 'string', + description: 'A description of your goal and what kind of tool would help accomplish it.' + } + }, + required: ['context'] + }, + annotations: { + title: 'Get More Tools', + readOnlyHint: true, + openWorldHint: true, + idempotentHint: true, + destructiveHint: false + } + } + return spec unless options.respond_to?(:capture_model_enabled?) && options.capture_model_enabled? + + spec.merge(inputSchema: SchemaMutation.add_model_parameter( + spec[:inputSchema], tool_name: name, description: options.model_description, options: options + )) + end + + # The canned acknowledgement returned to the agent after it calls `get_more_tools`. + def result + { content: [{ type: 'text', text: RESULT_TEXT }], isError: false } + end + + # Register the virtual tool on an `MCP::Server` so the gem dispatches it like + # any other tool: argument validation, envelope checks, in-flight tracking + # and cancellation all apply. {Instrumentation} recognises the returned class + # and records the call as `$mcp_missing_capability`. + # + # @return [Class] the registered `MCP::Tool` subclass + def register(server, name, options = nil) + spec = descriptor(name, options) + annotations = spec[:annotations] + content = result[:content] + server.define_tool( + name: name, + description: spec[:description], + input_schema: spec[:inputSchema], + annotations: { + title: annotations[:title], + read_only_hint: annotations[:readOnlyHint], + open_world_hint: annotations[:openWorldHint], + idempotent_hint: annotations[:idempotentHint], + destructive_hint: annotations[:destructiveHint] + } + ) { |**| ::MCP::Tool::Response.new(content) } + server.tools[name] + end + end + end +end diff --git a/lib/posthog/mcp/tracking_data.rb b/lib/posthog/mcp/tracking_data.rb new file mode 100644 index 0000000..c375116 --- /dev/null +++ b/lib/posthog/mcp/tracking_data.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module PostHog + module MCP + # All per-server analytics state. Lives on the instrumented `MCP::Server` + # instance (never in module-level storage), guarded by its own mutex. + # + # @api private + class TrackingData + MAX_INITIALIZED_SESSIONS = 1000 + + attr_reader :options, :sink, :server_name, :server_version, :identified_sessions, + :tool_descriptions, :tool_categories, :tool_output_instructions, :tool_owned_params + attr_accessor :session_id, :session_source, :last_mcp_session_id, :last_activity, :warned_no_stateless_session, + :virtual_tool, :http_transport_seen, :warned_unscoped_capture + + def initialize(options:, sink:, server_name: nil, server_version: nil) + @options = options + @sink = sink + @server_name = server_name + @server_version = server_version + @mutex = Mutex.new + @session_id = nil + @session_source = 'generated' + @last_mcp_session_id = nil + @last_activity = Time.now + @warned_no_stateless_session = false + # Set once a request arrives over HTTP: from then on the server-wide session + # is never a safe fallback for a capture that lost its request scope. + @http_transport_seen = false + @warned_unscoped_capture = false + # The `get_more_tools` class {Tools.register} added to the server, if any. + @virtual_tool = nil + @identified_sessions = IdentityCache.new + @tool_descriptions = {} + @tool_categories = {} + # Which tools got `_mcp_instructions` declared at tools/list. Only those + # may be mirrored into; absent fails closed. + @tool_output_instructions = {} + # Which injected argument names the analytics layer owns per tool (i.e. + # the tool does not declare them itself). + @tool_owned_params = {} + @initialized_sessions = {} + end + + def synchronize(&block) + if @mutex.owned? + yield + else + @mutex.synchronize(&block) + end + end + + def mark_session_initialized(session_id) + synchronize do + @initialized_sessions.delete(session_id) + @initialized_sessions[session_id] = true + @initialized_sessions.shift while @initialized_sessions.length > MAX_INITIALIZED_SESSIONS + end + end + + def session_initialized?(session_id) + synchronize { @initialized_sessions.key?(session_id) } + end + + # Claim the one lazy initialize event a session gets. Check and mark are a + # single critical section, so when concurrent first requests race only the + # caller that inserted the key sees `true` and emits `$mcp_initialize`. + def claim_session_initialized(session_id) + synchronize do + next false if @initialized_sessions.key?(session_id) + + @initialized_sessions[session_id] = true + @initialized_sessions.shift while @initialized_sessions.length > MAX_INITIALIZED_SESSIONS + true + end + end + + def remember_tool(name, description: nil, category: nil, owned_params: nil, output_instructions: nil) + synchronize do + @tool_descriptions[name] = description if description.is_a?(String) && !description.empty? + @tool_categories[name] = category if category.is_a?(String) && !category.empty? + @tool_owned_params[name] = owned_params unless owned_params.nil? + @tool_output_instructions[name] = output_instructions unless output_instructions.nil? + end + end + end + end +end diff --git a/lib/posthog/mcp/truncation.rb b/lib/posthog/mcp/truncation.rb new file mode 100644 index 0000000..324afa9 --- /dev/null +++ b/lib/posthog/mcp/truncation.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +require 'json' +require 'time' + +module PostHog + module MCP + # Layered truncation so an event fits within a byte budget before capture: + # + # 1. Field-level string limits (intent, resource name, metadata fields). + # 2. Stack-frame limiting and message caps on the `$exception_list` shape. + # 3. Response content text limits (32KB per text block). + # 4. Recursive normalization of user-controlled fields (depth/breadth/string caps). + # 5. Size-targeted truncation: progressive depth reduction, then trimming the + # largest strings until under MAX_EVENT_BYTES. + # + # Pure functions; the input event is never mutated. Hash keys are strings. + # + # @api private + module Truncation + MAX_DEPTH = 10 + MAX_BREADTH = 100 + MAX_STRING_LENGTH = 32_768 + # The core client drops any single message + # larger than `Defaults::Message::MAX_BYTES` (32KB) at batch time, so the + # internal event is budgeted to leave headroom for the envelope (`$lib`, + # timestamp, uuid, distinct_id) the client adds around it. + MAX_EVENT_BYTES = PostHog::Defaults::Message::MAX_BYTES - 2048 + + MAX_USER_INTENT_LENGTH = 2048 + MAX_ERROR_MESSAGE_LENGTH = 2048 + MAX_RESOURCE_NAME_LENGTH = 256 + MAX_METADATA_LENGTH = 256 + MAX_STACK_FRAMES = 50 + MAX_CONTENT_TEXT_LENGTH = 32_768 + + TRUNCATION_SUFFIX = '...' + + METADATA_FIELDS = [ + ['user_intent', MAX_USER_INTENT_LENGTH], + ['resource_name', MAX_RESOURCE_NAME_LENGTH], + ['server_name', MAX_METADATA_LENGTH], + ['server_version', MAX_METADATA_LENGTH], + ['client_name', MAX_METADATA_LENGTH], + ['client_version', MAX_METADATA_LENGTH], + ['error_type', MAX_METADATA_LENGTH], + ['client_user_agent', MAX_METADATA_LENGTH], + ['vendor_client', MAX_METADATA_LENGTH], + ['llm_model', MAX_METADATA_LENGTH] + ].freeze + + # Includes user-supplied `properties` (custom events, `event_properties`, + # `capture_tool_call`): a large numeric array cannot be shrunk by string + # trimming, so it must take part in depth/breadth reduction or the core + # client drops the whole message at batch time. + NORMALIZED_FIELDS = %w[parameters response identify_actor_data error properties].freeze + + module_function + + # Recursively normalize a value: cap strings, coerce non-serializable + # values, convert times, detect cycles, and bound depth/breadth. + def normalize(value, depth = MAX_DEPTH, max_breadth = MAX_BREADTH, max_string_length = MAX_STRING_LENGTH) + visit(value, depth, max_breadth, max_string_length, {}.compare_by_identity) + end + + def visit(value, remaining_depth, max_breadth, max_string_length, memo) + case value + when nil, true, false, Integer then value + when Float + return '[NaN]' if value.nan? + return (value.positive? ? '[Infinity]' : '[-Infinity]') if value.infinite? + + value + when String + value.length > max_string_length ? value[0, max_string_length] + TRUNCATION_SUFFIX : value + when Symbol then value.to_s + when Time then value.utc.iso8601(3) + when Proc, Method + name = value.respond_to?(:name) ? value.name : nil + "[Function: #{name || ''}]" + when Array + return '[Circular ~]' if memo.key?(value) + return '[Array]' if remaining_depth <= 0 + + memo[value] = true + result = visit_array(value, remaining_depth - 1, max_breadth, max_string_length, memo) + memo.delete(value) + result + when Hash + return '[Circular ~]' if memo.key?(value) + return '[Object]' if remaining_depth <= 0 + + memo[value] = true + result = visit_object(value, remaining_depth - 1, max_breadth, max_string_length, memo) + memo.delete(value) + result + else + value.respond_to?(:iso8601) ? value.iso8601 : value.to_s + end + end + + def visit_array(array, remaining_depth, max_breadth, max_string_length, memo) + result = [] + array.each_with_index do |item, index| + if index >= max_breadth + result << '[MaxProperties ~]' + break + end + result << visit(item, remaining_depth, max_breadth, max_string_length, memo) + end + result + end + + def visit_object(hash, remaining_depth, max_breadth, max_string_length, memo) + result = {} + count = 0 + hash.each do |key, val| + if count >= max_breadth + result['...'] = '[MaxProperties ~]' + break + end + result[key.to_s] = visit(val, remaining_depth, max_breadth, max_string_length, memo) + count += 1 + end + result + end + + def truncate_string(value, max_length) + return value unless value.is_a?(String) && value.length > max_length + + value[0, max_length] + TRUNCATION_SUFFIX + end + + def truncate_stack_frames(frames) + return frames unless frames.is_a?(Array) && frames.length > MAX_STACK_FRAMES + + half = MAX_STACK_FRAMES / 2 + frames[0, half] + frames[-half, half] + end + + def truncate_exception_list(error) + list = error['$exception_list'] + return error unless list.is_a?(Array) + + truncated = list.map do |exception| + next exception unless exception.is_a?(Hash) + + nxt = exception.dup + nxt['value'] = truncate_string(nxt['value'], MAX_ERROR_MESSAGE_LENGTH) if nxt['value'].is_a?(String) + stacktrace = nxt['stacktrace'] + if stacktrace.is_a?(Hash) && stacktrace['frames'].is_a?(Array) && !stacktrace['frames'].empty? + nxt['stacktrace'] = stacktrace.merge('frames' => truncate_stack_frames(stacktrace['frames'])) + end + nxt + end + error.merge('$exception_list' => truncated) + end + + def truncate_response_content(response) + return response unless response.is_a?(Hash) + + content = response['content'] + return response unless content.is_a?(Array) + + new_content = content.map do |block| + if block.is_a?(Hash) && block['type'] == 'text' && block['text'].is_a?(String) && + block['text'].length > MAX_CONTENT_TEXT_LENGTH + block.merge('text' => block['text'][0, MAX_CONTENT_TEXT_LENGTH] + TRUNCATION_SUFFIX) + else + block + end + end + response.merge('content' => new_content) + end + + # Byte size of the compact JSON encoding, coercing non-JSON values like the + # transport would. + def json_byte_size(value) + JSON.generate(jsonable(value)).bytesize + end + + def jsonable(value) + case value + when Hash then value.to_h { |k, v| [k.to_s, jsonable(v)] } + when Array then value.map { |v| jsonable(v) } + when String, Integer, true, false, nil then value + when Float then value.finite? ? value : value.to_s + when Time then value.utc.iso8601(3) + else + value.respond_to?(:iso8601) ? value.iso8601 : value.to_s + end + end + + def collect_string_paths(obj, current_path, results) + case obj + when String + results << { path: current_path.dup, length: obj.length } if obj.length > 100 + when Array + obj.each_with_index { |item, i| collect_string_paths(item, current_path + [i.to_s], results) } + when Hash + obj.each { |key, value| collect_string_paths(value, current_path + [key.to_s], results) } + end + end + + def get_nested_value(obj, path) + path.reduce(obj) do |current, key| + case current + when Array then current[key.to_i] + when Hash then current[key] + else return nil + end + end + end + + def set_nested_value(obj, path, value) + parent = path.empty? ? nil : get_nested_value(obj, path[0...-1]) + case parent + when Array then parent[path.last.to_i] = value + when Hash then parent[path.last] = value + end + end + + def deep_copy(obj) + case obj + when Hash then obj.to_h { |k, v| [k, deep_copy(v)] } + when Array then obj.map { |v| deep_copy(v) } + else obj + end + end + + def truncate_largest_fields(obj, max_bytes) + result = deep_copy(obj) + + 10.times do + current_size = json_byte_size(result) + return result if current_size <= max_bytes + + excess = current_size - max_bytes + string_paths = [] + collect_string_paths(result, [], string_paths) + string_paths.sort_by! { |entry| -entry[:length] } + break if string_paths.empty? + + remaining = excess + 200 + truncated = false + string_paths.each do |entry| + break if remaining <= 0 + + length = entry[:length] + reduction = [remaining, length / 2].min + next if reduction < 10 + + new_length = length - reduction + current_value = get_nested_value(result, entry[:path]) + next unless current_value.is_a?(String) + + set_nested_value(result, entry[:path], current_value[0, new_length] + TRUNCATION_SUFFIX) + remaining -= reduction + truncated = true + end + + break unless truncated + end + + result + end + + def truncate_to_size(event, fields = NORMALIZED_FIELDS) + return event if json_byte_size(event) <= MAX_EVENT_BYTES + + # Trim the largest strings first so a big tool response keeps its shape + # (the budget here is tight enough that depth reduction alone would turn + # a `content` array into "[Array]"). + trimmed = truncate_largest_fields(event, MAX_EVENT_BYTES) + return trimmed if json_byte_size(trimmed) <= MAX_EVENT_BYTES + + (MAX_DEPTH - 1).downto(1) do |depth| + reduced = event.dup + fields.each do |field| + reduced[field] = normalize(reduced[field], depth) unless reduced[field].nil? + end + return reduced if json_byte_size(reduced) <= MAX_EVENT_BYTES + end + + minimal = event.dup + fields.each do |field| + minimal[field] = normalize(minimal[field], 1) unless minimal[field].nil? + end + truncate_largest_fields(minimal, MAX_EVENT_BYTES) + end + + # Re-apply the byte budget to a built payload a `before_send` hook returned. + # The hook runs after {truncate_event}, so it can grow an event back over + # the transport's per-message limit, where the batch would drop it whole; + # trimming here costs the enrichment its bulk instead of the whole event. + # + # @param payload [Hash] a payload from {EventBuilder.build}, post-hook + # @return [Hash] a payload within the byte budget + def truncate_payload(payload) + return payload unless payload.is_a?(Hash) + + key = payload.key?(:properties) && !payload.key?('properties') ? :properties : 'properties' + truncate_to_size(payload, [key]) + end + + # @param event [Hash] internal event with string keys + # @return [Hash] new event within the byte budget + def truncate_event(event) + result = event.dup + + METADATA_FIELDS.each do |key, max_length| + result[key] = truncate_string(result[key], max_length) if result[key].is_a?(String) + end + + result['error'] = truncate_exception_list(result['error']) if result['error'].is_a?(Hash) + result['response'] = truncate_response_content(result['response']) unless result['response'].nil? + + NORMALIZED_FIELDS.each do |field| + result[field] = normalize(result[field]) unless result[field].nil? + end + + truncate_to_size(result) + end + end + end +end diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 604a112..b97cd83 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -118,6 +118,149 @@ instance_method PostHog::Logging#logger() class_method PostHog::Logging.included(base) class_method PostHog::Logging.logger() class_method PostHog::Logging.logger=(arg0) +module PostHog::MCP +class_method PostHog::MCP.decode_session_id(value) +class_method PostHog::MCP.derive_session_id_from_conversation(conversation_id) +class_method PostHog::MCP.derive_session_id_from_mcp_session(mcp_session_id) +class_method PostHog::MCP.encode_session_id(payload) +class_method PostHog::MCP.get_more_tools_result() +class_method PostHog::MCP.instrument(server, client = ..., options: ..., **kwargs) +class PostHog::MCP::Analytics +instance_method PostHog::MCP::Analytics#capture(event, properties = ...) +instance_method PostHog::MCP::Analytics#flush() +class PostHog::MCP::Client < PostHog::Client +instance_method PostHog::MCP::Client#capture_initialize(client_name: ..., client_version: ..., protocol_version: ..., parameters: ..., response: ..., duration_ms: ..., distinct_id: ..., session_id: ..., client_user_agent: ..., vendor_client: ..., set_properties: ..., groups: ..., properties: ..., timestamp: ...) +instance_method PostHog::MCP::Client#capture_missing_capability(context: ..., parameters: ..., protocol_version: ..., distinct_id: ..., session_id: ..., client_user_agent: ..., vendor_client: ..., set_properties: ..., groups: ..., properties: ..., timestamp: ..., llm_model: ..., llm_model_source: ...) +instance_method PostHog::MCP::Client#capture_tool_call(tool_name, intent: ..., intent_source: ..., parameters: ..., response: ..., duration_ms: ..., is_error: ..., error: ..., error_type: ..., category: ..., tool_description: ..., protocol_version: ..., distinct_id: ..., session_id: ..., client_user_agent: ..., vendor_client: ..., set_properties: ..., groups: ..., properties: ..., timestamp: ..., llm_model: ..., llm_model_source: ...) +instance_method PostHog::MCP::Client#capture_tools_list(tool_names: ..., parameters: ..., response: ..., duration_ms: ..., is_error: ..., error: ..., error_type: ..., protocol_version: ..., distinct_id: ..., session_id: ..., client_user_agent: ..., vendor_client: ..., set_properties: ..., groups: ..., properties: ..., timestamp: ...) +instance_method PostHog::MCP::Client#prepare_tool_call(name, args = ..., input_schema: ...) +instance_method PostHog::MCP::Client#prepare_tool_list(tools, context: ..., report_missing: ..., capture_model: ...) +class PostHog::MCP::ContextOptions < Struct +instance_method PostHog::MCP::ContextOptions#description() +instance_method PostHog::MCP::ContextOptions#description=(_) +class_method PostHog::MCP::ContextOptions.[](*arg0) +class_method PostHog::MCP::ContextOptions.inspect() +class_method PostHog::MCP::ContextOptions.keyword_init?() +class_method PostHog::MCP::ContextOptions.members() +class_method PostHog::MCP::ContextOptions.new(*arg0) +constant PostHog::MCP::DEFAULT_CONTEXT_PARAMETER_DESCRIPTION: String +constant PostHog::MCP::DEFAULT_CONVERSATION_ID_DESCRIPTION: String +constant PostHog::MCP::DEFAULT_MODEL_PARAMETER_DESCRIPTION: String +constant PostHog::MCP::EXPERIMENTAL_NOTICE: String +module PostHog::MCP::Event +constant PostHog::MCP::Event::CUSTOM: String +constant PostHog::MCP::Event::EXCEPTION: String +constant PostHog::MCP::Event::IDENTIFY: String +constant PostHog::MCP::Event::INITIALIZE: String +constant PostHog::MCP::Event::MISSING_CAPABILITY: String +constant PostHog::MCP::Event::PROMPTS_LIST: String +constant PostHog::MCP::Event::PROMPT_GET: String +constant PostHog::MCP::Event::RESOURCES_LIST: String +constant PostHog::MCP::Event::RESOURCE_READ: String +constant PostHog::MCP::Event::TOOLS_LIST: String +constant PostHog::MCP::Event::TOOL_CALL: String +constant PostHog::MCP::INACTIVITY_TIMEOUT_MINUTES: Integer +constant PostHog::MCP::LIB_NAME: String +constant PostHog::MCP::MCP_SESSION_HEADER: String +class PostHog::MCP::ModelOptions < Struct +instance_method PostHog::MCP::ModelOptions#description() +instance_method PostHog::MCP::ModelOptions#description=(_) +class_method PostHog::MCP::ModelOptions.[](*arg0) +class_method PostHog::MCP::ModelOptions.inspect() +class_method PostHog::MCP::ModelOptions.keyword_init?() +class_method PostHog::MCP::ModelOptions.members() +class_method PostHog::MCP::ModelOptions.new(*arg0) +class PostHog::MCP::Options +instance_method PostHog::MCP::Options#before_send() +instance_method PostHog::MCP::Options#capture_model() +instance_method PostHog::MCP::Options#capture_model_enabled?() +instance_method PostHog::MCP::Options#context() +instance_method PostHog::MCP::Options#context_description() +instance_method PostHog::MCP::Options#context_enabled?() +instance_method PostHog::MCP::Options#enable_conversation_id() +instance_method PostHog::MCP::Options#enable_exception_autocapture() +instance_method PostHog::MCP::Options#event_properties() +instance_method PostHog::MCP::Options#identify() +instance_method PostHog::MCP::Options#intent_fallback() +instance_method PostHog::MCP::Options#logger() +instance_method PostHog::MCP::Options#missing_capability_tool_name() +instance_method PostHog::MCP::Options#model_description() +instance_method PostHog::MCP::Options#report_missing() +class PostHog::MCP::PreparedToolCall < Struct +instance_method PostHog::MCP::PreparedToolCall#args() +instance_method PostHog::MCP::PreparedToolCall#args=(_) +instance_method PostHog::MCP::PreparedToolCall#intent() +instance_method PostHog::MCP::PreparedToolCall#intent=(_) +instance_method PostHog::MCP::PreparedToolCall#intent_source() +instance_method PostHog::MCP::PreparedToolCall#intent_source=(_) +instance_method PostHog::MCP::PreparedToolCall#is_missing_capability() +instance_method PostHog::MCP::PreparedToolCall#is_missing_capability=(_) +instance_method PostHog::MCP::PreparedToolCall#llm_model() +instance_method PostHog::MCP::PreparedToolCall#llm_model=(_) +instance_method PostHog::MCP::PreparedToolCall#llm_model_source() +instance_method PostHog::MCP::PreparedToolCall#llm_model_source=(_) +class_method PostHog::MCP::PreparedToolCall.[](*arg0) +class_method PostHog::MCP::PreparedToolCall.inspect() +class_method PostHog::MCP::PreparedToolCall.keyword_init?() +class_method PostHog::MCP::PreparedToolCall.members() +class_method PostHog::MCP::PreparedToolCall.new(*arg0) +module PostHog::MCP::Property +constant PostHog::MCP::Property::CLIENT_NAME: String +constant PostHog::MCP::Property::CLIENT_USER_AGENT: String +constant PostHog::MCP::Property::CLIENT_VERSION: String +constant PostHog::MCP::Property::CONVERSATION_ID: String +constant PostHog::MCP::Property::DURATION_MS: String +constant PostHog::MCP::Property::ERROR_MESSAGE: String +constant PostHog::MCP::Property::ERROR_TYPE: String +constant PostHog::MCP::Property::INTENT: String +constant PostHog::MCP::Property::INTENT_SOURCE: String +constant PostHog::MCP::Property::IS_ERROR: String +constant PostHog::MCP::Property::LISTED_TOOL_NAMES: String +constant PostHog::MCP::Property::LLM_MODEL: String +constant PostHog::MCP::Property::LLM_MODEL_SOURCE: String +constant PostHog::MCP::Property::PARAMETERS: String +constant PostHog::MCP::Property::PROTOCOL_VERSION: String +constant PostHog::MCP::Property::RESOURCE_NAME: String +constant PostHog::MCP::Property::RESPONSE: String +constant PostHog::MCP::Property::SERVER_NAME: String +constant PostHog::MCP::Property::SERVER_VERSION: String +constant PostHog::MCP::Property::SESSION_ID: String +constant PostHog::MCP::Property::SOURCE: String +constant PostHog::MCP::Property::TOOL_CATEGORY: String +constant PostHog::MCP::Property::TOOL_DESCRIPTION: String +constant PostHog::MCP::Property::TOOL_NAME: String +constant PostHog::MCP::Property::VENDOR_CLIENT: String +class PostHog::MCP::RackMiddleware +instance_method PostHog::MCP::RackMiddleware#call(env) +constant PostHog::MCP::RackMiddleware::ENV_KEY: String +constant PostHog::MCP::RackMiddleware::MINT_ENV_KEY: String +constant PostHog::MCP::SOURCE: String +class PostHog::MCP::SessionTokenPayload < Struct +instance_method PostHog::MCP::SessionTokenPayload#client_name() +instance_method PostHog::MCP::SessionTokenPayload#client_name=(_) +instance_method PostHog::MCP::SessionTokenPayload#client_version() +instance_method PostHog::MCP::SessionTokenPayload#client_version=(_) +instance_method PostHog::MCP::SessionTokenPayload#protocol_version() +instance_method PostHog::MCP::SessionTokenPayload#protocol_version=(_) +instance_method PostHog::MCP::SessionTokenPayload#session_id() +instance_method PostHog::MCP::SessionTokenPayload#session_id=(_) +class_method PostHog::MCP::SessionTokenPayload.[](*arg0) +class_method PostHog::MCP::SessionTokenPayload.inspect() +class_method PostHog::MCP::SessionTokenPayload.keyword_init?() +class_method PostHog::MCP::SessionTokenPayload.members() +class_method PostHog::MCP::SessionTokenPayload.new(*arg0) +class PostHog::MCP::UserIdentity < Struct +instance_method PostHog::MCP::UserIdentity#distinct_id() +instance_method PostHog::MCP::UserIdentity#distinct_id=(_) +instance_method PostHog::MCP::UserIdentity#groups() +instance_method PostHog::MCP::UserIdentity#groups=(_) +instance_method PostHog::MCP::UserIdentity#properties() +instance_method PostHog::MCP::UserIdentity#properties=(_) +class_method PostHog::MCP::UserIdentity.[](*arg0) +class_method PostHog::MCP::UserIdentity.inspect() +class_method PostHog::MCP::UserIdentity.keyword_init?() +class_method PostHog::MCP::UserIdentity.members() +class_method PostHog::MCP::UserIdentity.new(*arg0) module PostHog::Rails constant PostHog::Rails::VERSION: String class PostHog::RequiresServerEvaluation < StandardError diff --git a/scripts/public_api_snapshot.rb b/scripts/public_api_snapshot.rb index 4dab515..cdb9bf8 100644 --- a/scripts/public_api_snapshot.rb +++ b/scripts/public_api_snapshot.rb @@ -60,6 +60,8 @@ def check def load_core_sdk add_load_path(CORE_LIB_DIR) require 'posthog' + # Opt-in integration shipped in the core gem; loaded here so its public surface is tracked too. + require 'posthog/mcp' end def captured_core_api diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 105f54e..1d75ae3 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -343,6 +343,16 @@ def shutdown end describe '#capture' do + it 'honours the private per-event _lib/_lib_version override without relabeling the client' do + client.capture(distinct_id: 'u', event: 'mcp', _lib: 'posthog-ruby-mcp', _lib_version: '1.2.3') + properties = client.dequeue_last_message[:properties] + expect(properties).to include('$lib' => 'posthog-ruby-mcp', '$lib_version' => '1.2.3') + expect(properties).not_to have_key(:_lib) + + client.capture(distinct_id: 'u', event: 'plain') + expect(client.dequeue_last_message[:properties]).to include('$lib' => 'posthog-ruby', '$lib_version' => PostHog::VERSION) + end + it 'errors without an event' do expect { client.capture(distinct_id: 'user') }.to raise_error( ArgumentError diff --git a/spec/posthog/mcp/client_spec.rb b/spec/posthog/mcp/client_spec.rb new file mode 100644 index 0000000..a7d9bd8 --- /dev/null +++ b/spec/posthog/mcp/client_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Client do + let(:client) { described_class.new(api_key: 'phc_test', test_mode: true) } + + it 'captures tool calls with $lib override, anonymous distinct id and error scalars' do + client.capture_tool_call('execute-sql', is_error: false, response: { ok: true }) + event = client.dequeue_last_message + expect(event[:event]).to eq('$mcp_tool_call') + expect(event[:distinct_id]).to eq('anonymous') + expect(event[:properties]).to include('$process_person_profile' => false, '$lib' => 'posthog-ruby-mcp', + '$lib_version' => PostHog::VERSION, '$mcp_is_error' => false) + expect(event[:properties]).not_to have_key('$session_id') + + client.capture_tool_call('add', is_error: true, error: ArgumentError.new('bad input'), distinct_id: 'user-123', + set_properties: { email: 'a@b.com' }, groups: { organization: 'org_1' }) + events = drain_events(client) + expect(events.map { |e| e[:event] }).to eq(['$mcp_tool_call', '$exception']) + props = events[0][:properties] + expect(props).to include('$mcp_error_type' => 'ArgumentError', '$mcp_error_message' => 'bad input', + '$set' => { 'email' => 'a@b.com' }, '$groups' => { 'organization' => 'org_1' }) + expect(events[0][:distinct_id]).to eq('user-123') + + client.capture_tool_call('add', is_error: true, error: ArgumentError.new('bad input'), error_type: 'validation') + expect(client.dequeue_last_message[:properties]['$mcp_error_type']).to eq('validation') + client.dequeue_last_message + + client.capture_tool_call('add', is_error: true, error: 'upstream timed out') + expect(client.dequeue_last_message[:properties]).to include('$mcp_error_type' => 'Error', + '$mcp_error_message' => 'upstream timed out') + client.clear + + client.capture_tool_call('add', is_error: true, error: ArgumentError.new('x' * 5000)) + events = drain_events(client) + message = events[0][:properties]['$mcp_error_message'] + expect(message.length).to eq(2051) + expect(message).to eq(events[1][:properties]['$exception_list'][0]['value']) + end + + it 'survives self-referential and oversized custom properties' do + properties = { 'rows' => (1..10_000).to_a } + properties['self'] = properties + expect { client.capture_tool_call('add', is_error: false, properties: properties) }.not_to raise_error + event = client.dequeue_last_message + expect(event[:event]).to eq('$mcp_tool_call') + expect(event[:properties]['self']).to eq('[Circular ~]') + expect(event[:properties]['rows'].length).to be <= PostHog::MCP::Truncation::MAX_BREADTH + 1 + end + + it 'respects mcp_exception_autocapture: false and default error strings' do + quiet = described_class.new(api_key: 'phc_test', test_mode: true, mcp_exception_autocapture: false) + quiet.capture_tool_call('add', is_error: true) + events = drain_events(quiet) + expect(events.length).to eq(1) + expect(events[0][:properties]['$mcp_error_message']).to eq('Tool add returned an error') + quiet.capture_tools_list(is_error: true) + expect(quiet.dequeue_last_message[:properties]['$mcp_error_message']).to eq('tools/list failed') + end + + it 'captures initialize, tools list and missing capability' do + client.capture_initialize(client_name: 'claude-code', client_version: '1.2.3', protocol_version: '2025-06-18', + distinct_id: 'user-123', duration_ms: 7) + props = client.dequeue_last_message[:properties] + expect(props).to include('$mcp_client_name' => 'claude-code', '$mcp_client_version' => '1.2.3', + '$mcp_protocol_version' => '2025-06-18', '$mcp_duration_ms' => 7) + + client.capture_tools_list(tool_names: %w[execute-sql query-logs get_more_tools], duration_ms: 3, distinct_id: 'u') + props = client.dequeue_last_message[:properties] + expect(props['$mcp_listed_tool_names']).to eq(%w[execute-sql query-logs get_more_tools]) + + client.capture_missing_capability(context: ' wanted a tool to export to CSV ', distinct_id: 'u', + llm_model: ' claude-opus-4-8 ') + event = client.dequeue_last_message + expect(event[:event]).to eq('$mcp_missing_capability') + expect(event[:properties]).to include('$mcp_intent' => 'wanted a tool to export to CSV', + '$mcp_intent_source' => 'context_parameter', + '$mcp_resource_name' => 'get_more_tools', + '$mcp_llm_model' => 'claude-opus-4-8', + '$mcp_llm_model_source' => 'self_reported') + end + + it 'prepares tool lists and tool calls' do + tools = [{ name: 'a', inputSchema: { type: 'object', properties: {} } }, { name: 'get_more_tools' }] + prepared = client.prepare_tool_list(tools, report_missing: true) + expect(prepared.length).to eq(2) + expect(prepared[0][:inputSchema][:properties]).to have_key(:context) + expect(tools[0][:inputSchema][:properties]).to eq({}) + prepared = client.prepare_tool_list([tools[0]], context: false, report_missing: true) + expect(prepared.map { |t| t[:name] }).to eq(%w[a get_more_tools]) + + call = client.prepare_tool_call('search', { context: ' find it ', q: 'x' }) + expect(call.to_h).to eq(args: { q: 'x' }, intent: 'find it', intent_source: 'context_parameter', + llm_model: nil, llm_model_source: nil, is_missing_capability: false) + expect(client.prepare_tool_call('get_more_tools').is_missing_capability).to be(true) + end + + it 'advertises llm_model on every tool and on the virtual one when capture_model is on' do + tools = [{ name: 'a', inputSchema: { type: 'object', properties: {} } }] + prepared = client.prepare_tool_list(tools, capture_model: true, report_missing: true) + expect(prepared[0][:inputSchema][:properties].keys).to eq(%i[context llm_model]) + expect(prepared[1][:name]).to eq('get_more_tools') + expect(prepared[1][:inputSchema][:properties].keys).to eq(%i[context llm_model]) + expect(prepared[1][:inputSchema][:required]).to contain_exactly('context', 'llm_model') + + without = client.prepare_tool_list(tools, report_missing: true) + expect(without[0][:inputSchema][:properties].keys).to eq([:context]) + expect(without[1][:inputSchema][:properties].keys).to eq([:context]) + end + + it 'round-trips an injected llm_model and leaves a tool-declared one in args' do + injected = { type: 'object', properties: { title: { type: 'string' } } } + call = client.prepare_tool_call('add', { title: 'x', llm_model: ' claude-opus-4-8 ' }, input_schema: injected) + expect(call.args).to eq(title: 'x') + expect(call.llm_model).to eq('claude-opus-4-8') + expect(call.llm_model_source).to eq('self_reported') + # The stripped args must be safe to splat into a tool that takes keywords. + expect(->(title:) { title }.call(**call.args)).to eq('x') + + own = { type: 'object', properties: { llm_model: { type: 'string' } } } + kept = client.prepare_tool_call('add', { llm_model: 'app-owned' }, input_schema: own) + expect(kept.args).to eq(llm_model: 'app-owned') + expect(kept.llm_model).to be_nil + expect(kept.llm_model_source).to be_nil + + unknown = client.prepare_tool_call('add', { llm_model: 'unknown' }, input_schema: injected) + expect(unknown.args).to eq({}) + expect(unknown.llm_model).to be_nil + end + + it 'leaves a composed or referenced schema, and the context argument it owns, alone' do + composed = { type: 'object', allOf: [{ properties: { context: { type: 'string' } }, required: ['context'] }] } + expect(client.prepare_tool_list([{ name: 'search', inputSchema: composed }])[0][:inputSchema]).to eq(composed) + kept = client.prepare_tool_call('search', { context: 'application data' }, input_schema: composed) + expect(kept.args).to eq(context: 'application data') + expect(kept.intent).to be_nil + + referenced = { type: 'object', :$ref => '#/$defs/payload' } + expect(client.prepare_tool_list([{ name: 'search', inputSchema: referenced }])[0][:inputSchema]).to eq(referenced) + expect(client.prepare_tool_call('search', { context: 'application data' }, input_schema: referenced).args) + .to eq(context: 'application data') + end + + it 'strips only the context argument it injected when given the tool schema' do + own = { type: 'object', properties: { context: { type: 'string' } }, required: ['context'] } + expect(client.prepare_tool_list([{ name: 'search', inputSchema: own }])[0][:inputSchema]).to eq(own) + kept = client.prepare_tool_call('search', { context: 'application data' }, input_schema: own) + expect(kept.args).to eq(context: 'application data') + # The tool declares `context`, so its value is the tool's own data: it stays in + # `args` and must not be reported as the agent's intent. + expect(kept.intent).to be_nil + expect(kept.intent_source).to be_nil + + injected = { type: 'object', properties: { title: { type: 'string' } } } + stripped = client.prepare_tool_call('add', { title: 'x', context: 'agent intent' }, input_schema: injected) + expect(stripped.args).to eq(title: 'x') + expect(stripped.intent).to eq('agent intent') + + expect(client.prepare_tool_call('search', { context: 'application data' }).args).to eq({}) + end +end diff --git a/spec/posthog/mcp/conversation_id_spec.rb b/spec/posthog/mcp/conversation_id_spec.rb new file mode 100644 index 0000000..24b92b3 --- /dev/null +++ b/spec/posthog/mcp/conversation_id_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::ConversationId do + let(:handle) { '019fd2b0-1111-7111-8111-111111111111' } + + describe '.resolve' do + it 'echoes a minted-shaped handle (lowercased) and mints over anything else' do + expect(described_class.resolve(true, handle, 'a_tool', 'get_more_tools')).to eq([handle, false]) + expect(described_class.resolve(true, " #{handle.upcase} ", 'a_tool', 'get_more_tools')).to eq([handle, false]) + %w[conv-1 1 session chat_abc not-a-uuid 019fd2b0-1111-4111-8111-111111111111].each do |value| + id, minted = described_class.resolve(true, value, 'a_tool', 'get_more_tools') + expect(minted).to be(true) + expect(id).not_to eq(value) + expect(id).to match(described_class::MINTED_CONVERSATION_ID) + end + minted_id, = described_class.resolve(true, nil, 'a_tool', 'get_more_tools') + expect(described_class.resolve(true, minted_id, 'a_tool', 'get_more_tools')).to eq([minted_id, false]) + end + + it 'mints when handed no value it owns' do + # A tool that declares `conversation_id` itself is never stripped, so nothing + # reaches `.resolve` and its value cannot anchor an analytics conversation. + [nil, 123, ' '].each do |supplied| + id, minted = described_class.resolve(true, supplied, 'a_tool', 'get_more_tools') + expect(minted).to be(true) + expect(id).to match(described_class::MINTED_CONVERSATION_ID) + end + end + + it 'is inert when disabled or for the missing-capability tool' do + expect(described_class.resolve(false, handle, 'a_tool', 'get_more_tools')).to eq([nil, false]) + expect(described_class.resolve(true, nil, 'get_more_tools', 'get_more_tools')).to eq([nil, false]) + end + end + + describe '.extract' do + it 'trims and rejects non-strings' do + expect(described_class.extract('conversation_id' => ' abc ')).to eq('abc') + expect(described_class.extract(conversation_id: 123)).to be_nil + expect(described_class.extract(conversation_id: ' ')).to be_nil + expect(described_class.extract('not a hash')).to be_nil + expect(described_class.extract(nil)).to be_nil + end + end + + describe '.inject_prompt_back' do + it 'appends a compact JSON text block when content is an array, including errored results' do + result = described_class.inject_prompt_back({ content: [{ type: 'text', text: 'hello' }] }, 'conv-123') + expect(result[:content].length).to eq(2) + expect(result[:content][1]).to eq(type: 'text', text: '{"conversation_id":"conv-123"}') + errored = described_class.inject_prompt_back({ isError: true, content: [{ type: 'text', text: 'oops' }] }, + 'conv-123') + expect(errored[:content][1][:text]).to include('conv-123') + expect(described_class.inject_prompt_back({}, 'conv-123')).to eq({}) + expect(described_class.inject_prompt_back({ content: 'not-an-array' }, 'conv-123')).to eq(content: 'not-an-array') + expect(described_class.inject_prompt_back(nil, 'conv-123')).to be_nil + expect(described_class.inject_prompt_back('string', 'conv-123')).to eq('string') + end + end + + describe '.mirror_instructions' do + it 'writes into structuredContent only when present and customer data wins' do + result, delivered = described_class.mirror_instructions({ content: [], structuredContent: { total: 7 } }, + 'conv-9') + expect(delivered).to be(true) + expect(result[:structuredContent]).to eq(total: 7, _mcp_instructions: { 'conversation_id' => 'conv-9' }) + + untouched = { content: [], structuredContent: { _mcp_instructions: { 'x' => 1 } } } + expect(described_class.mirror_instructions(untouched, 'conv-9')).to eq([untouched, false]) + expect(described_class.mirror_instructions({ content: [] }, 'conv-9')).to eq([{ content: [] }, false]) + expect(described_class.mirror_instructions('nope', 'conv-9')).to eq(['nope', false]) + end + end +end diff --git a/spec/posthog/mcp/event_builder_spec.rb b/spec/posthog/mcp/event_builder_spec.rb new file mode 100644 index 0000000..e7f5279 --- /dev/null +++ b/spec/posthog/mcp/event_builder_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::EventBuilder do + let(:base) do + { + 'id' => 'evt_test123', 'session_id' => 'ses_session456', 'event_type' => 'mcp:tools/call', + 'timestamp' => Time.utc(2025, 1, 15, 10), 'resource_name' => 'get_weather', + 'server_name' => 'weather-server', 'server_version' => '1.0.0', + 'client_name' => 'claude-desktop', 'client_version' => '2.0.0', 'duration' => 150, 'is_error' => false + } + end + + def error_for(message, type) + { '$exception_list' => [{ 'type' => type, 'value' => message, 'mechanism' => { 'type' => 'generic', 'handled' => true } }], + '$exception_level' => 'error' } + end + + it 'builds the default tool call payload exactly' do + events = described_class.build(base) + expect(events.length).to eq(1) + event = events[0] + expect(event['event']).to eq('$mcp_tool_call') + expect(event['distinct_id']).to eq('ses_session456') + expect(event['timestamp']).to eq(Time.utc(2025, 1, 15, 10)) + expect(event['properties']).to eq( + '$session_id' => 'ses_session456', '$mcp_source' => 'posthog_mcp_analytics', '$mcp_tool_name' => 'get_weather', + '$mcp_resource_name' => 'get_weather', '$mcp_duration_ms' => 150, '$mcp_server_name' => 'weather-server', + '$mcp_server_version' => '1.0.0', '$mcp_client_name' => 'claude-desktop', '$mcp_client_version' => '2.0.0', + '$mcp_is_error' => false, '$process_person_profile' => false + ) + end + + it 'adds identity, intent, parameters and response' do + event = described_class.build(base.merge( + 'identify_actor_given_id' => 'user_abc123', 'duration' => 250, 'parameters' => { 'city' => 'London' }, + 'response' => { 'temp' => 15 }, 'user_intent' => 'Check the weather in London', + 'user_intent_source' => 'context_parameter', 'identify_actor_data' => { 'name' => 'Alice', 'plan' => 'pro' }, + 'groups' => { 'organization' => 'org_123' }, 'llm_model' => 'claude-opus-4-8', 'llm_model_source' => 'self_reported' + ))[0] + props = event['properties'] + expect(event['distinct_id']).to eq('user_abc123') + expect(props).to include('$mcp_duration_ms' => 250, '$mcp_intent' => 'Check the weather in London', + '$mcp_intent_source' => 'context_parameter', '$mcp_parameters' => { 'city' => 'London' }, + '$mcp_response' => { 'temp' => 15 }, '$set' => { 'name' => 'Alice', 'plan' => 'pro' }, + '$groups' => { 'organization' => 'org_123' }, '$mcp_llm_model' => 'claude-opus-4-8', + '$mcp_llm_model_source' => 'self_reported') + expect(props).not_to have_key('$process_person_profile') + end + + it 'fans out an $exception sibling with the narrower property set' do + events = described_class.build(base.merge('is_error' => true, 'error' => error_for('Connection timeout', 'TimeoutError'), + 'protocol_version' => '2025-06-18', 'tool_description' => 'Fetches weather', + 'tool_category' => 'Logs', 'groups' => { 'organization' => 'org_123' }, + 'properties' => { 'deployment' => 'prod' }, 'parameters' => { 'a' => 1 })) + expect(events.length).to eq(2) + primary = events[0]['properties'] + expect(primary).to include('$mcp_is_error' => true, '$mcp_error_type' => 'TimeoutError', + '$mcp_error_message' => 'Connection timeout', '$mcp_tool_description' => 'Fetches weather', + '$mcp_tool_category' => 'Logs') + sibling = events[1] + expect(sibling['event']).to eq('$exception') + expect(sibling['distinct_id']).to eq('ses_session456') + expect(sibling['properties']).to eq( + '$session_id' => 'ses_session456', '$process_person_profile' => false, '$groups' => { 'organization' => 'org_123' }, + '$exception_list' => [{ 'type' => 'TimeoutError', 'value' => 'Connection timeout', + 'mechanism' => { 'type' => 'generic', 'handled' => true } }], + '$exception_level' => 'error', '$mcp_resource_name' => 'get_weather', '$mcp_tool_name' => 'get_weather', + '$mcp_tool_description' => 'Fetches weather', '$mcp_tool_category' => 'Logs', '$mcp_server_name' => 'weather-server', + '$mcp_server_version' => '1.0.0', '$mcp_client_name' => 'claude-desktop', '$mcp_client_version' => '2.0.0', + '$mcp_protocol_version' => '2025-06-18', 'deployment' => 'prod' + ) + end + + it 'honours the exception autocapture toggle and the error matrix' do + errored = base.merge('is_error' => true, 'error' => error_for('Connection timeout', 'TimeoutError')) + expect(described_class.build(errored, enable_exception_autocapture: false).length).to eq(1) + + props = described_class.build(errored.merge('error_type' => 'rate_limited'))[0]['properties'] + expect(props).to include('$mcp_error_type' => 'rate_limited', '$mcp_error_message' => 'Connection timeout') + + props = described_class.build(base.merge('is_error' => true, 'error_type' => 'validation'))[0]['properties'] + expect(props).to include('$mcp_error_type' => 'validation') + expect(props).not_to have_key('$mcp_error_message') + + props = described_class.build(base)[0]['properties'] + expect(props).not_to have_key('$mcp_error_type') + expect(props).not_to have_key('$mcp_error_message') + end + + it 'steps past the Ruby dispatch wrapper when picking error scalars' do + error = { '$exception_list' => [ + { 'type' => 'MCP::Server::RequestHandlerError', 'value' => 'Internal error calling tool boom' }, + { 'type' => 'ArgumentError', 'value' => 'explode' } + ] } + props = described_class.build(base.merge('is_error' => true, 'error' => error))[0]['properties'] + expect(props).to include('$mcp_error_type' => 'ArgumentError', '$mcp_error_message' => 'explode') + end + + it 'scopes tool-only properties and listed tool names by event type' do + props = described_class.build(base.merge('event_type' => 'mcp:resources/read', 'resource_name' => 'my_resource', + 'tool_description' => 'd', 'listed_tool_names' => ['ignored']))[0]['properties'] + expect(props['$mcp_resource_name']).to eq('my_resource') + expect(props).not_to have_key('$mcp_tool_name') + expect(props).not_to have_key('$mcp_tool_description') + expect(props).not_to have_key('$mcp_listed_tool_names') + + props = described_class.build(base.merge('event_type' => 'mcp:tools/list', 'resource_name' => nil, + 'listed_tool_names' => %w[get_weather list_alerts]))[0]['properties'] + expect(props['$mcp_listed_tool_names']).to eq(%w[get_weather list_alerts]) + props = described_class.build(base.merge('event_type' => 'mcp:tools/list', + 'listed_tool_names' => []))[0]['properties'] + expect(props).not_to have_key('$mcp_listed_tool_names') + end + + it 'maps every event type and sends custom names verbatim' do + { + 'posthog:custom' => '$mcp_custom', 'posthog:identify' => '$identify', 'mcp:tools/call' => '$mcp_tool_call', + 'mcp:tools/list' => '$mcp_tools_list', 'mcp:initialize' => '$mcp_initialize', + 'mcp:resources/read' => '$mcp_resource_read', 'mcp:resources/list' => '$mcp_resources_list', + 'mcp:prompts/get' => '$mcp_prompt_get', 'mcp:prompts/list' => '$mcp_prompts_list', + 'mcp:missing_capability' => '$mcp_missing_capability' + }.each do |type, name| + expect(described_class.build({ 'event_type' => type })[0]['event']).to eq(name) + end + event = described_class.build({ 'event_type' => 'posthog:custom', 'event_name' => 'feedback_submitted', + 'properties' => { 'rating' => 5 } })[0] + expect(event['event']).to eq('feedback_submitted') + expect(event['properties']['rating']).to eq(5) + expect(event['distinct_id']).to eq('anonymous') + end +end +# rubocop:enable Layout/LineLength diff --git a/spec/posthog/mcp/exceptions_spec.rb b/spec/posthog/mcp/exceptions_spec.rb new file mode 100644 index 0000000..89591cf --- /dev/null +++ b/spec/posthog/mcp/exceptions_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Exceptions do + it 'builds $exception_list from a Ruby exception via ExceptionCapture' do + error = begin + raise ArgumentError, 'bad input' + rescue ArgumentError => e + e + end + result = described_class.capture_exception(error) + expect(result['$exception_level']).to eq('error') + first = result['$exception_list'][0] + expect(first['type']).to eq('ArgumentError') + expect(first['value']).to eq('bad input') + expect(first['stacktrace']['frames']).not_to be_empty + end + + it 'builds a generic entry from strings and isError results' do + expect(described_class.capture_exception('upstream timed out')['$exception_list']).to eq( + [{ 'mechanism' => { 'type' => 'generic', 'handled' => true }, 'type' => 'Error', + 'value' => 'upstream timed out' }] + ) + result = described_class.capture_exception('isError' => true, + 'content' => [{ + 'type' => 'text', 'text' => 'tool failed badly' + }]) + expect(result['$exception_list'][0]['value']).to eq('tool failed badly') + result = described_class.capture_exception(isError: true, content: []) + expect(result['$exception_list'][0]['value']).to eq('Unknown error') + end + + it 'keeps the wrapper chain and appends an original_error not on the cause chain' do + inner = ArgumentError.new('explode') + wrapper = MCP::Server::RequestHandlerError.new('Internal error calling tool boom', {}, original_error: inner) + result = described_class.capture_exception(wrapper) + types = result['$exception_list'].map { |entry| entry['type'] } + expect(types).to eq(%w[MCP::Server::RequestHandlerError ArgumentError]) + primary = described_class.primary_exception(result) + expect(primary['type']).to eq('ArgumentError') + expect(primary['value']).to eq('explode') + end + + it 'does not unwrap a wrapper without a cause' do + wrapper = MCP::Server::RequestHandlerError.new('Internal error calling tool boom', {}) + result = described_class.capture_exception(wrapper) + expect(described_class.primary_exception(result)['type']).to eq('MCP::Server::RequestHandlerError') + expect(described_class.primary_exception(result)['value']).to eq('Internal error calling tool boom') + end +end diff --git a/spec/posthog/mcp/http_transport_spec.rb b/spec/posthog/mcp/http_transport_spec.rb new file mode 100644 index 0000000..eb96449 --- /dev/null +++ b/spec/posthog/mcp/http_transport_spec.rb @@ -0,0 +1,342 @@ +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength + +require_relative 'spec_helper' +require 'rack' +require 'stringio' + +class PostHogMcpHttpSpecTool < MCP::Tool + tool_name 'ping_tool' + input_schema(properties: {}) + class << self + def call(**) + MCP::Tool::Response.new([{ type: 'text', text: 'pong' }]) + end + end +end + +class PostHogMcpHttpSpecCaptureTool < MCP::Tool + tool_name 'capture_tool' + input_schema(properties: {}) + class << self + attr_accessor :analytics, :before_capture + + def call(**) + before_capture&.call + analytics.capture('in_tool_event', { from: 'tool' }) + MCP::Tool::Response.new([{ type: 'text', text: 'captured' }]) + end + end +end + +RSpec.describe 'PostHog::MCP over Streamable HTTP' do + let(:client) { new_test_client } + let(:server) { MCP::Server.new(name: 'http-server', version: '1.0.0', tools: [PostHogMcpHttpSpecTool]) } + + before { allow(Kernel).to receive(:warn) } + + def env_for(body, headers = {}) + env = { + 'REQUEST_METHOD' => 'POST', 'PATH_INFO' => '/', 'SCRIPT_NAME' => '', 'QUERY_STRING' => '', + 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80', 'HTTP_HOST' => 'localhost', 'rack.url_scheme' => 'http', + 'HTTP_ACCEPT' => 'application/json, text/event-stream', 'CONTENT_TYPE' => 'application/json', + 'rack.input' => StringIO.new(JSON.generate(body)), 'rack.errors' => StringIO.new + } + headers.each { |name, value| env["HTTP_#{name.upcase.tr('-', '_')}"] = value } + env + end + + def initialize_body(id = 1, version = '2025-06-18') + rpc(id, 'initialize', { protocolVersion: version, capabilities: {}, clientInfo: { name: 'claude-code', version: '1.2.3' } }) + end + + def parse(response) + JSON.parse(response[2].respond_to?(:join) ? response[2].join : response[2].to_s) + end + + context 'stateless mode' do + let(:transport) { MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true, enable_json_response: true) } + + it 'mints a session token on initialize, recovers it on replay, and stamps transport identity' do + PostHog::MCP.instrument(server, client) + response = transport.call(env_for(initialize_body, 'user-agent' => 'claude-code/2.1.0 (cli)', 'x-anthropic-client' => 'cli')) + expect(response[0]).to eq(200) + token = response[1]['mcp-session-id'] + payload = PostHog::MCP.decode_session_id(token) + expect(payload.session_id).to match(/\Ases_/) + expect(payload.client_name).to eq('claude-code') + expect(payload.client_version).to eq('1.2.3') + expect(payload.protocol_version).to eq('2025-06-18') + + replay = transport.call(env_for(rpc(2, 'tools/call', { name: 'ping_tool', arguments: {} }), + 'mcp-session-id' => token, 'user-agent' => 'claude-code/2.1.0 (cli)', + 'x-anthropic-client' => 'cli')) + expect(parse(replay)['result']['content'][0]['text']).to eq('pong') + expect(replay[1]['mcp-session-id']).to be_nil + + events = drain_events(client) + init = events.find { |e| e[:event] == '$mcp_initialize' } + expect(init[:properties]['$session_id']).to eq(payload.session_id) + expect(init[:properties]).to include('$mcp_client_user_agent' => 'claude-code/2.1.0 (cli)', '$mcp_vendor_client' => 'cli') + call = events.find { |e| e[:event] == '$mcp_tool_call' } + expect(call[:properties]).to include('$session_id' => payload.session_id, '$mcp_client_name' => 'claude-code', + '$mcp_client_version' => '1.2.3', '$mcp_protocol_version' => '2025-06-18', + '$mcp_client_user_agent' => 'claude-code/2.1.0 (cli)') + expect(events.count { |e| e[:event] == '$mcp_initialize' }).to eq(1) + end + + it 'does not mint for modern-era handshakes and warns once for tokenless requests' do + logs = [] + PostHog::MCP.instrument(server, client, logger: ->(m) { logs << m }) + response = transport.call(env_for(initialize_body(1, 'draft'))) + expect(response[1]['mcp-session-id']).to be_nil + 2.times { |i| transport.call(env_for(rpc(i + 2, 'tools/call', { name: 'ping_tool', arguments: {} }))) } + expect(logs.count { |m| m.include?('fragment across requests') }).to eq(1) + end + + it 'leaves uninstrumented servers alone' do + response = transport.call(env_for(initialize_body)) + expect(response[0]).to eq(200) + expect(response[1]['mcp-session-id']).to be_nil + expect(client.queued_messages).to eq(0) + end + end + + context 'stateful mode' do + let(:transport) { MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true) } + + it 'hashes the transport session id deterministically across requests' do + PostHog::MCP.instrument(server, client) + response = transport.call(env_for(initialize_body)) + session_id = response[1]['mcp-session-id'] + expect(PostHog::MCP.decode_session_id(session_id)).to be_nil + transport.call(env_for(rpc(2, 'tools/call', { name: 'ping_tool', arguments: {} }), 'mcp-session-id' => session_id)) + events = drain_events(client) + expected = PostHog::MCP.derive_session_id_from_mcp_session(session_id) + expect(events.map { |e| e[:properties]['$session_id'] }.uniq).to eq([expected]) + expect(events.map { |e| e[:event] }).to eq(['$mcp_initialize', '$mcp_tool_call']) + ensure + transport.close + end + + it 'runs get_more_tools through the real request lifecycle so in-flight entries are released' do + PostHog::MCP.instrument(server, client, report_missing: true) + session_id = transport.call(env_for(initialize_body))[1]['mcp-session-id'] + 3.times do |i| + response = transport.call(env_for(rpc(i + 2, 'tools/call', { name: 'get_more_tools', arguments: { context: 'csv export' } }), + 'mcp-session-id' => session_id)) + expect(response[0]).to eq(200) + expect(parse(response)['result']['content'][0]['text']).to include('Unfortunately') + end + server_session = transport.instance_variable_get(:@sessions).fetch(session_id).fetch(:server_session) + expect((2..4).none? { |id| server_session.in_flight?(id) }).to be(true) + expect(drain_events(client).count { |e| e[:event] == '$mcp_missing_capability' }).to eq(3) + ensure + transport.close + end + end + + context 'custom events captured inside a tool body' do + let(:server) do + MCP::Server.new(name: 'http-server', version: '1.0.0', tools: [PostHogMcpHttpSpecTool, PostHogMcpHttpSpecCaptureTool]) + end + let(:transport) { MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true, enable_json_response: true) } + let(:identify) { ->(_request, extra) { { distinct_id: extra['headers']['user-agent'] } } } + + before do + PostHogMcpHttpSpecCaptureTool.analytics = PostHog::MCP.instrument(server, client, identify: identify) + PostHogMcpHttpSpecCaptureTool.before_capture = nil + end + + def initialize_as(user) + transport.call(env_for(initialize_body, 'user-agent' => user))[1]['mcp-session-id'] + end + + def call_capture_tool_as(user, token, id: 2) + transport.call(env_for(rpc(id, 'tools/call', { name: 'capture_tool', arguments: {} }), 'mcp-session-id' => token, 'user-agent' => user)) + end + + # distinct_id => $session_id for every event with the given name + def attribution(events, name) + events.select { |e| e[:event] == name }.to_h { |e| [e[:distinct_id], e[:properties]['$session_id']] } + end + + it 'attributes the event to the caller, not to the last request the server finished' do + alice = initialize_as('alice') + initialize_as('bob') + call_capture_tool_as('alice', alice) + + events = drain_events(client) + expect(attribution(events, 'in_tool_event')).to eq('alice' => PostHog::MCP.decode_session_id(alice).session_id) + expect(attribution(events, 'in_tool_event')).to eq(attribution(events, '$mcp_tool_call')) + end + + it 'attributes the event to the caller while another request runs to completion on the same server' do + alice = initialize_as('alice') + bob = initialize_as('bob') + bob_finished = Queue.new + PostHogMcpHttpSpecCaptureTool.before_capture = lambda do + # Runs inside Alice's tool body: let Bob's whole request finish before Alice captures. + PostHogMcpHttpSpecCaptureTool.before_capture = nil + Thread.new do + call_capture_tool_as('bob', bob, id: 3) + bob_finished << true + end + bob_finished.pop + end + call_capture_tool_as('alice', alice) + + events = drain_events(client) + expect(attribution(events, 'in_tool_event')).to eq( + 'alice' => PostHog::MCP.decode_session_id(alice).session_id, + 'bob' => PostHog::MCP.decode_session_id(bob).session_id + ) + expect(attribution(events, 'in_tool_event')).to eq(attribution(events, '$mcp_tool_call')) + end + end +end + +# An input the middleware must never touch: reading it fails the example. +class PostHogMcpUnreadableInput + def read(*) = raise('the middleware read the request body') + def gets(*) = raise('the middleware read the request body') + def each(*) = raise('the middleware read the request body') +end + +RSpec.describe PostHog::MCP::RackMiddleware do + let(:client) { new_test_client } + let(:server) { MCP::Server.new(name: 'rack-server', version: '1.0.0', tools: [PostHogMcpHttpSpecTool]) } + + before { allow(Kernel).to receive(:warn) } + + def env_for(body, headers = {}) + env = { 'REQUEST_METHOD' => 'POST', 'rack.input' => StringIO.new(body) } + headers.each { |name, value| env["HTTP_#{name.upcase.tr('-', '_')}"] = value } + env + end + + def initialize_json(version = '2025-06-18') + JSON.generate(rpc(1, 'initialize', { protocolVersion: version, capabilities: {}, clientInfo: { name: 'claude-code', version: '1.2.3' } })) + end + + # A dispatcher that reads the body itself, exactly as a custom Rack stack does. + def dispatching_app(status: 200, body: nil) + lambda do |env| + request = JSON.parse(env['rack.input'].read) + [status, { 'content-type' => 'application/json' }, [body || server.handle_json(JSON.generate(request))]] + end + end + + it 'mints from the instrumented server without reading the request body' do + PostHog::MCP.instrument(server, client) + env = { 'REQUEST_METHOD' => 'POST', 'rack.input' => PostHogMcpUnreadableInput.new } + app = ->(_e) { [200, { 'content-type' => 'application/json' }, [server.handle_json(initialize_json)]] } + status, headers, = described_class.new(app).call(env) + + expect(status).to eq(200) + payload = PostHog::MCP.decode_session_id(headers['mcp-session-id']) + expect(payload.session_id).to match(/\Ases_/) + expect(payload.client_name).to eq('claude-code') + expect(payload.protocol_version).to eq('2025-06-18') + expect(env['posthog_mcp.session']).to eq(payload) + expect(drain_events(client).find { |e| e[:event] == '$mcp_initialize' }[:properties]['$session_id']) + .to eq(payload.session_id) + end + + it 'publishes the request headers so a server below it sees the HTTP context' do + PostHog::MCP.instrument(server, client) + env = env_for(initialize_json, 'user-agent' => 'claude-code/2.1.0 (cli)', 'x-anthropic-client' => 'cli') + _, headers, = described_class.new(dispatching_app).call(env) + token = headers['mcp-session-id'] + + replay = env_for(JSON.generate(rpc(2, 'tools/call', { name: 'ping_tool', arguments: {} })), + 'mcp-session-id' => token, 'user-agent' => 'claude-code/2.1.0 (cli)') + _, replay_headers, = described_class.new(dispatching_app).call(replay) + expect(replay_headers['mcp-session-id']).to be_nil + expect(replay['posthog_mcp.session'].session_id).to eq(PostHog::MCP.decode_session_id(token).session_id) + + events = drain_events(client) + expect(events.map { |e| e[:properties]['$session_id'] }.uniq).to eq([PostHog::MCP.decode_session_id(token).session_id]) + expect(events.last[:properties]).to include('$mcp_client_name' => 'claude-code', '$mcp_client_version' => '1.2.3', + '$mcp_client_user_agent' => 'claude-code/2.1.0 (cli)') + end + + it 'mints nothing for a modern-era handshake or one the server rejects' do + PostHog::MCP.instrument(server, client) + env = env_for(initialize_json('draft')) + _, headers, = described_class.new(dispatching_app).call(env) + expect(headers['mcp-session-id']).to be_nil + expect(env['posthog_mcp.session']).to be_nil + + # A JSON-RPC error rides on a 200, and nothing below minted a token for it. + rejected = JSON.generate({ jsonrpc: '2.0', id: 1, error: { code: -32_602, message: 'Unsupported protocol version' } }) + env = env_for(initialize_json) + _, headers, = described_class.new(dispatching_app(body: rejected)).call(env) + expect(headers['mcp-session-id']).to be_nil + expect(env['posthog_mcp.session']).to be_nil + end + + it 'lets a hand-rolled dispatcher mint the session it captures against' do + minted = nil + app = lambda do |env| + params = JSON.parse(env['rack.input'].read)['params'] + minted = env['posthog_mcp.mint'].call(client_name: params['clientInfo']['name'], + client_version: params['clientInfo']['version'], + protocol_version: params['protocolVersion']) + [200, {}, ['{}']] + end + env = env_for(initialize_json) + _, headers, = described_class.new(app).call(env) + + expect(minted.client_name).to eq('claude-code') + expect(PostHog::MCP.decode_session_id(headers['mcp-session-id'])).to eq(minted) + expect(env['posthog_mcp.session']).to eq(minted) + expect(env).not_to have_key('posthog_mcp.mint') + end + + it 'refuses to mint for a modern-era client' do + minted = :unset + app = lambda do |env| + minted = env['posthog_mcp.mint'].call(client_name: 'c', protocol_version: '2026-07-28') + [200, {}, ['{}']] + end + _, headers, = described_class.new(app).call(env_for(initialize_json('2026-07-28'))) + expect(minted).to be_nil + expect(headers['mcp-session-id']).to be_nil + end + + it 'withholds a token the client cannot use when the request then fails' do + app = lambda do |env| + env['posthog_mcp.mint'].call(client_name: 'c', protocol_version: '2025-06-18') + [400, {}, ['{}']] + end + env = env_for(initialize_json) + _, headers, = described_class.new(app).call(env) + expect(headers['mcp-session-id']).to be_nil + expect(env['posthog_mcp.session']).to be_nil + end + + it 'attaches the token to a streaming body and never clobbers a replayed header' do + PostHog::MCP.instrument(server, client) + streaming = Class.new do + def initialize(json) = @json = json + def each = yield("data: #{@json}\n\n") + end + sse = lambda do |_env| + [200, { 'content-type' => 'text/event-stream' }, streaming.new(server.handle_json(initialize_json))] + end + _, headers, = described_class.new(sse).call(env_for(initialize_json)) + expect(headers['mcp-session-id']).not_to be_nil + + token = PostHog::MCP.encode_session_id(session_id: 'ses_replayed') + replay = env_for('{}', 'mcp-session-id' => token) + app = ->(env) { [200, {}, [env.key?('posthog_mcp.mint') ? 'hook' : 'no-hook']] } + _, headers, body = described_class.new(app).call(replay) + expect(headers['mcp-session-id']).to be_nil + expect(replay['posthog_mcp.session'].session_id).to eq('ses_replayed') + expect(body).to eq(['no-hook']) + end +end + +# rubocop:enable Layout/LineLength diff --git a/spec/posthog/mcp/identity_and_session_spec.rb b/spec/posthog/mcp/identity_and_session_spec.rb new file mode 100644 index 0000000..44e7ba8 --- /dev/null +++ b/spec/posthog/mcp/identity_and_session_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Identity do + let(:data) { PostHog::MCP::TrackingData.new(options: options, sink: nil) } + let(:options) { PostHog::MCP::Options.new(identify: identify) } + let(:identify) { ->(_request, _extra) { { distinct_id: 'user-1', properties: { role: 'developer' } } } } + let(:request) { { method: 'tools/call', params: { name: 'add_todo', arguments: {} } } } + + it 'fires $identify once per session and again only on material change' do + event = described_class.handle_identify(data, 'ses_1', request, { 'session_id' => 'abc', 'session' => Object.new }) + expect(event['event_type']).to eq('posthog:identify') + expect(event['resource_name']).to eq('add_todo') + expect(event['parameters']['extra']).to eq('session_id' => 'abc') + expect(described_class.handle_identify(data, 'ses_1', request, nil)).to be_nil + expect(data.identified_sessions.get('ses_1').to_h).to eq(distinct_id: 'user-1', properties: { role: 'developer' }, + groups: nil) + + data.options.instance_variable_set(:@identify, ->(_r, _e) { { distinct_id: 'user-2', groups: { org: 'o' } } }) + changed = described_class.handle_identify(data, 'ses_1', request, nil) + expect(changed).not_to be_nil + merged = data.identified_sessions.get('ses_1') + expect(merged.distinct_id).to eq('user-2') + expect(merged.properties).to eq(role: 'developer') + expect(merged.groups).to eq(org: 'o') + end + + it 'accepts static identities, 1-arity callables, and swallows errors and nils' do + static = PostHog::MCP::TrackingData.new(options: PostHog::MCP::Options.new(identify: { distinct_id: 'static' }), + sink: nil) + expect(described_class.handle_identify(static, 'ses_1', request, nil)['session_id']).to eq('ses_1') + one = PostHog::MCP::TrackingData.new(options: PostHog::MCP::Options.new(identify: lambda { |req| + { distinct_id: req[:params][:name] } + }), sink: nil) + described_class.handle_identify(one, 'ses_1', request, nil) + expect(one.identified_sessions.get('ses_1').distinct_id).to eq('add_todo') + boom = PostHog::MCP::TrackingData.new(options: PostHog::MCP::Options.new(identify: lambda { |_r, _e| + raise 'nope' + }), sink: nil) + expect(described_class.handle_identify(boom, 'ses_1', request, nil)).to be_nil + nils = PostHog::MCP::TrackingData.new(options: PostHog::MCP::Options.new(identify: ->(_r, _e) {}), sink: nil) + expect(described_class.handle_identify(nils, 'ses_1', request, nil)).to be_nil + end + + it 'hands back the identity it resolved, as a snapshot a later request cannot move' do + event, actor = described_class.identify_for_request(data, 'ses_1', request, nil) + expect(event['event_type']).to eq('posthog:identify') + expect(actor.distinct_id).to eq('user-1') + + data.options.instance_variable_set(:@identify, ->(_r, _e) { { distinct_id: 'user-2' } }) + _later, later_actor = described_class.identify_for_request(data, 'ses_1', request, nil) + expect(later_actor.distinct_id).to eq('user-2') + expect(data.identified_sessions.get('ses_1').distinct_id).to eq('user-2') + # The cache has moved on; the first request's own actor has not, which is what + # keeps its events attributed to it after its handler returns. + expect(actor.distinct_id).to eq('user-1') + end + + it 'falls back to the cached identity at resolution time when the callback yields nothing' do + described_class.identify_for_request(data, 'ses_1', request, nil) + data.options.instance_variable_set(:@identify, ->(_r, _e) { raise 'nope' }) + event, actor = described_class.identify_for_request(data, 'ses_1', request, nil) + expect(event).to be_nil + expect(actor.distinct_id).to eq('user-1') + + data.options.instance_variable_set(:@identify, ->(_r, _e) {}) + expect(described_class.identify_for_request(data, 'ses_1', request, nil)[1].distinct_id).to eq('user-1') + + # No `identify` option at all: nothing to attribute, and nothing cached either. + none = PostHog::MCP::TrackingData.new(options: PostHog::MCP::Options.new, sink: nil) + expect(described_class.identify_for_request(none, 'ses_1', request, nil)).to eq([nil, nil]) + end + + it 'keeps the identity cache consistent under concurrent identification' do + concurrent = PostHog::MCP::TrackingData.new( + options: PostHog::MCP::Options.new(identify: lambda { |req, _e| + { distinct_id: 'user-1', properties: { req[:params][:name] => true } } + }), + sink: nil + ) + threads = 16.times.map do |i| + Thread.new do + 50.times do |j| + described_class.handle_identify(concurrent, "ses_#{j % 4}", { params: { name: "t#{i}" } }, nil) + end + end + end + threads.each(&:join) + expect(concurrent.identified_sessions.size).to eq(4) + 4.times do |j| + identity = concurrent.identified_sessions.get("ses_#{j}") + expect(identity.distinct_id).to eq('user-1') + expect(identity.properties.keys.length).to eq(16) + end + end + + it 'bounds the identity cache as an LRU' do + cache = PostHog::MCP::IdentityCache.new(2) + cache.set('a', 1) + cache.set('b', 2) + cache.get('a') + cache.set('c', 3) + expect(cache.has?('b')).to be(false) + expect(cache.has?('a')).to be(true) + expect(cache.size).to eq(2) + end +end + +RSpec.describe PostHog::MCP::Session do + let(:data) { PostHog::MCP::TrackingData.new(options: PostHog::MCP::Options.new, sink: nil) } + + it 'anchors on an echoed conversation id without touching shared state' do + expect(described_class.resolve(data, 'transport-session', conversation_id: '0198d3a7-1111-7222-8333-444455556666')) + .to eq(%w[ses_57a5f3768678e803a4af9566ca8a661b conversation]) + expect(data.session_id).to be_nil + end + + it 'uses a token session verbatim, hashes transport sessions stickily, and generates otherwise' do + token = PostHog::MCP::SessionTokenPayload.new(session_id: 'ses_tok') + expect(described_class.resolve(data, 'ignored-raw', token: token)).to eq(%w[ses_tok token]) + generated, source = described_class.resolve(data, nil) + expect(source).to eq('generated') + expect(generated).not_to eq('ses_tok') + + expect(described_class.resolve(data, + 'mcp-123')).to eq([PostHog::MCP.derive_session_id_from_mcp_session('mcp-123'), + 'mcp']) + expect(described_class.resolve(data, + nil)).to eq([PostHog::MCP.derive_session_id_from_mcp_session('mcp-123'), 'mcp']) + end + + it 'rolls generated sessions over after 30 minutes of inactivity' do + first, = described_class.resolve(data, nil) + expect(described_class.resolve(data, nil)[0]).to eq(first) + data.last_activity = Time.now - (31 * 60) + expect(described_class.resolve(data, nil)[0]).not_to eq(first) + token = PostHog::MCP::SessionTokenPayload.new(session_id: 'ses_tok') + described_class.resolve(data, nil, token: token) + data.last_activity = Time.now - (31 * 60) + expect(described_class.resolve(data, nil, token: token)[0]).to eq('ses_tok') + end +end + +RSpec.describe PostHog::MCP::TrackingData do + let(:data) { described_class.new(options: PostHog::MCP::Options.new, sink: nil) } + + it 'lets exactly one racing caller claim a session initialize' do + # Check and mark must be one critical section: two threads opening the same + # session concurrently would otherwise both emit an $mcp_initialize. + 50.times do |round| + session_id = "ses_#{round}" + gate = Queue.new + threads = Array.new(12) do + Thread.new do + gate.pop + data.claim_session_initialized(session_id) + end + end + 12.times { gate << true } + expect(threads.map(&:value).count(true)).to eq(1) + expect(data.session_initialized?(session_id)).to be(true) + end + end +end diff --git a/spec/posthog/mcp/ids_spec.rb b/spec/posthog/mcp/ids_spec.rb new file mode 100644 index 0000000..9602895 --- /dev/null +++ b/spec/posthog/mcp/ids_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Ids do + describe '.deterministic_prefixed_id' do + # Frozen cross-SDK vectors from posthog-python test_conversation_session.py. + it 'matches the TypeScript and Python SDKs byte for byte' do + expect(described_class.deterministic_prefixed_id('ses', 'conv-123')).to eq('ses_19c018eaeb9263330c016d3a3a41474b') + expect(described_class.deterministic_prefixed_id('ses', '0198d3a7-1111-7222-8333-444455556666')) + .to eq('ses_57a5f3768678e803a4af9566ca8a661b') + expect(described_class.deterministic_prefixed_id('ses', 'a')).to eq('ses_8601ec8c0eec655f4ec03fd0b1129ba7') + end + + it 'is stable and distinguishes inputs' do + a = described_class.deterministic_prefixed_id('ses', 'mcp-session:project') + expect(a).to eq(described_class.deterministic_prefixed_id('ses', 'mcp-session:project')) + expect(a).not_to eq(described_class.deterministic_prefixed_id('ses', 'other-session:project')) + expect(a).to match(/\Ases_[0-9a-f]{32}\z/) + end + end + + describe '.uuid_v7' do + it 'produces version 7, variant 10 uuids' do + uuid = described_class.uuid_v7 + expect(uuid).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/) + end + + it 'is time-ordered and unique' do + first = Array.new(25) { described_class.uuid_v7 } + sleep 0.005 + second = Array.new(25) { described_class.uuid_v7 } + expect((first + second).uniq.length).to eq(50) + expect(first.max).to be < second.min + end + end + + describe '.new_prefixed_id' do + it 'prefixes a uuidv7' do + expect(described_class.new_prefixed_id('evt')).to match(/\Aevt_[0-9a-f-]{36}\z/) + expect(described_class.new_prefixed_id('ses')).to match(/\Ases_[0-9a-f-]{36}\z/) + end + end +end diff --git a/spec/posthog/mcp/instrument_spec.rb b/spec/posthog/mcp/instrument_spec.rb new file mode 100644 index 0000000..e6a46cf --- /dev/null +++ b/spec/posthog/mcp/instrument_spec.rb @@ -0,0 +1,687 @@ +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength + +require_relative 'spec_helper' + +class PostHogMcpSpecEchoTool < MCP::Tool + tool_name 'echo' + description 'Echoes back' + meta({ category: 'Utility' }) + input_schema(properties: { message: { type: 'string' } }, required: ['message']) + class << self + def call(message:, **) + MCP::Tool::Response.new([{ type: 'text', text: "Echo: #{message}" }]) + end + end +end + +class PostHogMcpSpecBoomTool < MCP::Tool + tool_name 'boom' + description 'Raises' + class << self + def call(**) + raise ArgumentError, 'explode with token phc_123456789012345678901234567890' + end + end +end + +class PostHogMcpSpecOwnsContextTool < MCP::Tool + tool_name 'owns_context' + input_schema(properties: { context: { type: 'string' } }) + class << self + def call(context: nil, **) + MCP::Tool::Response.new([{ type: 'text', text: "ctx=#{context}" }]) + end + end +end + +class PostHogMcpSpecStructuredTool < MCP::Tool + tool_name 'structured' + input_schema(properties: {}) + output_schema(properties: { total: { type: 'integer' } }, required: ['total']) + class << self + def call(**) + MCP::Tool::Response.new([{ type: 'text', text: 'ok' }], structured_content: { total: 7 }) + end + end +end + +class PostHogMcpSpecErrorResultTool < MCP::Tool + tool_name 'soft_fail' + class << self + def call(**) + MCP::Tool::Response.new([{ type: 'text', text: 'tool failed badly' }], error: true) + end + end +end + +class PostHogMcpSpecOwnsConversationIdTool < MCP::Tool + tool_name 'owns_conversation_id' + input_schema(properties: { conversation_id: { type: 'string' } }) + class << self + def call(conversation_id: nil, **) + MCP::Tool::Response.new([{ type: 'text', text: "cid=#{conversation_id}" }]) + end + end +end + +class PostHogMcpSpecComposedContextTool < MCP::Tool + tool_name 'composed' + input_schema(type: 'object', properties: { message: { type: 'string' } }, + allOf: [{ properties: { context: { type: 'string' } }, required: ['context'] }]) + class << self + def call(context: nil, **) + MCP::Tool::Response.new([{ type: 'text', text: "ctx=#{context}" }]) + end + end +end + +class PostHogMcpSpecCaptureTool < MCP::Tool + tool_name 'capture' + input_schema(properties: {}) + class << self + attr_accessor :analytics + + def call(**) + analytics.capture('in_tool_event') + MCP::Tool::Response.new([{ type: 'text', text: 'captured' }]) + end + end +end + +# Parks inside its own body so a second request can land, identify and finish +# while the first is still in flight on the same session. +class PostHogMcpSpecParkedTool < MCP::Tool + tool_name 'parked' + input_schema(properties: { user: { type: 'string' } }) + class << self + attr_accessor :entered, :release, :analytics + + def call(**) + entered << true + release.pop + analytics&.capture('inside_tool') + MCP::Tool::Response.new([{ type: 'text', text: 'done' }]) + end + end +end + +RSpec.describe PostHog::MCP do + let(:client) { new_test_client } + let(:tools) do + [PostHogMcpSpecEchoTool, PostHogMcpSpecBoomTool, PostHogMcpSpecOwnsContextTool, PostHogMcpSpecStructuredTool, + PostHogMcpSpecErrorResultTool] + end + let(:server) { MCP::Server.new(name: 'spec-server', version: '9.9.9', tools: tools) } + let(:client_info) { { name: 'claude-code', version: '1.2.3' } } + + def initialize_request(id = 1, version = '2025-06-18') + rpc(id, 'initialize', { protocolVersion: version, capabilities: {}, clientInfo: client_info }) + end + + describe '.instrument' do + it 'returns a handle, is idempotent, and warns about being experimental' do + expect(PostHog::MCP).to receive(:experimental_notice!).at_least(:once).and_call_original + expect(Kernel).to receive(:warn).with(a_string_including('experimental')).once + handle = described_class.instrument(server, client) + expect(handle).to be_a(PostHog::MCP::Analytics) + expect(described_class.instrument(server, client)).to be_a(PostHog::MCP::Analytics) + expect(described_class.tracking_data(server).server_name).to eq('spec-server') + end + + it 'returns a no-op handle for unsupported servers and when no client is available' do + allow(Kernel).to receive(:warn) + allow(PostHog).to receive(:client).and_return(nil) if PostHog.respond_to?(:client) + expect(described_class.instrument(Object.new, client)).to be_a(PostHog::MCP::NoopAnalytics) + handle = described_class.instrument(server) + expect(handle).to be_a(PostHog::MCP::Analytics) + expect(described_class.tracking_data(server).sink).to be_nil + server.handle(initialize_request) + expect(client.queued_messages).to eq(0) + handle.capture('custom') + end + + it 'falls back to the posthog-rails facade client when present' do + allow(Kernel).to receive(:warn) + facade_client = PostHog::Client.new(api_key: 'phc_facade', test_mode: true) + # posthog-rails installs `PostHog.client`; emulate it without disturbing a real facade loaded by other specs. + had_facade = PostHog.respond_to?(:client) + if had_facade + allow(PostHog).to receive(:client).and_return(facade_client) + else + PostHog.define_singleton_method(:client) { facade_client } + end + begin + described_class.instrument(server) + expect(described_class.tracking_data(server).sink.client).to equal(facade_client) + ensure + PostHog.singleton_class.send(:remove_method, :client) unless had_facade + end + end + + it 'leaves a user-provided around_request untouched and still invoked' do + seen = [] + server.configuration = MCP::Configuration.new(around_request: lambda { |data, &handler| + seen << data[:method] + handler.call + }) + described_class.instrument(server, client) + server.handle(rpc(1, 'tools/list')) + expect(seen).to eq(['tools/list']) + expect(drain_events(client).map { |e| e[:event] }).to include('$mcp_tools_list') + end + end + + describe 'end to end over Server#handle' do + before { allow(Kernel).to receive(:warn) } + + it 'captures initialize, tools/list and a successful tool call with $lib override' do + described_class.instrument(server, client) + response = server.handle(initialize_request) + expect(response[:result][:protocolVersion]).to eq('2025-06-18') + list = server.handle(rpc(2, 'tools/list')) + echo = list[:result][:tools].find { |t| t[:name] == 'echo' } + expect(echo[:inputSchema][:properties].keys).to eq(%i[message context]) + expect(echo[:inputSchema][:required]).to eq(%w[message context]) + expect(PostHogMcpSpecEchoTool.input_schema.to_h[:properties].keys).to eq([:message]) + + arguments = { message: 'hi', context: 'Echoing for jane@example.com' } + result = server.handle(rpc(3, 'tools/call', { name: 'echo', arguments: arguments })) + expect(result[:result][:content]).to eq([{ type: 'text', text: 'Echo: hi' }]) + + events = drain_events(client) + expect(events.map { |e| e[:event] }).to eq(['$mcp_initialize', '$mcp_tools_list', '$mcp_tool_call']) + init = events[0] + expect(init[:properties]).to include('$mcp_client_name' => 'claude-code', '$mcp_client_version' => '1.2.3', + '$mcp_protocol_version' => '2025-06-18', '$mcp_server_name' => 'spec-server', + '$mcp_server_version' => '9.9.9', '$lib' => 'posthog-ruby-mcp', + '$lib_version' => PostHog::VERSION, '$process_person_profile' => false) + expect(init[:distinct_id]).to eq(init[:properties]['$session_id']) + expect(init[:properties]['$session_id']).to match(/\Ases_/) + + listing = events[1][:properties] + expect(listing['$mcp_listed_tool_names']).to eq(%w[echo boom owns_context structured soft_fail]) + expect(listing['$mcp_is_error']).to be(false) + + call = events[2][:properties] + expect(call).to include('$mcp_tool_name' => 'echo', '$mcp_resource_name' => 'echo', + '$mcp_tool_description' => 'Echoes back', '$mcp_tool_category' => 'Utility', + '$mcp_intent' => 'Echoing for [redacted]', + '$mcp_intent_source' => 'context_parameter', '$mcp_is_error' => false, + '$mcp_client_name' => 'claude-code', '$mcp_source' => 'posthog_mcp_analytics') + expect(call['$mcp_parameters']['request']['params']['arguments']).to eq('message' => 'hi') + expect(call['$mcp_response']['content'][0]['text']).to eq('Echo: hi') + expect(call['$mcp_duration_ms']).to be_a(Float) + expect(events.map { |e| e[:properties]['$session_id'] }.uniq.length).to eq(1) + end + + it 'strips the injected context before the tool sees it but keeps a tool-owned context' do + described_class.instrument(server, client) + result = server.handle(rpc(1, 'tools/call', { name: 'owns_context', arguments: { context: 'mine' } })) + expect(result[:result][:content][0][:text]).to eq('ctx=mine') + list = server.handle(rpc(2, 'tools/list')) + owns = list[:result][:tools].find { |t| t[:name] == 'owns_context' } + expect(owns[:inputSchema][:required]).to be_nil + # The tool declares `context`, so its value is application data, not the + # agent's stated intent, and must not be read as one. + call = drain_events(client).find { |e| e[:event] == '$mcp_tool_call' } + expect(call[:properties]).not_to have_key('$mcp_intent') + expect(call[:properties]).not_to have_key('$mcp_intent_source') + end + + it 'records raised tool errors with unwrapped scalars and an $exception sibling, then re-raises to the client' do + described_class.instrument(server, client) + response = server.handle(rpc(1, 'tools/call', { name: 'boom', arguments: { context: 'break it' } })) + expect(response[:error][:code]).to eq(-32_603) + events = drain_events(client) + expect(events.map { |e| e[:event] }).to eq(['$mcp_initialize', '$mcp_tool_call', '$exception']) + call = events[1][:properties] + expect(call).to include('$mcp_is_error' => true, '$mcp_error_type' => 'ArgumentError', + '$mcp_error_message' => 'explode with token [redacted]') + expect(call).not_to have_key('$mcp_response') + sibling = events[2][:properties] + expect(sibling['$exception_list'].map { |e| e['type'] }).to eq(%w[MCP::Server::RequestHandlerError ArgumentError]) + expect(sibling).to include('$mcp_tool_name' => 'boom', '$exception_level' => 'error') + expect(sibling).not_to have_key('$mcp_source') + end + + it 'treats isError results as errors and honours enable_exception_autocapture: false' do + described_class.instrument(server, client, enable_exception_autocapture: false) + server.handle(rpc(1, 'tools/call', { name: 'soft_fail', arguments: {} })) + events = drain_events(client) + expect(events.map { |e| e[:event] }).to eq(['$mcp_initialize', '$mcp_tool_call']) + expect(events[1][:properties]).to include('$mcp_is_error' => true, '$mcp_error_type' => 'Error', + '$mcp_error_message' => 'tool failed badly') + end + + it 'captures prompts and resources events' do + server.define_prompt(name: 'greet', description: 'g', arguments: []) do |_args, **| + MCP::Prompt::Result.new(description: 'x', messages: []) + end + server.define_resource(uri: 'file:///readme', name: 'readme', mime_type: 'text/plain') do + [{ uri: 'file:///readme', text: 'hello' }] + end + described_class.instrument(server, client) + server.handle(rpc(1, 'prompts/list')) + server.handle(rpc(2, 'prompts/get', { name: 'greet', arguments: {} })) + server.handle(rpc(3, 'resources/list')) + server.handle(rpc(4, 'resources/read', { uri: 'file:///readme' })) + events = drain_events(client) + names = events.map { |e| e[:event] } + expect(names).to eq(['$mcp_initialize', '$mcp_prompts_list', '$mcp_prompt_get', '$mcp_resources_list', + '$mcp_resource_read']) + expect(events[2][:properties]['$mcp_resource_name']).to eq('greet') + expect(events[2][:properties]).not_to have_key('$mcp_tool_name') + expect(events[4][:properties]['$mcp_resource_name']).to eq('file:///readme') + expect(events[4][:properties]['$mcp_response']['contents'][0]['text']).to eq('hello') + end + + it 'redacts binary prompt messages and resource blobs on the way out' do + server.define_prompt(name: 'picture', description: 'p', arguments: []) do |_args, **| + MCP::Prompt::Result.new(description: 'x', messages: [ + MCP::Prompt::Message.new( + role: 'user', + content: MCP::Content::Image.new('c2Vuc2l0aXZl', 'image/png') + ) + ]) + end + server.define_resource(uri: 'file:///data.bin', name: 'data', mime_type: 'application/octet-stream') do + [{ uri: 'file:///data.bin', mimeType: 'application/octet-stream', blob: 'c2Vuc2l0aXZl' }] + end + described_class.instrument(server, client) + server.handle(rpc(1, 'prompts/get', { name: 'picture', arguments: {} })) + server.handle(rpc(2, 'resources/read', { uri: 'file:///data.bin' })) + events = drain_events(client) + prompt = events.find { |e| e[:event] == '$mcp_prompt_get' } + read = events.find { |e| e[:event] == '$mcp_resource_read' } + expect(prompt[:properties]['$mcp_response']['messages'][0]['content']) + .to eq('type' => 'text', 'text' => '[image content redacted - not supported by PostHog MCP analytics]') + expect(read[:properties]['$mcp_response']['contents'][0]['blob']) + .to eq('[binary resource content redacted - not supported by PostHog MCP analytics]') + expect(JSON.generate(events)).not_to include('c2Vuc2l0aXZl') + end + + it 'leaves a composed schema and the context argument it owns alone, listed or not' do + composed = MCP::Server.new(name: 'spec-server', version: '9.9.9', tools: [PostHogMcpSpecComposedContextTool]) + described_class.instrument(composed, client) + # Called before any tools/list, so ownership is decided from the schema alone. + first = composed.handle(rpc(1, 'tools/call', { name: 'composed', arguments: { context: 'why' } })) + expect(first[:result][:content][0][:text]).to eq('ctx=why') + + listed = composed.handle(rpc(2, 'tools/list'))[:result][:tools][0][:inputSchema] + expect(listed[:properties].keys).to eq([:message]) + expect(listed[:allOf]).to eq([{ properties: { context: { type: 'string' } }, required: ['context'] }]) + + second = composed.handle(rpc(3, 'tools/call', { name: 'composed', arguments: { context: 'why' } })) + expect(second[:result][:content][0][:text]).to eq('ctx=why') + calls = drain_events(client).select { |e| e[:event] == '$mcp_tool_call' } + # A composed schema is never injected into, so this `context` is the tool's + # own argument: it reaches the tool untouched and is not read as intent. + expect(calls.map { |c| c[:properties]['$mcp_intent'] }).to eq([nil, nil]) + end + + it 'flags an empty tools/list as an error' do + empty_server = MCP::Server.new(name: 'empty', tools: []) + described_class.instrument(empty_server, client) + empty_server.handle(rpc(1, 'tools/list')) + events = drain_events(client) + listing = events.find { |e| e[:event] == '$mcp_tools_list' }[:properties] + expect(listing['$mcp_is_error']).to be(true) + expect(listing['$mcp_error_message']).to eq('tools/list returned no tools') + expect(events.map { |e| e[:event] }).to include('$exception') + end + end + + describe 'identify, event_properties and before_send' do + before { allow(Kernel).to receive(:warn) } + + it 'identifies once per session, sets $set/$groups and turns person processing on' do + calls = 0 + identify = lambda do |_request, _extra| + calls += 1 + { distinct_id: 'user-1', properties: { name: 'Alice' }, groups: { organization: 'org_123' } } + end + described_class.instrument(server, client, identify: identify, event_properties: lambda { |_r, _e| + { env: 'production' } + }) + server.handle(initialize_request) + 3.times { |i| server.handle(rpc(i + 2, 'tools/call', { name: 'echo', arguments: { message: 'x' } })) } + events = drain_events(client) + expect(events.count { |e| e[:event] == '$identify' }).to eq(1) + expect(calls).to eq(4) + call = events.find { |e| e[:event] == '$mcp_tool_call' } + expect(call[:distinct_id]).to eq('user-1') + expect(call[:properties]).to include('$set' => { 'name' => 'Alice' }, '$groups' => { 'organization' => 'org_123' }, + 'env' => 'production') + expect(call[:properties]).not_to have_key('$process_person_profile') + identify_event = events.find { |e| e[:event] == '$identify' } + expect(identify_event[:properties]['$mcp_resource_name']).to eq('Unknown') + end + + it 'runs before_send per payload, dropping and mutating' do + before_send = lambda do |payload| + next nil if payload['event'] == '$exception' + + payload['properties'].delete('$mcp_intent') + payload + end + described_class.instrument(server, client, before_send: before_send) + server.handle(rpc(1, 'tools/call', { name: 'boom', arguments: { context: 'secret intent' } })) + events = drain_events(client) + expect(events.map { |e| e[:event] }).to eq(['$mcp_initialize', '$mcp_tool_call']) + expect(events[1][:properties]).not_to have_key('$mcp_intent') + end + + it 'attributes a request to the identity it resolved, not to one a later request installed' do + PostHogMcpSpecParkedTool.entered = Queue.new + PostHogMcpSpecParkedTool.release = Queue.new + identify = lambda do |request, _extra| + params = request[:params] || request['params'] || {} + args = params[:arguments] || params['arguments'] || {} + user = args[:user] || args['user'] + { distinct_id: user, properties: { email: "#{user}@example.com" } } + end + shared = MCP::Server.new(name: 'spec-server', version: '9.9.9', + tools: [PostHogMcpSpecParkedTool, PostHogMcpSpecEchoTool]) + PostHogMcpSpecParkedTool.analytics = described_class.instrument(shared, client, identify: identify) + + alice = Thread.new { shared.handle(rpc(1, 'tools/call', { name: 'parked', arguments: { user: 'alice' } })) } + PostHogMcpSpecParkedTool.entered.pop + # Bob's request lands whole - identify included - while Alice is parked in + # her tool body. Over stdio both share the server-wide session, so Bob's + # identify re-points that session's cache entry at Bob. + shared.handle(rpc(2, 'tools/call', { name: 'echo', arguments: { message: 'hi', user: 'bob' } })) + PostHogMcpSpecParkedTool.release << true + expect(alice.value[:result][:content][0][:text]).to eq('done') + + events = drain_events(client) + calls = events_named(events, '$mcp_tool_call') + alice_call = calls.find { |e| e[:properties]['$mcp_resource_name'] == 'parked' } + bob_call = calls.find { |e| e[:properties]['$mcp_resource_name'] == 'echo' } + # The race needs one session behind both requests; without that there is + # nothing to overwrite and the test proves nothing. + expect(calls.map { |e| e[:properties]['$session_id'] }.uniq.length).to eq(1) + + expect(bob_call[:distinct_id]).to eq('bob') + expect(bob_call[:properties]['$set']).to eq('email' => 'bob@example.com') + # Alice's event is built after Bob won the cache entry, so it must come from + # the identity pinned when her request started. + expect(alice_call[:distinct_id]).to eq('alice') + expect(alice_call[:properties]['$set']).to eq('email' => 'alice@example.com') + # A custom event from inside the tool body follows the same request identity. + custom = events_named(events, 'inside_tool').first + expect(custom[:distinct_id]).to eq('alice') + expect(custom[:properties]['$set']).to eq('email' => 'alice@example.com') + end + + it 'shrinks a payload a before_send hook grew past the transport limit' do + before_send = lambda do |payload| + payload['properties']['bloat'] = 'x' * 40_000 + payload + end + described_class.instrument(server, client, before_send: before_send) + server.handle(rpc(1, 'tools/call', { name: 'echo', arguments: { message: 'hi' } })) + events = drain_events(client) + # The hook runs after truncation, so without a second pass the batch would + # drop these whole for exceeding the per-message limit. + expect(events.map { |e| e[:event] }).to eq(['$mcp_initialize', '$mcp_tool_call']) + events.each do |event| + expect(JSON.generate(event).bytesize).to be <= PostHog::Defaults::Message::MAX_BYTES + expect(event[:properties]['bloat']).to start_with('x') + end + end + + it 'never lets analytics failures reach the tool' do + described_class.instrument(server, client, identify: ->(_r, _e) { raise 'identify exploded' }, + event_properties: ->(_r, _e) { raise 'props exploded' }, + intent_fallback: ->(_r, _e) { raise 'intent exploded' }) + result = server.handle(rpc(1, 'tools/call', { name: 'echo', arguments: { message: 'hi' } })) + expect(result[:result][:content][0][:text]).to eq('Echo: hi') + expect(drain_events(client).map { |e| e[:event] }).to include('$mcp_tool_call') + end + + it 'uses intent_fallback when no context arrives' do + fallback = ->(request, _e) { " Invoking #{request[:params][:name]} " } + described_class.instrument(server, client, intent_fallback: fallback) + server.handle(rpc(1, 'tools/call', { name: 'echo', arguments: { message: 'hi' } })) + call = drain_events(client).find { |e| e[:event] == '$mcp_tool_call' } + expect(call[:properties]).to include('$mcp_intent' => 'Invoking echo', '$mcp_intent_source' => 'inferred') + end + end + + describe 'conversation ids, get_more_tools and llm_model' do + before { allow(Kernel).to receive(:warn) } + + it 'mints, prompts back, mirrors into structuredContent and anchors the session on an echo' do + described_class.instrument(server, client, enable_conversation_id: true) + list = server.handle(rpc(1, 'tools/list')) + structured = list[:result][:tools].find { |t| t[:name] == 'structured' } + expect(structured[:inputSchema][:properties]).to have_key(:conversation_id) + expect(structured[:outputSchema][:properties]).to have_key(:_mcp_instructions) + expect(structured[:inputSchema][:required]).to eq(['context']) + + first = server.handle(rpc(2, 'tools/call', { name: 'echo', arguments: { message: 'hi', context: 'c' } })) + prompt_back = JSON.parse(first[:result][:content][1][:text]) + handle = prompt_back['conversation_id'] + expect(handle).to match(PostHog::MCP::ConversationId::MINTED_CONVERSATION_ID) + + arguments = { context: 'c', conversation_id: handle.upcase } + second = server.handle(rpc(3, 'tools/call', { name: 'structured', arguments: arguments })) + expect(second[:result][:structuredContent]).to eq(total: 7, _mcp_instructions: { 'conversation_id' => handle }) + expect(second[:result][:content].length).to eq(1) + + events = drain_events(client) + calls = events.select { |e| e[:event] == '$mcp_tool_call' } + expect(calls.map { |c| c[:properties]['$mcp_conversation_id'] }).to eq([handle, handle]) + expected_session = PostHog::MCP.derive_session_id_from_conversation(handle) + expect(calls.map { |c| c[:properties]['$session_id'] }.uniq).to eq([expected_session]) + expect(calls[0][:properties]['$mcp_parameters']['request']['params']['arguments']).to eq('message' => 'hi') + end + + it 'never anchors a conversation on a conversation_id the tool declares itself' do + own = MCP::Server.new(name: 'spec-server', version: '9.9.9', tools: [PostHogMcpSpecOwnsConversationIdTool]) + described_class.instrument(own, client, enable_conversation_id: true) + shared = '019fd2b0-1111-7111-8111-111111111111' + 2.times do |i| + result = own.handle(rpc(i + 1, 'tools/call', { name: 'owns_conversation_id', + arguments: { context: 'c', conversation_id: shared } })) + # The tool keeps its own argument. + expect(result[:result][:content][0][:text]).to eq("cid=#{shared}") + end + + calls = drain_events(client).select { |e| e[:event] == '$mcp_tool_call' } + handles = calls.map { |c| c[:properties]['$mcp_conversation_id'] } + # Two users passing the same application-owned value must not be stitched + # into one conversation, which would bleed one's $set onto the other. + expect(handles).to all(match(PostHog::MCP::ConversationId::MINTED_CONVERSATION_ID)) + expect(handles).not_to include(shared) + expect(handles.uniq.length).to eq(2) + expect(calls.map { |c| c[:properties]['$session_id'] }) + .not_to include(PostHog::MCP.derive_session_id_from_conversation(shared)) + end + + it 'drops a minted handle when the call raises so sessions do not orphan' do + described_class.instrument(server, client, enable_conversation_id: true) + 2.times { |i| server.handle(rpc(i + 1, 'tools/call', { name: 'boom', arguments: { context: 'c' } })) } + events = drain_events(client) + calls = events.select { |e| e[:event] == '$mcp_tool_call' } + expect(calls.map { |c| c[:properties].key?('$mcp_conversation_id') }).to eq([false, false]) + expect(events.map { |e| e[:properties]['$session_id'] }.uniq.length).to eq(1) + end + + it 'keeps the conversation handle out of the error message so failures still group' do + described_class.instrument(server, client, enable_conversation_id: true) + 2.times { |i| server.handle(rpc(i + 1, 'tools/call', { name: 'soft_fail', arguments: { context: 'c' } })) } + events = drain_events(client) + calls = events.select { |e| e[:event] == '$mcp_tool_call' } + expect(calls.map { |c| c[:properties]['$mcp_error_message'] }).to eq(['tool failed badly'] * 2) + expect(events.select { |e| e[:event] == '$exception' } + .map { |e| e[:properties]['$exception_list'][0]['value'] }).to eq(['tool failed badly'] * 2) + # The handle still reaches the agent on the delivered result. + expect(calls[0][:properties]['$mcp_response']['content'].length).to eq(2) + expect(calls[0][:properties]['$mcp_conversation_id']).to be_a(String) + end + + it 'advertises and intercepts get_more_tools as $mcp_missing_capability' do + described_class.instrument(server, client, report_missing: true) + list = server.handle(rpc(1, 'tools/list')) + virtual = list[:result][:tools].last + expect(virtual[:name]).to eq('get_more_tools') + expect(virtual[:inputSchema][:properties].keys).to eq([:context]) + result = server.handle(rpc(2, 'tools/call', + { name: 'get_more_tools', arguments: { context: 'need csv export' } })) + expect(result[:result][:content][0][:text]).to include('Unfortunately') + events = drain_events(client) + listing = events.find { |e| e[:event] == '$mcp_tools_list' } + expect(listing[:properties]['$mcp_listed_tool_names']).to include('get_more_tools') + missing = events.find { |e| e[:event] == '$mcp_missing_capability' } + expect(missing[:properties]).to include('$mcp_intent' => 'need csv export', + '$mcp_intent_source' => 'context_parameter', + '$mcp_resource_name' => 'get_more_tools') + expect(missing[:properties]).not_to have_key('$mcp_tool_name') + expect(events.none? { |e| e[:event] == '$mcp_tool_call' }).to be(true) + end + + it 'lets the gem validate get_more_tools arguments and still records the missing capability' do + described_class.instrument(server, client, report_missing: true) + result = server.handle(rpc(2, 'tools/call', { name: 'get_more_tools', arguments: {} })) + expect(result[:result][:isError]).to be(true) + expect(result[:result][:content][0][:text]).to include('Missing required arguments: context') + events = drain_events(client) + missing = events.find { |e| e[:event] == '$mcp_missing_capability' } + expect(missing[:properties]['$mcp_resource_name']).to eq('get_more_tools') + expect(missing[:properties]).not_to have_key('$mcp_intent') + expect(events.none? { |e| e[:event] == '$mcp_tool_call' }).to be(true) + end + + it 'leaves an application tool named get_more_tools alone' do + own = MCP::Tool.define(name: 'get_more_tools', input_schema: { properties: {} }) do |**| + MCP::Tool::Response.new([{ type: 'text', text: 'mine' }]) + end + server = MCP::Server.new(name: 'spec-server', version: '9.9.9', tools: [PostHogMcpSpecEchoTool, own]) + described_class.instrument(server, client, report_missing: true) + expect(server.tools['get_more_tools']).to equal(own) + result = server.handle(rpc(2, 'tools/call', { name: 'get_more_tools', arguments: { context: 'why' } })) + expect(result[:result][:content][0][:text]).to eq('mine') + events = drain_events(client) + expect(events.map { |e| e[:event] }).to include('$mcp_tool_call') + expect(events.none? { |e| e[:event] == '$mcp_missing_capability' }).to be(true) + end + + it 'advertises llm_model on get_more_tools and records it on the missing-capability event' do + described_class.instrument(server, client, report_missing: true, capture_model: true) + list = server.handle(rpc(1, 'tools/list')) + virtual = list[:result][:tools].find { |t| t[:name] == 'get_more_tools' } + expect(virtual[:inputSchema][:properties].keys).to contain_exactly(:context, :llm_model) + expect(virtual[:inputSchema][:properties].keys).not_to include(:conversation_id) + expect(virtual[:inputSchema][:required]).to contain_exactly('context', 'llm_model') + result = server.handle(rpc(2, 'tools/call', { name: 'get_more_tools', + arguments: { context: 'need csv export', + llm_model: ' claude-opus-4-8 ' } })) + expect(result[:result][:content][0][:text]).to include('Unfortunately') + missing = drain_events(client).find { |e| e[:event] == '$mcp_missing_capability' } + expect(missing[:properties]).to include('$mcp_llm_model' => 'claude-opus-4-8', + '$mcp_llm_model_source' => 'self_reported') + end + + it 'captures llm_model from the injected argument or client metadata' do + described_class.instrument(server, client, capture_model: true) + server.handle(rpc(1, 'tools/call', + { name: 'echo', arguments: { message: 'hi', llm_model: ' claude-opus-4-8 ' } })) + server.handle(rpc(2, 'tools/call', { name: 'echo', arguments: { message: 'hi', llm_model: 'unknown' } })) + server.handle(rpc(3, 'tools/call', { name: 'echo', arguments: { message: 'hi', llm_model: 'self' }, + _meta: { 'x-codex-turn-metadata' => { 'model' => 'gpt-5.6-sol' } } })) + calls = drain_events(client).select { |e| e[:event] == '$mcp_tool_call' }.map { |e| e[:properties] } + expect(calls[0]).to include('$mcp_llm_model' => 'claude-opus-4-8', '$mcp_llm_model_source' => 'self_reported') + expect(calls[1]).not_to have_key('$mcp_llm_model') + expect(calls[2]).to include('$mcp_llm_model' => 'gpt-5.6-sol', '$mcp_llm_model_source' => 'client_metadata') + end + end + + describe 'payload size' do + it 'keeps every captured message under the core client per-message limit so it is not dropped' do + allow(Kernel).to receive(:warn) + server.define_tool(name: 'huge', input_schema: { properties: {} }) do |**| + MCP::Tool::Response.new([{ type: 'text', text: 'lorem ipsum ' * 5000 }]) + end + described_class.instrument(server, client) + server.handle(rpc(1, 'tools/call', { name: 'huge', arguments: { context: 'c' * 5000 } })) + events = drain_events(client) + call = events.find { |e| e[:event] == '$mcp_tool_call' } + expect(call[:properties]['$mcp_response']['content'][0]['text']).to end_with('...') + events.each do |message| + expect(JSON.generate(message).bytesize).to be < PostHog::Defaults::Message::MAX_BYTES + end + end + end + + describe 'custom events' do + it 'anchors an in-tool event on the echoed conversation and the identified person' do + allow(Kernel).to receive(:warn) + capture_server = MCP::Server.new(name: 'spec-server', version: '9.9.9', tools: [PostHogMcpSpecCaptureTool]) + PostHogMcpSpecCaptureTool.analytics = described_class.instrument( + capture_server, client, enable_conversation_id: true, identify: { distinct_id: 'user-1' } + ) + conversation = '019fd2b0-1111-7111-8111-111111111111' + capture_server.handle(rpc(1, 'tools/call', { name: 'capture', + arguments: { context: 'c', conversation_id: conversation } })) + events = drain_events(client) + custom = events.find { |e| e[:event] == 'in_tool_event' } + call = events.find { |e| e[:event] == '$mcp_tool_call' } + expect(custom[:properties]['$session_id']) + .to eq(described_class.derive_session_id_from_conversation(conversation)) + expect(custom[:properties]['$session_id']).to eq(call[:properties]['$session_id']) + expect(custom[:distinct_id]).to eq('user-1') + expect(custom[:distinct_id]).to eq(call[:distinct_id]) + end + + it 'gives a capture that lost its request scope a standalone session on an HTTP server' do + allow(Kernel).to receive(:warn) + logs = [] + handle = described_class.instrument(server, client, logger: ->(message) { logs << message }) + server.handle(initialize_request) + data = described_class.tracking_data(server) + data.http_transport_seen = true # a request has arrived over HTTP + handle.capture('detached_event') + custom = drain_events(client).find { |e| e[:event] == 'detached_event' } + expect(custom[:properties]['$session_id']).to start_with('ses_') + expect(custom[:properties]['$session_id']).not_to eq(data.session_id) + expect(logs).to include(a_string_including('without the scope of the request')) + end + + it 'sends custom events verbatim on the current session' do + allow(Kernel).to receive(:warn) + handle = described_class.instrument(server, client) + server.handle(initialize_request) + handle.capture('feedback_submitted', { rating: 5 }) + expect { handle.capture('') }.to raise_error(ArgumentError) + events = drain_events(client) + custom = events.last + expect(custom[:event]).to eq('feedback_submitted') + expect(custom[:properties]).to include('rating' => 5, '$mcp_server_name' => 'spec-server') + expect(custom[:properties]['$session_id']).to eq(events.first[:properties]['$session_id']) + end + end + + describe 'concurrency' do + it 'does not cross-attribute concurrent requests' do + allow(Kernel).to receive(:warn) + described_class.instrument(server, client) + threads = 8.times.map do |i| + Thread.new do + server.handle(rpc(i, 'tools/call', { name: 'echo', arguments: { message: "m#{i}", context: "intent #{i}" } })) + end + end + threads.each(&:join) + calls = drain_events(client).select { |e| e[:event] == '$mcp_tool_call' } + expect(calls.length).to eq(8) + calls.each do |call| + message = call[:properties]['$mcp_parameters']['request']['params']['arguments']['message'] + expect(call[:properties]['$mcp_intent']).to eq("intent #{message.delete_prefix('m')}") + expect(call[:properties]['$mcp_response']['content'][0]['text']).to eq("Echo: #{message}") + end + end + end +end +# rubocop:enable Layout/LineLength diff --git a/spec/posthog/mcp/options_spec.rb b/spec/posthog/mcp/options_spec.rb new file mode 100644 index 0000000..8443fe6 --- /dev/null +++ b/spec/posthog/mcp/options_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Options do + it 'has JS/Python defaults' do + options = described_class.new + expect(options.report_missing).to be(false) + expect(options.enable_conversation_id).to be(false) + expect(options.enable_exception_autocapture).to be(true) + expect(options.context_enabled?).to be(true) + expect(options.context_description).to be_nil + expect(options.capture_model_enabled?).to be(false) + expect(PostHog::MCP::Tools.missing_capability_tool_name(options)).to eq('get_more_tools') + end + + it 'normalises hash forms' do + options = described_class.new(context: { description: 'why' }, capture_model: { description: 'which' }, + missing_capability_tool_name: 'find_tools') + expect(options.context_description).to eq('why') + expect(options.model_description).to eq('which') + expect(options.capture_model_enabled?).to be(true) + expect(PostHog::MCP::Tools.missing_capability_tool_name(options)).to eq('find_tools') + expect(described_class.new(context: false).context_enabled?).to be(false) + end +end diff --git a/spec/posthog/mcp/sanitization_spec.rb b/spec/posthog/mcp/sanitization_spec.rb new file mode 100644 index 0000000..ec255ad --- /dev/null +++ b/spec/posthog/mcp/sanitization_spec.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Sanitization do + binary = '[binary data redacted - not supported by PostHog MCP analytics]' + + describe '.sanitize_captured_value' do + it 'redacts sensitive keys and PostHog tokens' do + expect(described_class.sanitize_captured_value('authorization' => 'Bearer x', 'api_key' => 'k', 'safe' => 'keep')) + .to eq('authorization' => '[redacted]', 'api_key' => '[redacted]', 'safe' => 'keep') + value = 'Default project token api_token: phc_123456789012345678901234567890.' + expect(described_class.sanitize_captured_value(value)).to eq('Default project token api_token: [redacted].') + end + + it 'redacts base64-looking strings at and above the size gate only' do + expect(described_class.sanitize_captured_value("#{'A' * 10_239}=")).to eq(binary) + expect(described_class.sanitize_captured_value("#{'A' * 10_238}=")).to eq("#{'A' * 10_238}=") + expect(described_class.sanitize_captured_value("data:image/png;base64,#{'A' * 12_000}")).to eq(binary) + expect(described_class.sanitize_captured_value("#{'A-_' * 4000}=")).to eq(binary) + prose = 'Hello, world! This is NOT base64. ' * 400 + expect(described_class.sanitize_captured_value(prose)).to eq(prose) + expect(described_class.sanitize_captured_value('level1' => { 'data' => 'A' * 11_000 })) + .to eq('level1' => { 'data' => binary }) + end + + it 'keeps literal plus signs when percent-decoding base64 data URLs' do + url = "data:application/octet-stream;base64,#{'++//' * 3000}" + expect(described_class.sanitize_captured_value(url)).to eq(binary) + expect(described_class.sanitize_captured_value("data:image/png;base64,#{'A%2BB/' * 2500}")).to eq(binary) + end + + it 'redacts a private key block whole, keeping the text around it' do + # This body line survives word-by-word entropy detection on its own: it has + # two lowercase `/`-separated segments, so it reads as a path. The block + # must not depend on that heuristic. + path_like = 'fnjv/87b1pybnOeVFm/c/oO4xX5sBmCi6GCRnw4WOAvKbBEtMobMdZVh6gSm5ni3' + pem = "-----BEGIN RSA PRIVATE KEY-----\n#{path_like}\nMIIEpAIBAAKCAQEAx7Vn9s\n-----END RSA PRIVATE KEY-----" + out = described_class.sanitize_captured_value("here you go:\n#{pem}\nthanks!") + expect(out).not_to include(path_like) + expect(out).not_to include('MIIEpAIBAAKCAQEAx7Vn9s') + expect(out).to include('here you go:', '[redacted]', 'thanks!') + end + + it 'redacts every private key spelling, including truncated and repeated blocks' do + body = 'MIIEpAIBAAKCAQEAx7Vn9sQq2mKpL4tRbY8wZ3fH6jD1nC5vB0aX' + ['RSA PRIVATE KEY', 'EC PRIVATE KEY', 'PRIVATE KEY', 'ENCRYPTED PRIVATE KEY', 'OPENSSH PRIVATE KEY', + 'PGP PRIVATE KEY BLOCK'].each do |label| + block = "-----BEGIN #{label}-----\n#{body}\n-----END #{label}-----" + expect(described_class.sanitize_captured_value(block)).not_to include(body) + end + + # A block whose END marker never arrives still loses its body. + expect(described_class.sanitize_captured_value("-----BEGIN RSA PRIVATE KEY-----\n#{body}")) + .not_to include(body) + two = "-----BEGIN RSA PRIVATE KEY-----\n#{body}\n-----END RSA PRIVATE KEY-----" + expect(described_class.sanitize_captured_value("#{two}\nand again\n#{two}")).not_to include(body) + end + + it 'leaves prose that merely mentions a private key alone' do + ['my private key is in 1password', 'rotate the PRIVATE KEY next week', 'see docs/private-key.md'].each do |text| + expect(described_class.sanitize_captured_value(text)).to eq(text) + end + end + + it 'passes non-strings through and redacts other vendors\' credentials per word' do + expect(described_class.sanitize_captured_value(42)).to eq(42) + expect(described_class.sanitize_captured_value(true)).to eq(true) + redacted = described_class.sanitize_captured_value('auth failed for sk-proj-abc123XYZ789defGHI456jklMNO012pqr') + expect(redacted).to start_with('auth failed for') + expect(redacted).not_to include('sk-proj-') + expect(described_class.sanitize_captured_value('revenue warehouse unreachable (period=q3)')) + .to eq('revenue warehouse unreachable (period=q3)') + end + end + + describe '.stringify_keys' do + it 'marks cycles instead of recursing forever, while keeping shared references' do + shared = { leaf: 1 } + props = { a: shared, b: [shared] } + props[:self] = props + props[:list] = [props] + expect(described_class.stringify_keys(props)).to eq( + 'a' => { 'leaf' => 1 }, 'b' => [{ 'leaf' => 1 }], 'self' => '[Circular ~]', 'list' => ['[Circular ~]'] + ) + end + end + + describe '.build_captured_mcp_parameters' do + it 'keeps id/jsonrpc/method/params, strips injected arguments, and drops everything else' do + request = { + id: 102, jsonrpc: '2.0', method: 'tools/call', + params: { name: 'projects-get', + arguments: { context: 'Review local project access.', projectId: 1, + api_token: 'phc_123456789012345678901234567890' } }, + extra: { headers: { authorization: 'Bearer phx_123456789012345678901234567890' } } + } + expect(described_class.build_captured_mcp_parameters(request)).to eq( + 'request' => { 'id' => 102, 'jsonrpc' => '2.0', 'method' => 'tools/call', + 'params' => { 'name' => 'projects-get', + 'arguments' => { 'projectId' => 1, 'api_token' => '[redacted]' } } } + ) + end + end + + describe '.sanitize_response' do + it 'redacts non-text content blocks with the exact placeholders' do + response = { + 'content' => [ + { 'type' => 'text', 'text' => 'Hello world' }, + { 'type' => 'image', 'data' => 'base64imagedata...', 'mimeType' => 'image/png' }, + { 'type' => 'audio', 'data' => 'base64audiodata...', 'mimeType' => 'audio/wav' }, + { 'type' => 'resource', + 'resource' => { 'uri' => 'file:///data.bin', 'blob' => 'x', 'mimeType' => 'application/octet-stream' } }, + { 'type' => 'resource', + 'resource' => { 'uri' => 'file:///readme.txt', 'text' => 'This is a text resource' } }, + { 'type' => 'video', 'data' => 'somestuff', 'mimeType' => 'video/mp4' }, + { 'type' => 'resource_link', 'uri' => 'file:///some/resource', 'name' => 'My Resource' } + ], + 'structuredContent' => { 'project' => 'Default project', + 'api_token' => 'phc_123456789012345678901234567890' } + } + sanitized = described_class.sanitize_response(response) + expect(sanitized['content']).to eq([ + { 'type' => 'text', 'text' => 'Hello world' }, + { 'type' => 'text', + 'text' => '[image content redacted - not supported by PostHog MCP analytics]' }, + { 'type' => 'text', + 'text' => '[audio content redacted - not supported by PostHog MCP analytics]' }, + { 'type' => 'text', + 'text' => '[binary resource content redacted - not supported by PostHog MCP analytics]' }, + { 'type' => 'resource', + 'resource' => { 'uri' => 'file:///readme.txt', + 'text' => 'This is a text resource' } }, + { 'type' => 'text', + 'text' => '[unsupported content type "video" redacted - not supported by PostHog MCP analytics]' }, + { 'type' => 'resource_link', 'uri' => 'file:///some/resource', + 'name' => 'My Resource' } + ]) + expect(sanitized['structuredContent']).to eq('project' => 'Default project', 'api_token' => '[redacted]') + expect(response['structuredContent']['api_token']).to start_with('phc_') # never mutated + end + + it 'redacts binary blocks in a prompts/get result, however short' do + response = { + 'description' => 'a prompt', + 'messages' => [ + { 'role' => 'user', 'content' => { 'type' => 'text', 'text' => 'hello' } }, + { 'role' => 'user', 'content' => { 'type' => 'image', 'data' => 'c2Vuc2l0aXZl', 'mimeType' => 'image/png' } }, + { 'role' => 'assistant', + 'content' => [{ 'type' => 'audio', 'data' => 'c2Vuc2l0aXZl', 'mimeType' => 'audio/wav' }] } + ] + } + sanitized = described_class.sanitize_response(response) + expect(sanitized['messages'][0]['content']).to eq('type' => 'text', 'text' => 'hello') + expect(sanitized['messages'][1]['content']) + .to eq('type' => 'text', 'text' => '[image content redacted - not supported by PostHog MCP analytics]') + expect(sanitized['messages'][2]['content']) + .to eq([{ 'type' => 'text', 'text' => '[audio content redacted - not supported by PostHog MCP analytics]' }]) + expect(JSON.generate(sanitized)).not_to include('c2Vuc2l0aXZl') + expect(response['messages'][1]['content']['data']).to eq('c2Vuc2l0aXZl') # never mutated + end + + it 'redacts a blob in a resources/read result, however short, and keeps its text sibling' do + response = { + 'contents' => [ + { 'uri' => 'file:///data.bin', 'mimeType' => 'application/octet-stream', 'blob' => 'c2Vuc2l0aXZl' }, + { 'uri' => 'file:///readme.txt', 'mimeType' => 'text/plain', 'text' => 'readable' } + ] + } + sanitized = described_class.sanitize_response(response) + expect(sanitized['contents'][0]).to eq( + 'uri' => 'file:///data.bin', 'mimeType' => 'application/octet-stream', + 'blob' => '[binary resource content redacted - not supported by PostHog MCP analytics]' + ) + expect(sanitized['contents'][1]).to eq('uri' => 'file:///readme.txt', 'mimeType' => 'text/plain', + 'text' => 'readable') + expect(response['contents'][0]['blob']).to eq('c2Vuc2l0aXZl') # never mutated + end + end + + describe '.redact_pii' do + nbsp = ' ' + nnbsp = ' ' + { + 'Looking up orders for jane.doe@acme.co.uk before refunding.' => 'Looking up orders for [redacted] before refunding.', + "from #{'a' * 64}@example.com now" => 'from [redacted] now', + 'Blocking traffic from 203.0.113.42 after abuse.' => 'Blocking traffic from [redacted] after abuse.', + 'Tracing request from 2001:db8::ff00:42:8329 across the mesh.' => + 'Tracing request from [redacted] across the mesh.', + 'Routing host 2001:db8:: for now.' => 'Routing host [redacted] for now.', + 'Health check from ::1 passed.' => 'Health check from [redacted] passed.', + 'Reference ticket for number 415-555-0142 escalation.' => + 'Reference ticket for number [redacted] escalation.', + 'Call the customer on 415/555/0142 today.' => 'Call the customer on [redacted] today.', + 'Calling back on +1 (415) 555-0142 about the outage.' => 'Calling back on [redacted] about the outage.', + 'Reaching them at (415)555-0142 today.' => 'Reaching them at [redacted] today.', + 'Ring +44 (0) 20 7946 0958 please.' => 'Ring [redacted] please.', + "Calling the customer on 415#{nnbsp}555#{nnbsp}0132 today." => 'Calling the customer on [redacted] today.', + 'Charging the saved card 4111 1111 1111 1111 for the renewal.' => + 'Charging the saved card [redacted] for the renewal.', + 'Charging card 4111.1111.1111.1111 today.' => 'Charging card [redacted] today.', + 'Charging card 4111/1111/1111/1111 today.' => 'Charging card [redacted] today.', + "Charging card 4111#{nbsp}1111#{nbsp}1111#{nbsp}1111 now." => 'Charging card [redacted] now.', + 'Charging card 4111 1111 1111 1111 12/30 for renewal.' => 'Charging card [redacted] 12/30 for renewal.', + 'Moving funds 4111 1111 1111 1111 5555 5555 5555 4444 now.' => 'Moving funds [redacted] [redacted] now.', + 'Verifying SSN 123-45-6789 for the claim.' => 'Verifying SSN [redacted] for the claim.', + 'Verifying SSN 123 45 6789 for the claim.' => 'Verifying SSN [redacted] for the claim.', + 'Verifying SSN 123.45.6789 for the claim.' => 'Verifying SSN [redacted] for the claim.', + 'Emailing bob@example.com and calling +1-202-555-0170 about the issue.' => + 'Emailing [redacted] and calling [redacted] about the issue.' + }.each do |input, expected| + it "redacts #{input.inspect}" do + expect(described_class.redact_pii(input)).to eq(expected) + end + end + + [ + 'Fetching record 4155550142 from the ledger service.', + 'Looking up record 123456789 in the ledger.', + 'Correlating with order 1234567890123456 in the warehouse.', + 'Deploying at 2024-01-15 12:30 UTC after review.', + 'Upgrading to build 2024.11.05.1830 for the team.', + 'Calling std::bad and std::vector helpers for the team.', + 'Upgrading to v1.2.3 on 2024-01-15 by refactoring std::vector usage.', + 'Searching the organization repositories to prioritize open performance issues.' + ].each do |input| + it "leaves #{input.inspect} untouched" do + expect(described_class.redact_pii(input)).to eq(input) + end + end + + it 'handles pathological input quickly and passes non-strings through' do + pathological = "#{'a' * 50_000}@#{'a' * 50_000}" + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + expect(described_class.redact_pii(pathological)).to eq(pathological) + expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 1.0 + expect(described_class.redact_pii(123)).to eq(123) + end + end + + describe '.sanitize_event' do + it 'redacts PII in the intent only, and tokens in exception values' do + event = { + 'user_intent' => 'Rotating token phc_123456789012345678901234567890 for user carol@example.org.', + 'parameters' => { 'email' => 'dave@example.com', 'ip' => '203.0.113.42' }, + 'response' => { 'content' => [{ 'type' => 'text', 'text' => 'Matched dave@example.com at 203.0.113.42.' }] }, + 'error' => { '$exception_list' => [{ 'type' => 'Error', + 'value' => 'Project token phc_123456789012345678901234567890' }] } + } + sanitized = described_class.sanitize_event(event) + expect(sanitized['user_intent']).to eq('Rotating token [redacted] for user [redacted].') + expect(sanitized['parameters']).to eq('email' => 'dave@example.com', 'ip' => '203.0.113.42') + expect(sanitized['response']['content'][0]['text']).to eq('Matched dave@example.com at 203.0.113.42.') + expect(sanitized['error']['$exception_list'][0]['value']).to eq('Project token [redacted]') + expect(event['error']['$exception_list'][0]['value']).to include('phc_') + end + + it 'redacts credentials in the source lines a stack frame carries, keeping the source readable' do + frame = { + 'filename' => 'app/tools/billing.rb', 'lineno' => 14, 'in_app' => true, + 'pre_context' => [" API_KEY = 'sk-proj-abc123XYZ789defGHI456jklMNO012pqr'", ' class << self'], + 'context_line' => " charge(API_KEY, 'sk-proj-zzz999AAA888bbbCCC777dddEEE666fff')", + 'post_context' => [' end', ' end'] + } + event = { 'error' => { '$exception_list' => [{ 'type' => 'ArgumentError', 'value' => 'boom', + 'stacktrace' => { 'frames' => [frame] } }] } } + sanitized = described_class.sanitize_event(event)['error']['$exception_list'][0]['stacktrace']['frames'][0] + + expect(sanitized['pre_context'][0]).to eq(' API_KEY = [redacted]') + # Replacement takes the whole non-space run, punctuation included, exactly as + # the word-splitting path does elsewhere: erring toward redacting more. + expect(sanitized['context_line']).to eq(' charge(API_KEY, [redacted]') + # Indentation survives: the generic string path would rejoin on single + # spaces and flatten a stack trace into something unreadable. + expect(sanitized['pre_context'][1]).to eq(' class << self') + expect(sanitized['post_context']).to eq([' end', ' end']) + expect(sanitized['filename']).to eq('app/tools/billing.rb') + expect(sanitized['lineno']).to eq(14) + # The input is not mutated, as everywhere else in this module. + expect(frame['pre_context'][0]).to include('sk-proj-') + end + + it 'leaves frames without source context, and odd stacktrace shapes, alone' do + bare = { 'filename' => 'lib/gem.rb', 'lineno' => 3, 'in_app' => false } + %w[frames].each do |_| + event = { 'error' => { '$exception_list' => [{ 'value' => 'x', 'stacktrace' => { 'frames' => [bare] } }] } } + expect(described_class.sanitize_event(event)['error']['$exception_list'][0]['stacktrace']['frames']) + .to eq([bare]) + end + [nil, 'nope', { 'frames' => 'nope' }, { 'frames' => [nil, 42] }].each do |stacktrace| + event = { 'error' => { '$exception_list' => [{ 'value' => 'x', 'stacktrace' => stacktrace }] } } + expect { described_class.sanitize_event(event) }.not_to raise_error + end + expect(described_class.sanitize_event('error' => { '$exception_list' => ['not a hash'] })['error']) + .to eq('$exception_list' => ['not a hash']) + end + end +end +# rubocop:enable Layout/LineLength diff --git a/spec/posthog/mcp/schema_mutation_spec.rb b/spec/posthog/mcp/schema_mutation_spec.rb new file mode 100644 index 0000000..aa9214c --- /dev/null +++ b/spec/posthog/mcp/schema_mutation_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::SchemaMutation do + let(:schema) { { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] } } + + it 'adds a required context parameter on a new hash without touching the input' do + out = described_class.add_context_parameter(schema, tool_name: 't') + expect(out[:properties].keys).to eq(%i[x context]) + expect(out[:properties][:context][:description]).to eq(PostHog::MCP::DEFAULT_CONTEXT_PARAMETER_DESCRIPTION) + expect(out[:required]).to eq(%w[x context]) + expect(schema[:properties].keys).to eq([:x]) + expect(schema[:required]).to eq(['x']) + end + + it 'honours description overrides and keeps additionalProperties: false' do + out = described_class.add_context_parameter({ 'type' => 'object', 'properties' => {}, 'additionalProperties' => false }, + tool_name: 't', description: 'why') + expect(out).to eq('type' => 'object', 'properties' => { 'context' => { type: 'string', description: 'why' } }, + 'required' => ['context'], 'additionalProperties' => false) + end + + it 'skips schemas that declare the param or are composed, and builds one from nil' do + owned = { type: 'object', properties: { context: { type: 'string' } } } + expect(described_class.add_context_parameter(owned, tool_name: 't')).to equal(owned) + complex = { oneOf: [{ type: 'object' }] } + expect(described_class.add_context_parameter(complex, tool_name: 't')).to equal(complex) + referenced = { type: 'object', :$ref => '#/$defs/payload' } + expect(described_class.add_context_parameter(referenced, tool_name: 't')).to equal(referenced) + out = described_class.add_conversation_id_parameter(nil, tool_name: 't') + expect(out[:properties][:conversation_id][:description]).to eq(PostHog::MCP::DEFAULT_CONVERSATION_ID_DESCRIPTION) + expect(out[:required]).to eq([]) + end + + it 'adds the model parameter with the JS default description' do + out = described_class.add_model_parameter(schema, tool_name: 't') + expect(out[:properties][:llm_model][:description]).to eq(PostHog::MCP::DEFAULT_MODEL_PARAMETER_DESCRIPTION) + expect(out[:required]).to include('llm_model') + end + + describe '.injectable?' do + it 'refuses composed and referenced schemas only' do + expect(described_class.injectable?(schema)).to be(true) + expect(described_class.injectable?(nil)).to be(true) + expect(described_class.injectable?({})).to be(true) + expect(described_class.injectable?({ allOf: [{ required: ['context'] }] })).to be(false) + expect(described_class.injectable?({ 'anyOf' => [{ type: 'object' }] })).to be(false) + expect(described_class.injectable?({ oneOf: [{ type: 'object' }] })).to be(false) + expect(described_class.injectable?({ '$ref' => '#/$defs/payload' })).to be(false) + end + end + + describe '.add_output_instructions' do + it 'declares an optional _mcp_instructions property' do + out, declared = described_class.add_output_instructions({ type: 'object', properties: { total: { type: 'integer' } }, + required: ['total'] }, tool_name: 't') + expect(declared).to be(true) + expect(out[:properties][:_mcp_instructions]).to eq( + type: 'object', description: 'Server-issued metadata for this conversation.', + properties: { conversation_id: { type: 'string', description: 'The server-issued conversation identifier.' } } + ) + expect(out[:required]).to eq(['total']) + end + + it 'recognises its own earlier declaration, leaves customer keys alone, and skips complex schemas' do + ours, = described_class.add_output_instructions({ type: 'object', properties: {} }, tool_name: 't') + expect(described_class.add_output_instructions(ours, tool_name: 't')).to eq([ours, true]) + theirs = { type: 'object', properties: { _mcp_instructions: { type: 'string' } } } + expect(described_class.add_output_instructions(theirs, tool_name: 't')).to eq([theirs, false]) + complex = { '$ref': '#/x' } + expect(described_class.add_output_instructions(complex, tool_name: 't')).to eq([complex, false]) + expect(described_class.add_output_instructions(nil, tool_name: 't')).to eq([nil, false]) + end + end +end +# rubocop:enable Layout/LineLength diff --git a/spec/posthog/mcp/session_token_spec.rb b/spec/posthog/mcp/session_token_spec.rb new file mode 100644 index 0000000..c8182e0 --- /dev/null +++ b/spec/posthog/mcp/session_token_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::SessionToken do + let(:frozen) { 'eyJzaWQiOiJzZXNfZml4dHVyZSIsImNuIjoiQ2xhdWRlIENvZGUiLCJjdiI6IjEuMi4zIiwicHYiOiIyMDI1LTA2LTE4In0' } + + def wire(value) + Base64.urlsafe_encode64(JSON.generate(value), padding: false) + end + + it 'encodes the frozen cross-SDK wire format' do + payload = PostHog::MCP::SessionTokenPayload.new(session_id: 'ses_fixture', client_name: 'Claude Code', + client_version: '1.2.3', protocol_version: '2025-06-18') + expect(described_class.encode(payload)).to eq(frozen) + expect(PostHog::MCP.encode_session_id(session_id: 'ses_fixture', client_name: 'Claude Code', + client_version: '1.2.3', protocol_version: '2025-06-18')).to eq(frozen) + end + + it 'round-trips, omits absent fields, and clamps client fields to 200 chars' do + token = described_class.encode(session_id: 'ses_0199aabb') + expect(described_class.decode(token).to_h).to eq(session_id: 'ses_0199aabb', client_name: nil, client_version: nil, + protocol_version: nil) + + token = described_class.encode(session_id: 'ses_x', client_name: 'a' * 500, client_version: 'b' * 500) + decoded = described_class.decode(token) + expect(decoded.client_name.length).to eq(200) + expect(decoded.client_version.length).to eq(200) + + token = described_class.encode(session_id: 'ses_0199aabb', client_name: 'Клиент 😀 客户端', client_version: '1.0') + expect(token).to match(/\A[A-Za-z0-9_-]+\z/) + expect(described_class.decode(token).client_name).to eq('Клиент 😀 客户端') + end + + it 'rejects an empty session id on encode' do + expect { described_class.encode(session_id: '') }.to raise_error(ArgumentError) + end + + it 'returns nil for anything that is not one of our tokens' do + [ + '550e8400-e29b-41d4-a716-446655440000', 'ses_0199aabbccdd', 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig', + 'aaa.bbb.ccc', 'not a token!', Base64.urlsafe_encode64('not json at all', padding: false), '', nil, 42, ['a'], + 'A' * 5000, wire(['ses_x']), wire('ses_x'), wire(nil), wire({ cn: 'no sid' }), wire({ sid: 42 }), + wire({ sid: '' }), wire({ sid: 'x' * 200 }) + ].each do |value| + expect(described_class.decode(value)).to be_nil, "expected nil for #{value.inspect[0, 40]}" + end + huge = wire({ sid: 'ses_x', cn: 'y' * 8000 }) + expect(huge.length).to be > 4096 + expect(described_class.decode(huge)).to be_nil + end + + it 'drops malformed client fields but keeps the session id' do + decoded = described_class.decode(wire({ sid: 'ses_x', cn: 42, cv: {} })) + expect(decoded.session_id).to eq('ses_x') + expect(decoded.client_name).to be_nil + expect(decoded.client_version).to be_nil + end + + describe '.read_header' do + it 'reads case-insensitively, takes the first array element, trims, and rejects blanks' do + expect(described_class.read_header('mcp-session-id' => 'abc')).to eq('abc') + expect(described_class.read_header('Mcp-Session-Id' => 'abc')).to eq('abc') + expect(described_class.read_header('mcp-session-id' => %w[abc def])).to eq('abc') + expect(described_class.read_header('Mcp-Session-Id' => ' tok ')).to eq('tok') + expect(described_class.read_header({})).to be_nil + expect(described_class.read_header('mcp-session-id' => '')).to be_nil + expect(described_class.read_header('mcp-session-id' => ' ')).to be_nil + expect(described_class.read_header('mcp-session-id' => 42)).to be_nil + expect(described_class.read_header(nil)).to be_nil + expect(described_class.read_header('headers')).to be_nil + end + end +end diff --git a/spec/posthog/mcp/spec_helper.rb b/spec/posthog/mcp/spec_helper.rb new file mode 100644 index 0000000..eee41f5 --- /dev/null +++ b/spec/posthog/mcp/spec_helper.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'posthog/mcp' + +module MCPSpecHelpers + def drain_events(client) + events = [] + events << client.dequeue_last_message while client.queued_messages.positive? + events + end + + def events_named(events, name) + events.select { |event| event[:event] == name } + end + + def rpc(id, method, params = nil) + request = { jsonrpc: '2.0', id: id, method: method } + request[:params] = params unless params.nil? + request + end + + def new_test_client + PostHog::Client.new(api_key: 'phc_test', test_mode: true) + end +end + +RSpec.configure do |config| + config.include MCPSpecHelpers + config.before(:each) { PostHog::MCP.reset_for_tests! } +end diff --git a/spec/posthog/mcp/tools_spec.rb b/spec/posthog/mcp/tools_spec.rb new file mode 100644 index 0000000..19ba305 --- /dev/null +++ b/spec/posthog/mcp/tools_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Tools do + it 'exposes the cross-SDK descriptor and canned result' do + descriptor = described_class.descriptor + expect(descriptor[:name]).to eq('get_more_tools') + expect(descriptor[:description]).to eq('Check for additional tools whenever your task might benefit from specialized ' \ + 'capabilities - even if existing tools could work as a fallback.') + expect(descriptor[:inputSchema][:required]).to eq(['context']) + expect(descriptor[:annotations]).to eq(title: 'Get More Tools', readOnlyHint: true, openWorldHint: true, + idempotentHint: true, destructiveHint: false) + expect(PostHog::MCP.get_more_tools_result[:content][0][:text]).to eq( + 'Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the ' \ + 'tool list in the future.' + ) + end +end +# rubocop:enable Layout/LineLength diff --git a/spec/posthog/mcp/truncation_spec.rb b/spec/posthog/mcp/truncation_spec.rb new file mode 100644 index 0000000..d2f755d --- /dev/null +++ b/spec/posthog/mcp/truncation_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe PostHog::MCP::Truncation do + describe '.normalize' do + it 'caps strings at 32KB with a ... suffix' do + out = described_class.normalize('a' * 33_000) + expect(out.length).to eq(32_771) + expect(out).to end_with('...') + expect(described_class.normalize('a' * 32_768)).to eq('a' * 32_768) + end + + it 'collapses depth and breadth with the shared markers' do + nested = (1..15).reduce({}) { |acc, _| { 'nested' => acc } } + expect(described_class.normalize(nested, 5).dig('nested', 'nested', 'nested', 'nested', + 'nested')).to eq('[Object]') + deep_array = (1..15).reduce([]) { |acc, _| [acc] } + expect(described_class.normalize(deep_array, 3)[0][0][0]).to eq('[Array]') + expect(described_class.normalize({ 'a' => 1 }, 0)).to eq('[Object]') + expect(described_class.normalize([1, 2], 0)).to eq('[Array]') + expect(described_class.normalize('hello', 0)).to eq('hello') + + wide = (0...150).to_h { |i| ["key#{i}", i] } + out = described_class.normalize(wide, 10, 5) + expect(out.length).to eq(6) + expect(out['...']).to eq('[MaxProperties ~]') + out = described_class.normalize((0...150).to_a, 10, 5) + expect(out.length).to eq(6) + expect(out.last).to eq('[MaxProperties ~]') + end + + it 'marks cycles and coerces scalars' do + obj = { 'a' => 1 } + obj['self'] = obj + expect(described_class.normalize(obj)).to eq('a' => 1, 'self' => '[Circular ~]') + arr = [1, 2] + arr << arr + expect(described_class.normalize(arr)).to eq([1, 2, '[Circular ~]']) + shared = { 'x' => 1 } + expect(described_class.normalize('a' => shared, 'b' => shared)).to eq('a' => { 'x' => 1 }, 'b' => { 'x' => 1 }) + expect(described_class.normalize(Float::NAN)).to eq('[NaN]') + expect(described_class.normalize(Float::INFINITY)).to eq('[Infinity]') + expect(described_class.normalize(-Float::INFINITY)).to eq('[-Infinity]') + expect(described_class.normalize(true)).to eq(true) + expect(described_class.normalize(7)).to eq(7) + expect(described_class.normalize(Time.utc(2025, 1, 15, 12))).to eq('2025-01-15T12:00:00.000Z') + expect(described_class.normalize(:sym)).to eq('sym') + expect(described_class.normalize(-> {})).to start_with('[Function:') + end + end + + describe '.truncate_event' do + it 'applies field caps' do + event = described_class.truncate_event( + 'user_intent' => 'x' * 3000, 'resource_name' => 't' * 300, 'server_name' => 's' * 300, + 'client_version' => 'cv' * 200, + 'error' => { '$exception_list' => [{ 'value' => 'e' * 3000, + 'stacktrace' => { 'frames' => (0...80).map do |i| + { 'filename' => "file#{i}.rb" } + end } }] } + ) + expect(event['user_intent'].length).to eq(2051) + expect(event['resource_name'].length).to eq(259) + expect(event['server_name'].length).to eq(259) + expect(event['client_version'].length).to eq(259) + exception = event['error']['$exception_list'][0] + expect(exception['value'].length).to eq(2051) + frames = exception['stacktrace']['frames'] + expect(frames.length).to eq(50) + expect(frames[0]['filename']).to eq('file0.rb') + expect(frames[24]['filename']).to eq('file24.rb') + expect(frames[25]['filename']).to eq('file55.rb') + expect(frames[49]['filename']).to eq('file79.rb') + end + + it 'bounds user-supplied properties so the event fits the core client budget' do + event = described_class.truncate_event('event_type' => 'custom', 'properties' => { 'rows' => (1..10_000).to_a }) + expect(described_class.json_byte_size(event)).to be <= described_class::MAX_EVENT_BYTES + expect(event['properties']['rows'].length).to be <= described_class::MAX_BREADTH + 1 + end + + it 'caps response text blocks and then fits the whole event in the byte budget' do + content = described_class.truncate_response_content('content' => [{ 'type' => 'text', 'text' => 'x' * 40_000 }, + { 'type' => 'text', 'text' => 'short' }]) + expect(content['content'][0]['text'].length).to eq(32_771) + expect(content['content'][1]['text']).to eq('short') + + event = described_class.truncate_event('response' => content) + expect(event['response']['content'][0]['text']).to end_with('...') + expect(described_class.json_byte_size(event)).to be <= described_class::MAX_EVENT_BYTES + end + + it 'keeps events within the byte budget without mutating the input' do + timestamp = Time.utc(2025, 1, 15, 12) + cases = [ + { 'parameters' => { 'a' => 'x' * 60_000, 'b' => 'x' * 60_000, 'c' => 'x' * 60_000, 'd' => 'x' * 60_000 } }, + { 'parameters' => (1..8).reduce('leaf' => 'x' * 15_000) do |acc, _| + { 'level' => acc, 'pad' => 'x' * 15_000 } + end }, + { 'parameters' => 'x' * 120_000 }, + { 'parameters' => (0...60).to_h { |i| ["field_#{i}", 'z' * 5000] } }, + { 'parameters' => { 'blob' => 'z' * 300_000 } } + ] + cases.each do |input| + event = input.merge('timestamp' => timestamp, 'event_type' => 'mcp:tools/call', 'is_error' => false) + before = Marshal.load(Marshal.dump(event)) + expect(described_class.json_byte_size(event)).to be > described_class::MAX_EVENT_BYTES + out = described_class.truncate_event(event) + expect(described_class.json_byte_size(out)).to be <= described_class::MAX_EVENT_BYTES + expect(out['timestamp']).to eq(timestamp) + expect(out['event_type']).to eq('mcp:tools/call') + expect(event).to eq(before) + end + end + + it 'leaves SDK-controlled fields untouched' do + event = { 'event_type' => 'mcp:tools/call', 'resource_name' => 'echo', 'is_error' => false, 'duration' => 342 } + expect(described_class.truncate_event(event)).to eq(event) + end + end +end