posthog-ruby 3.23.8 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4074fb22041c597b36316d8f5ecd613adeb94c6e5440af887dbb0e4ccf95eb1
4
- data.tar.gz: b0a587ad8bef2e363321de12c0fdb7cce636fc80338caa3e4009855d4665e9ce
3
+ metadata.gz: 2ff857115cd53eb41945a0bad1a05768bb54770b4f5e2d8e8fda17a2bdb8a758
4
+ data.tar.gz: 910d5e7ade22aa5c3d8fed8794ad4f3b72230f1c649e40de8790b2acc9fa80e9
5
5
  SHA512:
6
- metadata.gz: 2aa7c9f54b15c7f4a53f3ed54419ac052a4ecbf9ab4b6454efe4fa4a99389e40ce35cf218aa19f5030e4cfb6ef3dcdb6d4669404687e050029d8ef77fb40a035
7
- data.tar.gz: e3438baa285a1fab688cf3f7aa3192f87c5719c1dc086a43147d6f1e88609ec9281479a80984726c59a0ae26a1a1f2faf069e3428f9f7a6f9b6d89efb0e1c31b
6
+ metadata.gz: 34f457e72fb2c137e38f6188b9fb63dfb9541a21b705f373e1c4d39d4037c70ae2762f48774d9fbdfec015f3c745a6e0d6775e56c57d011b8f57bc5ee99e299c
7
+ data.tar.gz: dccddee87b9b57ae401a36f31f76685bb353974eb94c390d564582ad79cf4060544ede2b28229f87de486afb68d0f740b28becd8dcd878ab96494b1b0c6958de
@@ -44,9 +44,16 @@ module PostHog
44
44
  $is_server
45
45
  $lib
46
46
  $lib_version
47
+ $release_id
47
48
  ].flat_map { |key| [key, key.to_sym] }.freeze
48
49
  private_constant :MINIMAL_FLAG_CALLED_EVENT_PROPERTIES
49
50
 
51
+ # Holds the id that `posthog-cli release resolve` prints. Error tracking
52
+ # links an exception to its release by this id, because a Ruby app has no
53
+ # build step that could inject it.
54
+ RELEASE_ID_ENV_VAR = 'POSTHOG_RELEASE_ID'
55
+ private_constant :RELEASE_ID_ENV_VAR
56
+
50
57
  # Thread-safe tracking of client instances per API key for singleton warnings
51
58
  @instances_by_api_key = {}
52
59
  @instances_mutex = Mutex.new
@@ -217,6 +224,7 @@ module PostHog
217
224
 
218
225
  @before_send = opts[:before_send]
219
226
  @is_server = opts.fetch(:is_server, true) != false
227
+ @release_id = normalize_string_option(ENV.fetch(RELEASE_ID_ENV_VAR, nil), blank_as_nil: true)
220
228
  @deprecation_emitted_for = Concurrent::Set.new
221
229
  end
222
230
 
@@ -302,6 +310,11 @@ module PostHog
302
310
 
303
311
  symbolize_keys! attrs
304
312
  minimal_flag_called_event = attrs.delete(:_minimal_flag_called_event) == true
313
+ # Integrations that ride on a host application's client (e.g. PostHog::MCP)
314
+ # may relabel a single event's `$lib`/`$lib_version` without relabeling the
315
+ # client or its User-Agent.
316
+ lib_override = attrs.delete(:_lib)
317
+ lib_version_override = attrs.delete(:_lib_version)
305
318
  enrich_capture_attrs_with_context(attrs)
306
319
 
307
320
  # Precedence: an explicit `flags` snapshot always wins, regardless of
@@ -370,8 +383,8 @@ module PostHog
370
383
  end
371
384
 
372
385
  attrs[:is_server] = @is_server
373
- attrs[:lib] = @lib
374
- attrs[:lib_version] = @lib_version
386
+ attrs[:lib] = lib_override || @lib
387
+ attrs[:lib_version] = (lib_version_override || @lib_version).to_s
375
388
  message = FieldParser.parse_for_capture(attrs)
376
389
  # Minimal events are built from the allowlist after full assembly so
377
390
  # context properties and parser-added metadata can never leak in.
@@ -1132,6 +1145,8 @@ module PostHog
1132
1145
  def enqueue(action)
1133
1146
  return false if @disabled || shutdown?
1134
1147
 
1148
+ # Every event type passes through here, and before_send still sees the id.
1149
+ action = add_release_id(action)
1135
1150
  action = process_before_send(action)
1136
1151
  return false if action.nil? || action.empty?
1137
1152
 
@@ -1163,6 +1178,17 @@ module PostHog
1163
1178
  end
1164
1179
  end
1165
1180
 
1181
+ # A `$release_id` the caller or the request context already set wins.
1182
+ def add_release_id(action)
1183
+ return action if @release_id.nil?
1184
+
1185
+ properties = action[:properties]
1186
+ return action unless properties.is_a?(Hash)
1187
+ return action if properties.key?('$release_id') || properties.key?(:$release_id)
1188
+
1189
+ action.merge(properties: properties.merge('$release_id' => @release_id))
1190
+ end
1191
+
1166
1192
  def normalize_string_option(value, blank_as_nil: false)
1167
1193
  return value unless value.is_a?(String)
1168
1194
 
@@ -0,0 +1,53 @@
1
+ # PostHog MCP analytics for Ruby
2
+
3
+ > **Experimental and unsupported.** `PostHog::MCP` is not an officially supported PostHog SDK: no
4
+ > support is provided for it, and its API, options, and the captured `$mcp_*` event schema may change
5
+ > in a minor release. A one-line warning is logged when you require it. Bug reports and patches are
6
+ > welcome at https://github.com/PostHog/posthog-ruby/issues, but don't build production reporting on
7
+ > it yet.
8
+
9
+ Product analytics for [Model Context Protocol](https://modelcontextprotocol.io) servers built on the
10
+ official Ruby [`mcp`](https://rubygems.org/gems/mcp) gem. Wrap an `MCP::Server` so every tool call,
11
+ agent intent, handshake, listing, prompt, resource read, and failure is captured to PostHog.
12
+
13
+ **Documentation: https://posthog.com/docs/mcp-analytics** — setup, every option, the event and
14
+ property catalog, sessions on stateless/multi-pod servers, conversation ids, intent, identifying
15
+ users, privacy, and custom dispatchers. That's the single source of truth; this directory
16
+ deliberately keeps no second copy of it.
17
+
18
+ Install is just `gem 'posthog-ruby'`. `PostHog::MCP.instrument` needs the `mcp` gem (`>= 1.4`) at
19
+ runtime, but anyone wrapping a server already has it. `PostHog::MCP::Client` (custom dispatchers)
20
+ needs nothing beyond `posthog-ruby`.
21
+
22
+ ## Ruby-specific notes
23
+
24
+ These are the few things that differ from what the docs describe for the other SDKs.
25
+
26
+ * **`$lib` is per event.** MCP events report `$lib: "posthog-ruby-mcp"` while the client you pass in
27
+ keeps its own identity (`posthog-ruby` or `posthog-rails`) for everything else it sends, so
28
+ instrumenting a server inside a Rails app doesn't relabel the app's other events.
29
+ * **Truncation is tighter.** Events are truncated to fit the core client's 32KB per-message limit,
30
+ because posthog-ruby drops larger messages at batch time - truncating harder beats losing the
31
+ event.
32
+ * **Request scope needs Ruby 3.2+ to be inherited.** A custom event captured from a thread or fiber
33
+ a tool spawns is attributed to that request on 3.2+ (fiber storage); before 3.2 capture from the
34
+ tool body itself, or an HTTP server falls back to a standalone session.
35
+ * **Composed schemas are left alone.** A tool whose `input_schema` is `oneOf`/`allOf`/`anyOf` or a
36
+ `$ref` has nothing injected into it and nothing stripped from its calls.
37
+ * **stdio servers own `$stdout`.** The integration's own messages go only to the `logger:` you pass
38
+ (default: nowhere); the experimental notice and misconfiguration warnings go to stderr. Point the
39
+ core SDK's logger away from stdout too: `PostHog::Logging.logger = Logger.new($stderr)`.
40
+
41
+ ## Layout
42
+
43
+ | File | What it does |
44
+ |---|---|
45
+ | `mcp.rb` (parent dir) | `PostHog::MCP.instrument` and the public helpers |
46
+ | `server_extension.rb` | Prepended onto `MCP::Server` and the Streamable HTTP transport |
47
+ | `instrumentation.rb` | Per-request dispatch: intent, identity, sessions, recording |
48
+ | `client.rb` | `PostHog::MCP::Client` for custom dispatchers |
49
+ | `rack_middleware.rb` | `Mcp-Session-Id` tokens for a custom Rack stack |
50
+ | `sanitization.rb`, `truncation.rb` | Redaction and payload budgets |
51
+
52
+ Runnable example: [`examples/mcp_server.rb`](../../../examples/mcp_server.rb). Specs:
53
+ `spec/posthog/mcp/`.
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Handle returned by {PostHog::MCP.instrument}. Emits custom events onto the
6
+ # same pipeline as the auto-captured `$mcp_*` events.
7
+ #
8
+ # @note Experimental.
9
+ class Analytics
10
+ # @api private
11
+ def initialize(server)
12
+ @server = server
13
+ end
14
+
15
+ # Capture a custom event scoped to the current MCP session. The event
16
+ # name is sent verbatim (a customer event, not `$`-prefixed).
17
+ #
18
+ # Inside a tool body the session and the identity are the ones pinned to
19
+ # the in-flight request by {Instrumentation}, so a concurrent request on
20
+ # the same session cannot reattribute this event. Over stdio, where a
21
+ # server only ever talks to one client, the session is the server's
22
+ # current one. On an HTTP server a call that has lost the request scope
23
+ # gets a standalone session rather than the server's, which may belong to
24
+ # another caller's request.
25
+ #
26
+ # @param event [String] event name
27
+ # @param properties [Hash] event properties
28
+ # @return [void]
29
+ # @raise [ArgumentError] when the event name is blank
30
+ def capture(event, properties = {})
31
+ unless event.is_a?(String) && !event.strip.empty?
32
+ raise ArgumentError, 'capture() requires an event name, e.g. analytics.capture("feedback_submitted")'
33
+ end
34
+
35
+ data = PostHog::MCP.tracking_data(@server)
36
+ return if data.nil?
37
+
38
+ scope = RequestScope.current
39
+ scope = nil unless scope.is_a?(Hash)
40
+ Instrumentation.capture_event(data, {
41
+ 'session_id' => current_session_id(data, scope),
42
+ 'event_type' => EventType::CUSTOM,
43
+ 'event_name' => event,
44
+ 'timestamp' => Time.now.utc,
45
+ 'properties' => properties
46
+ }, actor: scoped_actor(scope))
47
+ nil
48
+ end
49
+
50
+ # Flush the underlying PostHog client.
51
+ #
52
+ # @return [void]
53
+ def flush
54
+ data = PostHog::MCP.tracking_data(@server)
55
+ client = data&.sink&.client
56
+ client.flush if client.respond_to?(:flush)
57
+ nil
58
+ end
59
+
60
+ private
61
+
62
+ # The session pinned to the in-flight request, when there is one. On a server
63
+ # that has served an HTTP request the server-wide session is not a safe
64
+ # fallback: it belongs to whichever request settled it last, which under
65
+ # concurrency is somebody else's. That happens when a tool hands its work to
66
+ # a thread or fiber it spawned on Ruby 3.0/3.1, where {RequestScope} is
67
+ # fiber-local and is not inherited. Fail closed with a standalone session
68
+ # rather than filing the event under another caller's identity; over stdio a
69
+ # server only ever talks to one client, so the fallback stays.
70
+ def current_session_id(data, scope)
71
+ scoped = scope ? scope[:session_id] : nil
72
+ return scoped if scoped
73
+ return data.session_id unless data.http_transport_seen
74
+
75
+ warn_unscoped_capture(data)
76
+ Session.new_session_id
77
+ end
78
+
79
+ # The identity the in-flight request resolved, which {Instrumentation} pinned
80
+ # on the scope before running the tool body. Absent outside a request it
81
+ # started, and only then is the session-keyed cache consulted instead.
82
+ def scoped_actor(scope)
83
+ scope&.key?(:actor) ? scope[:actor] : Instrumentation::UNRESOLVED_ACTOR
84
+ end
85
+
86
+ def warn_unscoped_capture(data)
87
+ return if data.warned_unscoped_capture
88
+
89
+ data.warned_unscoped_capture = true
90
+ Log.warn(
91
+ data.options,
92
+ 'Warning: analytics.capture() ran without the scope of the request that started it, so its event got ' \
93
+ 'a standalone $session_id instead of the caller\'s. On Ruby 3.2+ the scope follows threads and fibers ' \
94
+ 'a tool spawns; before that it does not, so capture custom events from the tool body itself.'
95
+ )
96
+ end
97
+ end
98
+
99
+ # Returned when instrumentation could not be set up; every call is a no-op.
100
+ #
101
+ # @api private
102
+ class NoopAnalytics < Analytics
103
+ def initialize
104
+ super(nil)
105
+ end
106
+
107
+ def capture(_event = nil, _properties = {})
108
+ nil
109
+ end
110
+
111
+ def flush
112
+ nil
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # A {PostHog::Client} with first-class MCP analytics for custom dispatchers
6
+ # (your own HTTP layer, no `MCP::Server` to wrap). The host resolves
7
+ # identity and context per request and calls the capture methods directly;
8
+ # events flow through the same sanitize -> truncate -> `$exception` fan-out
9
+ # pipeline as {PostHog::MCP.instrument}. Does not need the `mcp` gem.
10
+ #
11
+ # @note Experimental.
12
+ #
13
+ # @example
14
+ # posthog = PostHog::MCP::Client.new(api_key: 'phc_...', host: 'https://us.i.posthog.com')
15
+ # posthog.capture_tool_call('search_docs', duration_ms: 42, distinct_id: 'user_123')
16
+ class Client < PostHog::Client
17
+ # @param opts [Hash] {PostHog::Client} options plus:
18
+ # @option opts [String] :missing_capability_tool_name name of the virtual tool (default `get_more_tools`)
19
+ # @option opts [Boolean] :mcp_exception_autocapture emit a sibling `$exception` for failed calls (default true)
20
+ def initialize(opts = {})
21
+ opts = opts.transform_keys(&:to_sym)
22
+ @missing_capability_tool_name = opts.delete(:missing_capability_tool_name) || Tools::GET_MORE_TOOLS_NAME
23
+ @mcp_exception_autocapture = opts.delete(:mcp_exception_autocapture) != false
24
+ super
25
+ @mcp_sink = Sink.new(self)
26
+ @mcp_options = Options.new(
27
+ enable_exception_autocapture: @mcp_exception_autocapture,
28
+ missing_capability_tool_name: @missing_capability_tool_name
29
+ )
30
+ end
31
+
32
+ # Capture a tool invocation. Emits `$mcp_tool_call` (+ `$exception` on error).
33
+ #
34
+ # @return [void]
35
+ def capture_tool_call(tool_name, intent: nil, intent_source: nil, parameters: nil, response: nil,
36
+ duration_ms: nil, is_error: false, error: nil, error_type: nil, category: nil,
37
+ tool_description: nil, protocol_version: nil, distinct_id: nil, session_id: nil,
38
+ client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
39
+ properties: nil, timestamp: nil, llm_model: nil, llm_model_source: nil)
40
+ event = base_event(EventType::MCP_TOOLS_CALL, distinct_id, session_id, set_properties, groups, properties,
41
+ timestamp, client_user_agent, vendor_client)
42
+ event['resource_name'] = tool_name
43
+ event['tool_description'] = tool_description
44
+ event['tool_category'] = category
45
+ event['protocol_version'] = protocol_version
46
+ event['parameters'] = parameters
47
+ event['response'] = response
48
+ event['duration'] = duration_ms
49
+ event['is_error'] = is_error == true
50
+ event['error_type'] = error_type
51
+ apply_intent(event, intent, intent_source)
52
+ apply_model(event, llm_model, llm_model_source)
53
+ if is_error
54
+ event['error'] =
55
+ Exceptions.capture_exception(error.nil? ? "Tool #{tool_name} returned an error" : error)
56
+ end
57
+ emit(event)
58
+ end
59
+
60
+ # Capture the connection handshake. Emits `$mcp_initialize`.
61
+ #
62
+ # @return [void]
63
+ def capture_initialize(client_name: nil, client_version: nil, protocol_version: nil, parameters: nil,
64
+ response: nil, duration_ms: nil, distinct_id: nil, session_id: nil,
65
+ client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
66
+ properties: nil, timestamp: nil)
67
+ event = base_event(EventType::MCP_INITIALIZE, distinct_id, session_id, set_properties, groups, properties,
68
+ timestamp, client_user_agent, vendor_client)
69
+ event['client_name'] = client_name
70
+ event['client_version'] = client_version
71
+ event['protocol_version'] = protocol_version
72
+ event['parameters'] = parameters
73
+ event['response'] = response
74
+ event['duration'] = duration_ms
75
+ emit(event)
76
+ end
77
+
78
+ # Capture a `tools/list` response. Emits `$mcp_tools_list` with `$mcp_listed_tool_names`.
79
+ #
80
+ # @return [void]
81
+ def capture_tools_list(tool_names: nil, parameters: nil, response: nil, duration_ms: nil, is_error: false,
82
+ error: nil, error_type: nil, protocol_version: nil, distinct_id: nil, session_id: nil,
83
+ client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
84
+ properties: nil, timestamp: nil)
85
+ event = base_event(EventType::MCP_TOOLS_LIST, distinct_id, session_id, set_properties, groups, properties,
86
+ timestamp, client_user_agent, vendor_client)
87
+ event['listed_tool_names'] = tool_names
88
+ event['protocol_version'] = protocol_version
89
+ event['parameters'] = parameters
90
+ event['response'] = response
91
+ event['duration'] = duration_ms
92
+ event['is_error'] = is_error == true
93
+ event['error_type'] = error_type
94
+ event['error'] = Exceptions.capture_exception(error.nil? ? 'tools/list failed' : error) if is_error
95
+ emit(event)
96
+ end
97
+
98
+ # Capture a `get_more_tools` call as a missing-capability report. Emits
99
+ # `$mcp_missing_capability` with the agent's description as `$mcp_intent`.
100
+ #
101
+ # @return [void]
102
+ def capture_missing_capability(context: nil, parameters: nil, protocol_version: nil, distinct_id: nil,
103
+ session_id: nil, client_user_agent: nil, vendor_client: nil,
104
+ set_properties: nil, groups: nil, properties: nil, timestamp: nil,
105
+ llm_model: nil, llm_model_source: nil)
106
+ event = base_event(EventType::MCP_MISSING_CAPABILITY, distinct_id, session_id, set_properties, groups,
107
+ properties, timestamp, client_user_agent, vendor_client)
108
+ event['resource_name'] = @missing_capability_tool_name
109
+ event['protocol_version'] = protocol_version
110
+ event['parameters'] = parameters
111
+ apply_intent(event, context, 'context_parameter')
112
+ apply_model(event, llm_model, llm_model_source)
113
+ emit(event)
114
+ end
115
+
116
+ # Inject the `context` argument (and, with `capture_model`, `llm_model`) into
117
+ # every tool descriptor (Hash with `inputSchema`) so agents state their intent,
118
+ # and optionally append the `get_more_tools` virtual tool. Returns a new Array
119
+ # of new Hashes. A tool whose schema is composed (oneOf/allOf/anyOf) or a
120
+ # `$ref` is passed through untouched.
121
+ #
122
+ # @param tools [Array<Hash>] `tools/list` entries
123
+ # @return [Array<Hash>]
124
+ def prepare_tool_list(tools, context: true, report_missing: false, capture_model: false)
125
+ options = Options.new(context: context, capture_model: capture_model)
126
+ prepared = tools.map do |tool|
127
+ next tool unless tool.is_a?(Hash) && (options.context_enabled? || options.capture_model_enabled?)
128
+
129
+ name = SchemaMutation.fetch(tool, :name) || 'unknown'
130
+ next tool if name == @missing_capability_tool_name
131
+
132
+ schema = SchemaMutation.fetch(tool, :inputSchema)
133
+ if options.context_enabled?
134
+ schema = SchemaMutation.add_context_parameter(schema, tool_name: name,
135
+ description: options.context_description)
136
+ end
137
+ if options.capture_model_enabled?
138
+ schema = SchemaMutation.add_model_parameter(schema, tool_name: name,
139
+ description: options.model_description)
140
+ end
141
+ tool.merge(SchemaMutation.key_for(tool, :inputSchema) => schema)
142
+ end
143
+ if report_missing && prepared.none? { |t| SchemaMutation.fetch(t, :name) == @missing_capability_tool_name }
144
+ prepared << Tools.descriptor(@missing_capability_tool_name, options)
145
+ end
146
+ prepared
147
+ end
148
+
149
+ # Pull the agent's intent off the `context` argument and its self-reported
150
+ # model off `llm_model`, strip the arguments this integration injected, and
151
+ # flag the `get_more_tools` virtual tool. Hand `intent`/`intent_source` and
152
+ # `llm_model`/`llm_model_source` straight to {#capture_tool_call}.
153
+ #
154
+ # Pass the tool's own `inputSchema` (the same Hash you handed to
155
+ # {#prepare_tool_list}) so a field the tool declares itself is left in
156
+ # `args` and never read as analytics: only an injected argument is stripped
157
+ # and reported. A composed (oneOf/allOf/anyOf) or `$ref` schema is never
158
+ # injected into, so its fields are the tool's own and are left alone too.
159
+ # Without the schema there is no way to tell the two apart, so the injected
160
+ # names are always stripped.
161
+ #
162
+ # @param name [String] tool name
163
+ # @param args [Hash, nil] the call's arguments
164
+ # @param input_schema [Hash, nil] the tool's raw `inputSchema`
165
+ # @return [PreparedToolCall]
166
+ def prepare_tool_call(name, args = nil, input_schema: nil)
167
+ intent = tool_declares?(input_schema, 'context') ? nil : Intent.normalize(argument(args, 'context'))
168
+ model = if tool_declares?(input_schema, ModelCapture::PARAM_NAME)
169
+ nil
170
+ else
171
+ ModelCapture.normalize(argument(args, ModelCapture::PARAM_NAME))
172
+ end
173
+ PreparedToolCall.new(
174
+ args: strip_injected(args, input_schema),
175
+ intent: intent,
176
+ intent_source: intent ? 'context_parameter' : nil,
177
+ llm_model: model,
178
+ llm_model_source: model ? 'self_reported' : nil,
179
+ is_missing_capability: name == @missing_capability_tool_name
180
+ )
181
+ end
182
+
183
+ private
184
+
185
+ def base_event(event_type, distinct_id, session_id, set_properties, groups, properties, timestamp,
186
+ client_user_agent, vendor_client)
187
+ event = {
188
+ 'event_type' => event_type,
189
+ 'session_id' => session_id,
190
+ 'timestamp' => timestamp || Time.now.utc,
191
+ 'properties' => properties,
192
+ 'groups' => groups,
193
+ 'client_user_agent' => client_user_agent,
194
+ 'vendor_client' => vendor_client
195
+ }
196
+ event['identify_actor_given_id'] = distinct_id if distinct_id.is_a?(String) && !distinct_id.empty?
197
+ event['identify_actor_data'] = set_properties if set_properties.is_a?(Hash) && !set_properties.empty?
198
+ event
199
+ end
200
+
201
+ def apply_model(event, llm_model, source)
202
+ model = ModelCapture.normalize(llm_model)
203
+ return unless model
204
+
205
+ event['llm_model'] = model
206
+ event['llm_model_source'] = source || 'self_reported'
207
+ end
208
+
209
+ def apply_intent(event, intent, source)
210
+ trimmed = intent.is_a?(String) ? intent.strip : ''
211
+ return if trimmed.empty?
212
+
213
+ event['user_intent'] = trimmed
214
+ event['user_intent_source'] = source || 'context_parameter'
215
+ end
216
+
217
+ def emit(event)
218
+ @mcp_sink.capture(event, @mcp_options)
219
+ nil
220
+ end
221
+
222
+ # True when the argument belongs to the tool rather than to this layer: it
223
+ # declares the field itself, or its schema is one we never inject into.
224
+ def tool_declares?(input_schema, param)
225
+ return false unless input_schema
226
+
227
+ !SchemaMutation.injectable?(input_schema) || SchemaMutation.declares_param?(input_schema, param)
228
+ end
229
+
230
+ def argument(args, param)
231
+ return nil unless args.is_a?(Hash)
232
+
233
+ args[param.to_sym] || args[param]
234
+ end
235
+
236
+ def strip_injected(args, input_schema)
237
+ return args unless args.is_a?(Hash)
238
+
239
+ keys = ['context', ModelCapture::PARAM_NAME].reject { |param| tool_declares?(input_schema, param) }
240
+ .flat_map { |param| [param.to_sym, param] }
241
+ .select { |key| args.key?(key) }
242
+ keys.empty? ? args : args.except(*keys)
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Value of `$mcp_source` on every primary `$mcp_*` event.
6
+ SOURCE = 'posthog_mcp_analytics'
7
+
8
+ # `$lib` stamped on MCP analytics events (per event, never on the client).
9
+ LIB_NAME = 'posthog-ruby-mcp'
10
+
11
+ # Generated (in-memory) sessions roll over after this much inactivity.
12
+ INACTIVITY_TIMEOUT_MINUTES = 30
13
+
14
+ # Header carrying the transport session id, and our self-encoded token.
15
+ MCP_SESSION_HEADER = 'mcp-session-id'
16
+
17
+ # Description of the injected `context` argument.
18
+ DEFAULT_CONTEXT_PARAMETER_DESCRIPTION =
19
+ 'Explain in 15-25 words, in third person, why this tool is called and how it supports ' \
20
+ "the user's goal. For analytics only. You MUST describe only the abstract purpose of the " \
21
+ 'tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying ' \
22
+ 'information from the user request or tool results, including names, emails, phone numbers, ' \
23
+ 'IPs, IDs, or credentials. You MUST generalize specific entities into roles such as "a user", ' \
24
+ '"the customer", or "an account". Example: "Retrieving a customer\'s recent orders to ' \
25
+ 'investigate a billing issue and help support determine the appropriate resolution."'
26
+
27
+ # Description of the injected `llm_model` argument.
28
+ DEFAULT_MODEL_PARAMETER_DESCRIPTION =
29
+ 'The exact model identifier you (the assistant) are running as, taken from your system ' \
30
+ 'prompt or environment (e.g. "claude-opus-4-8", "gpt-5.2"). Used for analytics only. If you ' \
31
+ 'do not know your model identifier with certainty, pass "unknown" — never guess.'
32
+
33
+ # Description of the injected `conversation_id` argument.
34
+ DEFAULT_CONVERSATION_ID_DESCRIPTION =
35
+ "Echo the conversation_id from the server's previous response. The server provides it on " \
36
+ 'the first call — never invent one, and do not issue parallel tool calls until you have it.'
37
+
38
+ # PostHog-owned event names. All `$`-prefixed per the PostHog convention.
39
+ module Event
40
+ CUSTOM = '$mcp_custom'
41
+ EXCEPTION = '$exception'
42
+ IDENTIFY = '$identify'
43
+ INITIALIZE = '$mcp_initialize'
44
+ MISSING_CAPABILITY = '$mcp_missing_capability'
45
+ PROMPT_GET = '$mcp_prompt_get'
46
+ PROMPTS_LIST = '$mcp_prompts_list'
47
+ RESOURCE_READ = '$mcp_resource_read'
48
+ RESOURCES_LIST = '$mcp_resources_list'
49
+ TOOL_CALL = '$mcp_tool_call'
50
+ TOOLS_LIST = '$mcp_tools_list'
51
+ end
52
+
53
+ # PostHog property wire keys emitted on MCP events.
54
+ module Property
55
+ CLIENT_NAME = '$mcp_client_name'
56
+ CLIENT_USER_AGENT = '$mcp_client_user_agent'
57
+ CLIENT_VERSION = '$mcp_client_version'
58
+ VENDOR_CLIENT = '$mcp_vendor_client'
59
+ PROTOCOL_VERSION = '$mcp_protocol_version'
60
+ CONVERSATION_ID = '$mcp_conversation_id'
61
+ DURATION_MS = '$mcp_duration_ms'
62
+ ERROR_MESSAGE = '$mcp_error_message'
63
+ ERROR_TYPE = '$mcp_error_type'
64
+ IS_ERROR = '$mcp_is_error'
65
+ INTENT = '$mcp_intent'
66
+ INTENT_SOURCE = '$mcp_intent_source'
67
+ LISTED_TOOL_NAMES = '$mcp_listed_tool_names'
68
+ LLM_MODEL = '$mcp_llm_model'
69
+ LLM_MODEL_SOURCE = '$mcp_llm_model_source'
70
+ PARAMETERS = '$mcp_parameters'
71
+ RESOURCE_NAME = '$mcp_resource_name'
72
+ RESPONSE = '$mcp_response'
73
+ SERVER_NAME = '$mcp_server_name'
74
+ SERVER_VERSION = '$mcp_server_version'
75
+ SESSION_ID = '$session_id'
76
+ SOURCE = '$mcp_source'
77
+ TOOL_CATEGORY = '$mcp_tool_category'
78
+ TOOL_DESCRIPTION = '$mcp_tool_description'
79
+ TOOL_NAME = '$mcp_tool_name'
80
+ end
81
+
82
+ # Internal dispatch keys for the event pipeline (never sent on the wire).
83
+ #
84
+ # @api private
85
+ module EventType
86
+ CUSTOM = 'posthog:custom'
87
+ IDENTIFY = 'posthog:identify'
88
+ MCP_INITIALIZE = 'mcp:initialize'
89
+ MCP_MISSING_CAPABILITY = 'mcp:missing_capability'
90
+ MCP_PROMPTS_GET = 'mcp:prompts/get'
91
+ MCP_PROMPTS_LIST = 'mcp:prompts/list'
92
+ MCP_RESOURCES_LIST = 'mcp:resources/list'
93
+ MCP_RESOURCES_READ = 'mcp:resources/read'
94
+ MCP_TOOLS_CALL = 'mcp:tools/call'
95
+ MCP_TOOLS_LIST = 'mcp:tools/list'
96
+
97
+ EVENT_NAME_BY_TYPE = {
98
+ CUSTOM => Event::CUSTOM,
99
+ IDENTIFY => Event::IDENTIFY,
100
+ MCP_INITIALIZE => Event::INITIALIZE,
101
+ MCP_MISSING_CAPABILITY => Event::MISSING_CAPABILITY,
102
+ MCP_PROMPTS_GET => Event::PROMPT_GET,
103
+ MCP_PROMPTS_LIST => Event::PROMPTS_LIST,
104
+ MCP_RESOURCES_LIST => Event::RESOURCES_LIST,
105
+ MCP_RESOURCES_READ => Event::RESOURCE_READ,
106
+ MCP_TOOLS_CALL => Event::TOOL_CALL,
107
+ MCP_TOOLS_LIST => Event::TOOLS_LIST
108
+ }.freeze
109
+ end
110
+ end
111
+ end