posthog-ruby 3.23.7 → 3.24.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.
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module PostHog
7
+ module MCP
8
+ # Layered truncation so an event fits within a byte budget before capture:
9
+ #
10
+ # 1. Field-level string limits (intent, resource name, metadata fields).
11
+ # 2. Stack-frame limiting and message caps on the `$exception_list` shape.
12
+ # 3. Response content text limits (32KB per text block).
13
+ # 4. Recursive normalization of user-controlled fields (depth/breadth/string caps).
14
+ # 5. Size-targeted truncation: progressive depth reduction, then trimming the
15
+ # largest strings until under MAX_EVENT_BYTES.
16
+ #
17
+ # Pure functions; the input event is never mutated. Hash keys are strings.
18
+ #
19
+ # @api private
20
+ module Truncation
21
+ MAX_DEPTH = 10
22
+ MAX_BREADTH = 100
23
+ MAX_STRING_LENGTH = 32_768
24
+ # The core client drops any single message
25
+ # larger than `Defaults::Message::MAX_BYTES` (32KB) at batch time, so the
26
+ # internal event is budgeted to leave headroom for the envelope (`$lib`,
27
+ # timestamp, uuid, distinct_id) the client adds around it.
28
+ MAX_EVENT_BYTES = PostHog::Defaults::Message::MAX_BYTES - 2048
29
+
30
+ MAX_USER_INTENT_LENGTH = 2048
31
+ MAX_ERROR_MESSAGE_LENGTH = 2048
32
+ MAX_RESOURCE_NAME_LENGTH = 256
33
+ MAX_METADATA_LENGTH = 256
34
+ MAX_STACK_FRAMES = 50
35
+ MAX_CONTENT_TEXT_LENGTH = 32_768
36
+
37
+ TRUNCATION_SUFFIX = '...'
38
+
39
+ METADATA_FIELDS = [
40
+ ['user_intent', MAX_USER_INTENT_LENGTH],
41
+ ['resource_name', MAX_RESOURCE_NAME_LENGTH],
42
+ ['server_name', MAX_METADATA_LENGTH],
43
+ ['server_version', MAX_METADATA_LENGTH],
44
+ ['client_name', MAX_METADATA_LENGTH],
45
+ ['client_version', MAX_METADATA_LENGTH],
46
+ ['error_type', MAX_METADATA_LENGTH],
47
+ ['client_user_agent', MAX_METADATA_LENGTH],
48
+ ['vendor_client', MAX_METADATA_LENGTH],
49
+ ['llm_model', MAX_METADATA_LENGTH]
50
+ ].freeze
51
+
52
+ # Includes user-supplied `properties` (custom events, `event_properties`,
53
+ # `capture_tool_call`): a large numeric array cannot be shrunk by string
54
+ # trimming, so it must take part in depth/breadth reduction or the core
55
+ # client drops the whole message at batch time.
56
+ NORMALIZED_FIELDS = %w[parameters response identify_actor_data error properties].freeze
57
+
58
+ module_function
59
+
60
+ # Recursively normalize a value: cap strings, coerce non-serializable
61
+ # values, convert times, detect cycles, and bound depth/breadth.
62
+ def normalize(value, depth = MAX_DEPTH, max_breadth = MAX_BREADTH, max_string_length = MAX_STRING_LENGTH)
63
+ visit(value, depth, max_breadth, max_string_length, {}.compare_by_identity)
64
+ end
65
+
66
+ def visit(value, remaining_depth, max_breadth, max_string_length, memo)
67
+ case value
68
+ when nil, true, false, Integer then value
69
+ when Float
70
+ return '[NaN]' if value.nan?
71
+ return (value.positive? ? '[Infinity]' : '[-Infinity]') if value.infinite?
72
+
73
+ value
74
+ when String
75
+ value.length > max_string_length ? value[0, max_string_length] + TRUNCATION_SUFFIX : value
76
+ when Symbol then value.to_s
77
+ when Time then value.utc.iso8601(3)
78
+ when Proc, Method
79
+ name = value.respond_to?(:name) ? value.name : nil
80
+ "[Function: #{name || '<anonymous>'}]"
81
+ when Array
82
+ return '[Circular ~]' if memo.key?(value)
83
+ return '[Array]' if remaining_depth <= 0
84
+
85
+ memo[value] = true
86
+ result = visit_array(value, remaining_depth - 1, max_breadth, max_string_length, memo)
87
+ memo.delete(value)
88
+ result
89
+ when Hash
90
+ return '[Circular ~]' if memo.key?(value)
91
+ return '[Object]' if remaining_depth <= 0
92
+
93
+ memo[value] = true
94
+ result = visit_object(value, remaining_depth - 1, max_breadth, max_string_length, memo)
95
+ memo.delete(value)
96
+ result
97
+ else
98
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
99
+ end
100
+ end
101
+
102
+ def visit_array(array, remaining_depth, max_breadth, max_string_length, memo)
103
+ result = []
104
+ array.each_with_index do |item, index|
105
+ if index >= max_breadth
106
+ result << '[MaxProperties ~]'
107
+ break
108
+ end
109
+ result << visit(item, remaining_depth, max_breadth, max_string_length, memo)
110
+ end
111
+ result
112
+ end
113
+
114
+ def visit_object(hash, remaining_depth, max_breadth, max_string_length, memo)
115
+ result = {}
116
+ count = 0
117
+ hash.each do |key, val|
118
+ if count >= max_breadth
119
+ result['...'] = '[MaxProperties ~]'
120
+ break
121
+ end
122
+ result[key.to_s] = visit(val, remaining_depth, max_breadth, max_string_length, memo)
123
+ count += 1
124
+ end
125
+ result
126
+ end
127
+
128
+ def truncate_string(value, max_length)
129
+ return value unless value.is_a?(String) && value.length > max_length
130
+
131
+ value[0, max_length] + TRUNCATION_SUFFIX
132
+ end
133
+
134
+ def truncate_stack_frames(frames)
135
+ return frames unless frames.is_a?(Array) && frames.length > MAX_STACK_FRAMES
136
+
137
+ half = MAX_STACK_FRAMES / 2
138
+ frames[0, half] + frames[-half, half]
139
+ end
140
+
141
+ def truncate_exception_list(error)
142
+ list = error['$exception_list']
143
+ return error unless list.is_a?(Array)
144
+
145
+ truncated = list.map do |exception|
146
+ next exception unless exception.is_a?(Hash)
147
+
148
+ nxt = exception.dup
149
+ nxt['value'] = truncate_string(nxt['value'], MAX_ERROR_MESSAGE_LENGTH) if nxt['value'].is_a?(String)
150
+ stacktrace = nxt['stacktrace']
151
+ if stacktrace.is_a?(Hash) && stacktrace['frames'].is_a?(Array) && !stacktrace['frames'].empty?
152
+ nxt['stacktrace'] = stacktrace.merge('frames' => truncate_stack_frames(stacktrace['frames']))
153
+ end
154
+ nxt
155
+ end
156
+ error.merge('$exception_list' => truncated)
157
+ end
158
+
159
+ def truncate_response_content(response)
160
+ return response unless response.is_a?(Hash)
161
+
162
+ content = response['content']
163
+ return response unless content.is_a?(Array)
164
+
165
+ new_content = content.map do |block|
166
+ if block.is_a?(Hash) && block['type'] == 'text' && block['text'].is_a?(String) &&
167
+ block['text'].length > MAX_CONTENT_TEXT_LENGTH
168
+ block.merge('text' => block['text'][0, MAX_CONTENT_TEXT_LENGTH] + TRUNCATION_SUFFIX)
169
+ else
170
+ block
171
+ end
172
+ end
173
+ response.merge('content' => new_content)
174
+ end
175
+
176
+ # Byte size of the compact JSON encoding, coercing non-JSON values like the
177
+ # transport would.
178
+ def json_byte_size(value)
179
+ JSON.generate(jsonable(value)).bytesize
180
+ end
181
+
182
+ def jsonable(value)
183
+ case value
184
+ when Hash then value.to_h { |k, v| [k.to_s, jsonable(v)] }
185
+ when Array then value.map { |v| jsonable(v) }
186
+ when String, Integer, true, false, nil then value
187
+ when Float then value.finite? ? value : value.to_s
188
+ when Time then value.utc.iso8601(3)
189
+ else
190
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
191
+ end
192
+ end
193
+
194
+ def collect_string_paths(obj, current_path, results)
195
+ case obj
196
+ when String
197
+ results << { path: current_path.dup, length: obj.length } if obj.length > 100
198
+ when Array
199
+ obj.each_with_index { |item, i| collect_string_paths(item, current_path + [i.to_s], results) }
200
+ when Hash
201
+ obj.each { |key, value| collect_string_paths(value, current_path + [key.to_s], results) }
202
+ end
203
+ end
204
+
205
+ def get_nested_value(obj, path)
206
+ path.reduce(obj) do |current, key|
207
+ case current
208
+ when Array then current[key.to_i]
209
+ when Hash then current[key]
210
+ else return nil
211
+ end
212
+ end
213
+ end
214
+
215
+ def set_nested_value(obj, path, value)
216
+ parent = path.empty? ? nil : get_nested_value(obj, path[0...-1])
217
+ case parent
218
+ when Array then parent[path.last.to_i] = value
219
+ when Hash then parent[path.last] = value
220
+ end
221
+ end
222
+
223
+ def deep_copy(obj)
224
+ case obj
225
+ when Hash then obj.to_h { |k, v| [k, deep_copy(v)] }
226
+ when Array then obj.map { |v| deep_copy(v) }
227
+ else obj
228
+ end
229
+ end
230
+
231
+ def truncate_largest_fields(obj, max_bytes)
232
+ result = deep_copy(obj)
233
+
234
+ 10.times do
235
+ current_size = json_byte_size(result)
236
+ return result if current_size <= max_bytes
237
+
238
+ excess = current_size - max_bytes
239
+ string_paths = []
240
+ collect_string_paths(result, [], string_paths)
241
+ string_paths.sort_by! { |entry| -entry[:length] }
242
+ break if string_paths.empty?
243
+
244
+ remaining = excess + 200
245
+ truncated = false
246
+ string_paths.each do |entry|
247
+ break if remaining <= 0
248
+
249
+ length = entry[:length]
250
+ reduction = [remaining, length / 2].min
251
+ next if reduction < 10
252
+
253
+ new_length = length - reduction
254
+ current_value = get_nested_value(result, entry[:path])
255
+ next unless current_value.is_a?(String)
256
+
257
+ set_nested_value(result, entry[:path], current_value[0, new_length] + TRUNCATION_SUFFIX)
258
+ remaining -= reduction
259
+ truncated = true
260
+ end
261
+
262
+ break unless truncated
263
+ end
264
+
265
+ result
266
+ end
267
+
268
+ def truncate_to_size(event, fields = NORMALIZED_FIELDS)
269
+ return event if json_byte_size(event) <= MAX_EVENT_BYTES
270
+
271
+ # Trim the largest strings first so a big tool response keeps its shape
272
+ # (the budget here is tight enough that depth reduction alone would turn
273
+ # a `content` array into "[Array]").
274
+ trimmed = truncate_largest_fields(event, MAX_EVENT_BYTES)
275
+ return trimmed if json_byte_size(trimmed) <= MAX_EVENT_BYTES
276
+
277
+ (MAX_DEPTH - 1).downto(1) do |depth|
278
+ reduced = event.dup
279
+ fields.each do |field|
280
+ reduced[field] = normalize(reduced[field], depth) unless reduced[field].nil?
281
+ end
282
+ return reduced if json_byte_size(reduced) <= MAX_EVENT_BYTES
283
+ end
284
+
285
+ minimal = event.dup
286
+ fields.each do |field|
287
+ minimal[field] = normalize(minimal[field], 1) unless minimal[field].nil?
288
+ end
289
+ truncate_largest_fields(minimal, MAX_EVENT_BYTES)
290
+ end
291
+
292
+ # Re-apply the byte budget to a built payload a `before_send` hook returned.
293
+ # The hook runs after {truncate_event}, so it can grow an event back over
294
+ # the transport's per-message limit, where the batch would drop it whole;
295
+ # trimming here costs the enrichment its bulk instead of the whole event.
296
+ #
297
+ # @param payload [Hash] a payload from {EventBuilder.build}, post-hook
298
+ # @return [Hash] a payload within the byte budget
299
+ def truncate_payload(payload)
300
+ return payload unless payload.is_a?(Hash)
301
+
302
+ key = payload.key?(:properties) && !payload.key?('properties') ? :properties : 'properties'
303
+ truncate_to_size(payload, [key])
304
+ end
305
+
306
+ # @param event [Hash] internal event with string keys
307
+ # @return [Hash] new event within the byte budget
308
+ def truncate_event(event)
309
+ result = event.dup
310
+
311
+ METADATA_FIELDS.each do |key, max_length|
312
+ result[key] = truncate_string(result[key], max_length) if result[key].is_a?(String)
313
+ end
314
+
315
+ result['error'] = truncate_exception_list(result['error']) if result['error'].is_a?(Hash)
316
+ result['response'] = truncate_response_content(result['response']) unless result['response'].nil?
317
+
318
+ NORMALIZED_FIELDS.each do |field|
319
+ result[field] = normalize(result[field]) unless result[field].nil?
320
+ end
321
+
322
+ truncate_to_size(result)
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog'
4
+
5
+ require 'posthog/mcp/constants'
6
+ require 'posthog/mcp/log'
7
+ require 'posthog/mcp/ids'
8
+ require 'posthog/mcp/options'
9
+ require 'posthog/mcp/session_token'
10
+ require 'posthog/mcp/session'
11
+ require 'posthog/mcp/identity'
12
+ require 'posthog/mcp/tools'
13
+ require 'posthog/mcp/exceptions'
14
+ require 'posthog/mcp/sanitization'
15
+ require 'posthog/mcp/truncation'
16
+ require 'posthog/mcp/conversation_id'
17
+ require 'posthog/mcp/intent'
18
+ require 'posthog/mcp/schema_mutation'
19
+ require 'posthog/mcp/event_builder'
20
+ require 'posthog/mcp/sink'
21
+ require 'posthog/mcp/tracking_data'
22
+ require 'posthog/mcp/request_scope'
23
+ require 'posthog/mcp/analytics'
24
+ require 'posthog/mcp/instrumentation'
25
+ require 'posthog/mcp/server_extension'
26
+ require 'posthog/mcp/rack_middleware'
27
+ require 'posthog/mcp/client'
28
+
29
+ module PostHog
30
+ # PostHog MCP analytics for servers built on the official Ruby `mcp` gem.
31
+ #
32
+ # Wrap an `MCP::Server` so every tool call, handshake, listing, prompt,
33
+ # resource read, and failure is captured to PostHog as a `$mcp_*` event.
34
+ #
35
+ # @note Experimental and not officially supported: no support is provided for
36
+ # this integration, and its API and the captured event schema may change in a
37
+ # minor release. A warning is logged when this file is required.
38
+ # Docs: https://posthog.com/docs/mcp-analytics
39
+ #
40
+ # @example
41
+ # require 'posthog/mcp'
42
+ #
43
+ # posthog = PostHog::Client.new(api_key: 'phc_...', host: 'https://us.i.posthog.com')
44
+ # server = MCP::Server.new(name: 'my-server', version: '1.0.0', tools: [MyTool])
45
+ # analytics = PostHog::MCP.instrument(server, posthog)
46
+ #
47
+ # # With posthog-rails the client is resolved from PostHog.client:
48
+ # PostHog::MCP.instrument(server)
49
+ module MCP
50
+ EXPERIMENTAL_NOTICE =
51
+ 'PostHog::MCP is experimental and not officially supported: no support is provided for it, and its ' \
52
+ 'API and the captured $mcp_* event schema may change in a minor release. Docs: ' \
53
+ 'https://posthog.com/docs/mcp-analytics. Feedback welcome at https://github.com/PostHog/posthog-ruby/issues.'
54
+
55
+ class << self
56
+ # Instrument an `MCP::Server`.
57
+ #
58
+ # @param server [MCP::Server] the server to wrap
59
+ # @param client [PostHog::Client, nil] the PostHog client to send through. Defaults to
60
+ # `PostHog.client` when the posthog-rails facade is loaded.
61
+ # @param options [PostHog::MCP::Options, nil] prebuilt options; otherwise pass keywords
62
+ # @param kwargs [Hash] {PostHog::MCP::Options} keywords (`identify:`, `before_send:`, ...)
63
+ # @return [PostHog::MCP::Analytics] handle for custom events; a no-op handle when
64
+ # instrumentation fails (logged, never raised)
65
+ # @raise [LoadError] when the `mcp` gem is not available
66
+ def instrument(server, client = nil, options: nil, **kwargs)
67
+ opts = options.is_a?(Options) ? options : Options.new(**kwargs)
68
+ ensure_mcp_sdk!
69
+ experimental_notice!(opts)
70
+
71
+ begin
72
+ unless server.is_a?(::MCP::Server)
73
+ raise TypeError, "Unsupported server type: #{server.class}. Pass an MCP::Server."
74
+ end
75
+
76
+ existing = tracking_data(server)
77
+ if existing
78
+ Log.debug(opts, 'instrument() - server already instrumented, skipping initialization')
79
+ return Analytics.new(server)
80
+ end
81
+
82
+ resolved_client = resolve_client(client)
83
+ Log.warn(opts, 'Warning: no PostHog client available; MCP events will not be sent.') if resolved_client.nil?
84
+ sink = resolved_client ? Sink.new(resolved_client) : nil
85
+ data = TrackingData.new(options: opts, sink: sink, server_name: safe_call(server, :name),
86
+ server_version: safe_call(server, :version))
87
+ install_extensions!
88
+ server.instance_variable_set(:@__posthog_mcp, data)
89
+ register_missing_capability_tool(server, data)
90
+ Analytics.new(server)
91
+ rescue StandardError => e
92
+ Log.warn(opts, "Warning: failed to instrument server - #{e.class}: #{e.message}")
93
+ NoopAnalytics.new
94
+ end
95
+ end
96
+
97
+ # @api private
98
+ # @return [PostHog::MCP::TrackingData, nil]
99
+ def tracking_data(server)
100
+ return nil unless server.instance_variable_defined?(:@__posthog_mcp)
101
+
102
+ server.instance_variable_get(:@__posthog_mcp)
103
+ end
104
+
105
+ # Encode a session token for a custom HTTP layer's `Mcp-Session-Id` response header.
106
+ #
107
+ # @param payload [PostHog::MCP::SessionTokenPayload, Hash]
108
+ # @return [String]
109
+ def encode_session_id(payload)
110
+ SessionToken.encode(payload)
111
+ end
112
+
113
+ # Decode an `Mcp-Session-Id` value; nil for anything that is not one of our tokens.
114
+ #
115
+ # @return [PostHog::MCP::SessionTokenPayload, nil]
116
+ def decode_session_id(value)
117
+ SessionToken.decode(value)
118
+ end
119
+
120
+ # Deterministic `$session_id` for a transport session id (stable across restarts).
121
+ #
122
+ # @return [String]
123
+ def derive_session_id_from_mcp_session(mcp_session_id)
124
+ Session.derive_session_id_from_mcp_session(mcp_session_id)
125
+ end
126
+
127
+ # Deterministic `$session_id` for an agent conversation handle.
128
+ #
129
+ # @return [String]
130
+ def derive_session_id_from_conversation(conversation_id)
131
+ Session.derive_session_id_from_conversation(conversation_id)
132
+ end
133
+
134
+ # The canned `get_more_tools` result for custom dispatchers.
135
+ #
136
+ # @return [Hash]
137
+ def get_more_tools_result # rubocop:disable Naming/AccessorMethodName -- public API name
138
+ Tools.result
139
+ end
140
+
141
+ # @api private
142
+ def mcp_sdk_available?
143
+ defined?(::MCP::Server) ? true : false
144
+ end
145
+
146
+ # @api private
147
+ def experimental_notice!(options = nil)
148
+ Log.debug(options, EXPERIMENTAL_NOTICE)
149
+ return if @experimental_notice_shown
150
+
151
+ @experimental_notice_shown = true
152
+ Kernel.warn("[posthog-ruby] #{EXPERIMENTAL_NOTICE}")
153
+ end
154
+
155
+ # @api private
156
+ def reset_for_tests!
157
+ @experimental_notice_shown = false
158
+ end
159
+
160
+ private
161
+
162
+ def ensure_mcp_sdk!
163
+ return if mcp_sdk_available?
164
+
165
+ raise LoadError, "PostHog::MCP.instrument needs the MCP SDK. Add `gem 'mcp', '>= 1.4'` to your Gemfile. " \
166
+ '(PostHog::MCP::Client for custom dispatchers works without it.)'
167
+ end
168
+
169
+ def resolve_client(client)
170
+ return client if client
171
+
172
+ PostHog.respond_to?(:client) ? PostHog.client : nil
173
+ rescue StandardError
174
+ nil
175
+ end
176
+
177
+ def safe_call(object, method_name)
178
+ object.respond_to?(method_name) ? object.public_send(method_name) : nil
179
+ rescue StandardError
180
+ nil
181
+ end
182
+
183
+ # Adds the `get_more_tools` virtual tool as a real server tool. An application
184
+ # tool that already uses the name wins and is tracked as an ordinary tool.
185
+ def register_missing_capability_tool(server, data)
186
+ return unless data.options.report_missing
187
+
188
+ name = Tools.missing_capability_tool_name(data.options)
189
+ return if server.tools.is_a?(Hash) && server.tools.key?(name)
190
+
191
+ data.virtual_tool = Tools.register(server, name, data.options)
192
+ rescue StandardError => e
193
+ Log.warn(data.options, "Warning: could not register the #{name} tool - #{e.class}: #{e.message}")
194
+ end
195
+
196
+ def install_extensions!
197
+ return if @extensions_installed
198
+
199
+ ::MCP::Server.prepend(ServerExtension)
200
+ if defined?(::MCP::Server::Transports::StreamableHTTPTransport)
201
+ ::MCP::Server::Transports::StreamableHTTPTransport.prepend(TransportExtension)
202
+ end
203
+ @extensions_installed = true
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ begin
210
+ require 'mcp'
211
+ rescue LoadError
212
+ # The `mcp` gem is a peer dependency of PostHog::MCP.instrument; PostHog::MCP::Client
213
+ # (custom dispatchers) works without it. `instrument` raises a LoadError with a hint.
214
+ end
215
+
216
+ PostHog::MCP.experimental_notice!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.23.7'
4
+ VERSION = '3.24.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.23.7
4
+ version: 3.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -48,6 +48,31 @@ files:
48
48
  - lib/posthog/flag_definition_cache.rb
49
49
  - lib/posthog/internal/context.rb
50
50
  - lib/posthog/logging.rb
51
+ - lib/posthog/mcp.rb
52
+ - lib/posthog/mcp/README.md
53
+ - lib/posthog/mcp/analytics.rb
54
+ - lib/posthog/mcp/client.rb
55
+ - lib/posthog/mcp/constants.rb
56
+ - lib/posthog/mcp/conversation_id.rb
57
+ - lib/posthog/mcp/event_builder.rb
58
+ - lib/posthog/mcp/exceptions.rb
59
+ - lib/posthog/mcp/identity.rb
60
+ - lib/posthog/mcp/ids.rb
61
+ - lib/posthog/mcp/instrumentation.rb
62
+ - lib/posthog/mcp/intent.rb
63
+ - lib/posthog/mcp/log.rb
64
+ - lib/posthog/mcp/options.rb
65
+ - lib/posthog/mcp/rack_middleware.rb
66
+ - lib/posthog/mcp/request_scope.rb
67
+ - lib/posthog/mcp/sanitization.rb
68
+ - lib/posthog/mcp/schema_mutation.rb
69
+ - lib/posthog/mcp/server_extension.rb
70
+ - lib/posthog/mcp/session.rb
71
+ - lib/posthog/mcp/session_token.rb
72
+ - lib/posthog/mcp/sink.rb
73
+ - lib/posthog/mcp/tools.rb
74
+ - lib/posthog/mcp/tracking_data.rb
75
+ - lib/posthog/mcp/truncation.rb
51
76
  - lib/posthog/message_batch.rb
52
77
  - lib/posthog/noop_worker.rb
53
78
  - lib/posthog/response.rb