posthog-ruby 3.23.8 → 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.
- checksums.yaml +4 -4
- data/lib/posthog/client.rb +7 -2
- data/lib/posthog/mcp/README.md +53 -0
- data/lib/posthog/mcp/analytics.rb +116 -0
- data/lib/posthog/mcp/client.rb +246 -0
- data/lib/posthog/mcp/constants.rb +111 -0
- data/lib/posthog/mcp/conversation_id.rb +89 -0
- data/lib/posthog/mcp/event_builder.rb +168 -0
- data/lib/posthog/mcp/exceptions.rb +112 -0
- data/lib/posthog/mcp/identity.rb +194 -0
- data/lib/posthog/mcp/ids.rb +56 -0
- data/lib/posthog/mcp/instrumentation.rb +729 -0
- data/lib/posthog/mcp/intent.rb +101 -0
- data/lib/posthog/mcp/log.rb +37 -0
- data/lib/posthog/mcp/options.rb +128 -0
- data/lib/posthog/mcp/rack_middleware.rb +112 -0
- data/lib/posthog/mcp/request_scope.rb +72 -0
- data/lib/posthog/mcp/sanitization.rb +504 -0
- data/lib/posthog/mcp/schema_mutation.rb +179 -0
- data/lib/posthog/mcp/server_extension.rb +54 -0
- data/lib/posthog/mcp/session.rb +71 -0
- data/lib/posthog/mcp/session_token.rb +106 -0
- data/lib/posthog/mcp/sink.rb +98 -0
- data/lib/posthog/mcp/tools.rb +91 -0
- data/lib/posthog/mcp/tracking_data.rb +89 -0
- data/lib/posthog/mcp/truncation.rb +326 -0
- data/lib/posthog/mcp.rb +216 -0
- data/lib/posthog/version.rb +1 -1
- metadata +26 -1
|
@@ -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
|
data/lib/posthog/mcp.rb
ADDED
|
@@ -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!
|
data/lib/posthog/version.rb
CHANGED
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.
|
|
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
|