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 +4 -4
- data/lib/posthog/client.rb +28 -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 +27 -2
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PostHog
|
|
4
|
+
module MCP
|
|
5
|
+
# One JSON-RPC request's analytics lifecycle. Created by {ServerExtension}
|
|
6
|
+
# around the dispatch lambda `MCP::Server#handle_request` returns, so it
|
|
7
|
+
# sees the raw request, the params Hash the handler will receive (and may
|
|
8
|
+
# strip injected arguments from it), the session, the result, and any
|
|
9
|
+
# raised error. Everything it needs travels through its own instance
|
|
10
|
+
# variables; nothing is read from the gem's `@instrumentation_data`.
|
|
11
|
+
#
|
|
12
|
+
# Analytics failures are logged and swallowed: the handler's result or
|
|
13
|
+
# exception is always returned or re-raised unchanged.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class Instrumentation
|
|
17
|
+
TRACKED_METHODS = {
|
|
18
|
+
'initialize' => :initialize,
|
|
19
|
+
'tools/list' => :tools_list,
|
|
20
|
+
'tools/call' => :tools_call,
|
|
21
|
+
'prompts/get' => :prompts_get,
|
|
22
|
+
'prompts/list' => :prompts_list,
|
|
23
|
+
'resources/read' => :resources_read,
|
|
24
|
+
'resources/list' => :resources_list
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
GENERIC_EVENT_TYPES = {
|
|
28
|
+
prompts_get: EventType::MCP_PROMPTS_GET,
|
|
29
|
+
prompts_list: EventType::MCP_PROMPTS_LIST,
|
|
30
|
+
resources_read: EventType::MCP_RESOURCES_READ,
|
|
31
|
+
resources_list: EventType::MCP_RESOURCES_LIST
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
MODERN_PROTOCOL_REVISION = '2026-07-28'
|
|
35
|
+
REVISION_SHAPE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
36
|
+
DRAFT_REVISION = 'draft'
|
|
37
|
+
META_CLIENT_INFO_KEY = 'io.modelcontextprotocol/clientInfo'
|
|
38
|
+
META_PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion'
|
|
39
|
+
INJECTED_PARAMS = ['context', ConversationId::PARAM_NAME, ModelCapture::PARAM_NAME].freeze
|
|
40
|
+
|
|
41
|
+
# Passed as `actor:` by a caller that has no request identity to offer, and
|
|
42
|
+
# is distinct from an explicit `nil` (a request that resolved to nobody).
|
|
43
|
+
UNRESOLVED_ACTOR = Object.new.freeze
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def tracked?(method)
|
|
47
|
+
TRACKED_METHODS.key?(method)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Enrich an event with session/identity/server metadata and hand it to
|
|
51
|
+
# the sink.
|
|
52
|
+
#
|
|
53
|
+
# @param actor [UserIdentity, nil, Object] the identity resolved for the
|
|
54
|
+
# request this event belongs to, pinned when the request started. Only
|
|
55
|
+
# {UNRESOLVED_ACTOR} falls back to the session-keyed cache, which a
|
|
56
|
+
# concurrent request on the same session may have moved on since.
|
|
57
|
+
def capture_event(data, input, actor: UNRESOLVED_ACTOR)
|
|
58
|
+
sink = data.sink
|
|
59
|
+
return nil if sink.nil?
|
|
60
|
+
|
|
61
|
+
session_id = input['session_id'] || data.session_id
|
|
62
|
+
actor = (session_id ? data.identified_sessions.get(session_id) : nil) if actor.equal?(UNRESOLVED_ACTOR)
|
|
63
|
+
timestamp = input['timestamp'] || Time.now.utc
|
|
64
|
+
duration = input['duration']
|
|
65
|
+
duration = (Time.now - timestamp) * 1000.0 if duration.nil? && input['timestamp']
|
|
66
|
+
|
|
67
|
+
full = input.merge(
|
|
68
|
+
'session_id' => session_id,
|
|
69
|
+
'event_type' => input['event_type'] || EventType::CUSTOM,
|
|
70
|
+
'timestamp' => timestamp,
|
|
71
|
+
'duration' => duration,
|
|
72
|
+
'server_name' => data.server_name,
|
|
73
|
+
'server_version' => data.server_version,
|
|
74
|
+
'identify_actor_given_id' => actor&.distinct_id,
|
|
75
|
+
'identify_actor_data' => actor ? (actor.properties || {}) : {},
|
|
76
|
+
'groups' => actor&.groups
|
|
77
|
+
)
|
|
78
|
+
sink.capture(full, data.options)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def monotonic_now
|
|
82
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def legacy_era?(protocol_version)
|
|
86
|
+
return true unless protocol_version.is_a?(String) && !protocol_version.empty?
|
|
87
|
+
return false if protocol_version == DRAFT_REVISION
|
|
88
|
+
|
|
89
|
+
!(REVISION_SHAPE.match?(protocol_version) && protocol_version >= MODERN_PROTOCOL_REVISION)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initialize(server, data, method:, request:, params:, session: nil, request_id: nil)
|
|
94
|
+
@server = server
|
|
95
|
+
@data = data
|
|
96
|
+
@options = data.options
|
|
97
|
+
@kind = TRACKED_METHODS.fetch(method)
|
|
98
|
+
@method = method
|
|
99
|
+
@request = request.is_a?(Hash) ? request : {}
|
|
100
|
+
@params = params.is_a?(Hash) ? params : {}
|
|
101
|
+
@session = session
|
|
102
|
+
@request_id = request_id
|
|
103
|
+
@scope = RequestScope.current
|
|
104
|
+
@headers = @scope ? (@scope[:headers] || {}) : {}
|
|
105
|
+
@token = SessionToken.decode(header_session_id)
|
|
106
|
+
@data.http_transport_seen = true if http?
|
|
107
|
+
@identified_in_request = {}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Runs the wrapped handler and records the request.
|
|
111
|
+
def dispatch(&handler)
|
|
112
|
+
@start = self.class.monotonic_now
|
|
113
|
+
return dispatch_kind(&handler) if @scope
|
|
114
|
+
|
|
115
|
+
# Only the Streamable HTTP transport publishes a scope. Opening one for
|
|
116
|
+
# every other transport too (stdio, a custom dispatcher) means the session
|
|
117
|
+
# settled before the tool body runs is the one {Analytics#capture} reads
|
|
118
|
+
# inside it, whatever the request is anchored on.
|
|
119
|
+
RequestScope.with(headers: {}, transport: :other) do |scope|
|
|
120
|
+
@scope = scope
|
|
121
|
+
dispatch_kind(&handler)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def dispatch_kind(&handler)
|
|
128
|
+
case @kind
|
|
129
|
+
when :tools_call then dispatch_tool_call(&handler)
|
|
130
|
+
when :tools_list then dispatch_tools_list(&handler)
|
|
131
|
+
when :initialize then dispatch_initialize(&handler)
|
|
132
|
+
else dispatch_generic(&handler)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- dispatchers -------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def dispatch_tool_call
|
|
139
|
+
name = fetch(@params, :name)
|
|
140
|
+
arguments = fetch(@params, :arguments)
|
|
141
|
+
arguments = nil unless arguments.is_a?(Hash)
|
|
142
|
+
original_arguments = arguments&.dup
|
|
143
|
+
missing_name = Tools.missing_capability_tool_name(@options)
|
|
144
|
+
owned = safely([]) { owned_params_for(name) }
|
|
145
|
+
stripped = safely({}) { strip_injected_arguments(arguments, owned) }
|
|
146
|
+
# Resolution reads the stripped values, never `original_arguments`: only an
|
|
147
|
+
# argument this layer injected is ours to interpret. A `conversation_id` or
|
|
148
|
+
# `context` the tool declares itself stays application data and is left to it.
|
|
149
|
+
conversation_id, minted = safely([nil, false]) do
|
|
150
|
+
ConversationId.resolve(@options.enable_conversation_id, stripped[ConversationId::PARAM_NAME], name,
|
|
151
|
+
missing_name)
|
|
152
|
+
end
|
|
153
|
+
request = request_with_arguments(original_arguments)
|
|
154
|
+
|
|
155
|
+
if virtual_tool?(name)
|
|
156
|
+
# Run the gem's own handler so validation, in-flight tracking and
|
|
157
|
+
# cancellation behave exactly as for any other tool; only the event differs.
|
|
158
|
+
begin
|
|
159
|
+
result = yield
|
|
160
|
+
ensure
|
|
161
|
+
safely { record_missing_capability(name, original_arguments, request) }
|
|
162
|
+
end
|
|
163
|
+
return result
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
safely { prime_session(request, minted ? nil : conversation_id) }
|
|
167
|
+
begin
|
|
168
|
+
result = yield
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
safely do
|
|
171
|
+
cid = minted ? nil : conversation_id
|
|
172
|
+
session_id = prepare_request(request, conversation_id: cid)
|
|
173
|
+
record_tool_call(session_id, name, request, error: e, conversation_id: cid, stripped: stripped)
|
|
174
|
+
end
|
|
175
|
+
raise
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
delivered = false
|
|
179
|
+
# The handle appended below is unique per conversation, so an error read
|
|
180
|
+
# off the delivered result would give every identical failure a different
|
|
181
|
+
# `$mcp_error_message`. Grouping reads the result the tool returned.
|
|
182
|
+
error_source = result
|
|
183
|
+
unless no_response?(result) || conversation_id.nil?
|
|
184
|
+
safely do
|
|
185
|
+
if @data.tool_output_instructions[name]
|
|
186
|
+
result, delivered = ConversationId.mirror_instructions(result, conversation_id)
|
|
187
|
+
end
|
|
188
|
+
if minted
|
|
189
|
+
with_prompt_back = ConversationId.inject_prompt_back(result, conversation_id)
|
|
190
|
+
delivered ||= !with_prompt_back.equal?(result)
|
|
191
|
+
result = with_prompt_back
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
safely do
|
|
197
|
+
cid = minted && !delivered ? nil : conversation_id
|
|
198
|
+
session_id = prepare_request(request, conversation_id: cid)
|
|
199
|
+
record_tool_call(session_id, name, request, result: result, conversation_id: cid,
|
|
200
|
+
stripped: stripped, error_source: error_source)
|
|
201
|
+
end
|
|
202
|
+
result
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def dispatch_tools_list
|
|
206
|
+
result = begin
|
|
207
|
+
yield
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
safely { record_tools_list(prepare_request(@request), names: [], error: e) }
|
|
210
|
+
raise
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
return result if no_response?(result)
|
|
214
|
+
|
|
215
|
+
names = []
|
|
216
|
+
empty = false
|
|
217
|
+
safely do
|
|
218
|
+
tools = fetch(result, :tools)
|
|
219
|
+
if tools.is_a?(Array)
|
|
220
|
+
names = tools.map { |tool| fetch(tool, :name) }.compact
|
|
221
|
+
empty = tools.empty?
|
|
222
|
+
mutated = tools.map { |tool| mutate_tool(tool) }
|
|
223
|
+
result = result.merge(SchemaMutation.key_for(result, :tools) => mutated)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
safely do
|
|
228
|
+
session_id = prepare_request(@request)
|
|
229
|
+
record_tools_list(session_id, names: names, response: result, empty: empty)
|
|
230
|
+
end
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def dispatch_initialize
|
|
235
|
+
client_info = fetch(@params, :clientInfo)
|
|
236
|
+
client_name = client_info.is_a?(Hash) ? fetch(client_info, :name) : nil
|
|
237
|
+
client_version = client_info.is_a?(Hash) ? fetch(client_info, :version) : nil
|
|
238
|
+
requested_version = fetch(@params, :protocolVersion)
|
|
239
|
+
|
|
240
|
+
result = begin
|
|
241
|
+
yield
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
safely do
|
|
244
|
+
session_id = prepare_request(@request, skip_initialize: true)
|
|
245
|
+
record_initialize(session_id, client_name, client_version, requested_version, error: e)
|
|
246
|
+
end
|
|
247
|
+
raise
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
safely do
|
|
251
|
+
negotiated = (result.is_a?(Hash) ? fetch(result, :protocolVersion) : nil) || requested_version
|
|
252
|
+
minted = mint_session_token(client_name, client_version, negotiated, requested_version)
|
|
253
|
+
session_id = prepare_request(@request, skip_initialize: true, token: minted)
|
|
254
|
+
record_initialize(session_id, client_name, client_version, negotiated, response: result)
|
|
255
|
+
end
|
|
256
|
+
result
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def dispatch_generic
|
|
260
|
+
result = begin
|
|
261
|
+
yield
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
safely { record_generic(prepare_request(@request), error: e) }
|
|
264
|
+
raise
|
|
265
|
+
end
|
|
266
|
+
safely { record_generic(prepare_request(@request), result: result) }
|
|
267
|
+
result
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- recording ---------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def record_tool_call(session_id, name, request, result: nil, error: nil, conversation_id: nil, stripped: {},
|
|
273
|
+
error_source: nil)
|
|
274
|
+
event = base_event(EventType::MCP_TOOLS_CALL, session_id, request)
|
|
275
|
+
event['resource_name'] = name
|
|
276
|
+
event['tool_description'] = @data.tool_descriptions[name]
|
|
277
|
+
event['tool_category'] = @data.tool_categories[name]
|
|
278
|
+
event['parameters'] = Sanitization.build_captured_mcp_parameters(request)
|
|
279
|
+
event['conversation_id'] = conversation_id
|
|
280
|
+
event['is_error'] = false
|
|
281
|
+
|
|
282
|
+
intent = Intent.resolve(@data, request, extra, stripped['context'])
|
|
283
|
+
if intent
|
|
284
|
+
event['user_intent'] = intent[0]
|
|
285
|
+
event['user_intent_source'] = intent[1]
|
|
286
|
+
end
|
|
287
|
+
if @options.capture_model_enabled?
|
|
288
|
+
model = ModelCapture.resolve(request, stripped[ModelCapture::PARAM_NAME])
|
|
289
|
+
if model
|
|
290
|
+
event['llm_model'] = model[0]
|
|
291
|
+
event['llm_model_source'] = model[1]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
if error
|
|
296
|
+
event['is_error'] = true
|
|
297
|
+
event['error'] = Exceptions.capture_exception(error)
|
|
298
|
+
elsif !result.nil? && !no_response?(result)
|
|
299
|
+
event['response'] = result
|
|
300
|
+
source = error_source.nil? ? result : error_source
|
|
301
|
+
if tool_result_error?(source)
|
|
302
|
+
event['is_error'] = true
|
|
303
|
+
event['error'] = Exceptions.capture_exception(Sanitization.stringify_keys(source))
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
finish_event(event, request)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def record_missing_capability(name, arguments, request)
|
|
311
|
+
session_id = prepare_request(request)
|
|
312
|
+
event = base_event(EventType::MCP_MISSING_CAPABILITY, session_id, request)
|
|
313
|
+
event.delete('duration')
|
|
314
|
+
event['resource_name'] = name
|
|
315
|
+
event['parameters'] = Sanitization.build_captured_mcp_parameters(request)
|
|
316
|
+
context = arguments.is_a?(Hash) ? (arguments[:context] || arguments['context']) : nil
|
|
317
|
+
if context.is_a?(String) && !context.strip.empty?
|
|
318
|
+
event['user_intent'] = context.strip
|
|
319
|
+
event['user_intent_source'] = 'context_parameter'
|
|
320
|
+
end
|
|
321
|
+
if @options.capture_model_enabled?
|
|
322
|
+
model = ModelCapture.resolve(request, self_reported_model(arguments))
|
|
323
|
+
if model
|
|
324
|
+
event['llm_model'] = model[0]
|
|
325
|
+
event['llm_model_source'] = model[1]
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
finish_event(event, request)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The virtual tool declares `llm_model` itself, so the argument is never
|
|
332
|
+
# stripped and is read straight off the call.
|
|
333
|
+
def self_reported_model(arguments)
|
|
334
|
+
return nil unless arguments.is_a?(Hash)
|
|
335
|
+
|
|
336
|
+
arguments[ModelCapture::PARAM_NAME] || arguments[ModelCapture::PARAM_NAME.to_sym]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def record_tools_list(session_id, names:, response: nil, empty: false, error: nil)
|
|
340
|
+
event = base_event(EventType::MCP_TOOLS_LIST, session_id, @request)
|
|
341
|
+
event['listed_tool_names'] = names
|
|
342
|
+
event['parameters'] = Sanitization.build_captured_mcp_parameters(@request)
|
|
343
|
+
event['response'] = response unless response.nil? || no_response?(response)
|
|
344
|
+
event['is_error'] = !error.nil? || empty
|
|
345
|
+
event['timestamp'] = Time.now.utc
|
|
346
|
+
if error
|
|
347
|
+
event['error'] = Exceptions.capture_exception(error)
|
|
348
|
+
elsif empty
|
|
349
|
+
event['error'] = Exceptions.capture_exception('tools/list returned no tools')
|
|
350
|
+
end
|
|
351
|
+
finish_event(event, @request)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def record_initialize(session_id, client_name, client_version, protocol_version, response: nil, error: nil)
|
|
355
|
+
@data.mark_session_initialized(session_id)
|
|
356
|
+
event = base_event(EventType::MCP_INITIALIZE, session_id, @request)
|
|
357
|
+
event['client_name'] = client_name
|
|
358
|
+
event['client_version'] = client_version
|
|
359
|
+
event['protocol_version'] = protocol_version
|
|
360
|
+
event['parameters'] = Sanitization.build_captured_mcp_parameters(@request)
|
|
361
|
+
event['response'] = response unless response.nil? || no_response?(response)
|
|
362
|
+
if error
|
|
363
|
+
event['is_error'] = true
|
|
364
|
+
event['error'] = Exceptions.capture_exception(error)
|
|
365
|
+
end
|
|
366
|
+
finish_event(event, @request)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def record_generic(session_id, result: nil, error: nil)
|
|
370
|
+
event = base_event(GENERIC_EVENT_TYPES.fetch(@kind), session_id, @request)
|
|
371
|
+
event['resource_name'] = generic_resource_name
|
|
372
|
+
event['parameters'] = Sanitization.build_captured_mcp_parameters(@request)
|
|
373
|
+
event['response'] = result unless result.nil? || no_response?(result)
|
|
374
|
+
event['is_error'] = !error.nil?
|
|
375
|
+
event['error'] = Exceptions.capture_exception(error) if error
|
|
376
|
+
finish_event(event, @request)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def base_event(event_type, session_id, request)
|
|
380
|
+
event = {
|
|
381
|
+
'event_type' => event_type,
|
|
382
|
+
'session_id' => session_id,
|
|
383
|
+
'duration' => duration_ms,
|
|
384
|
+
'client_name' => nil,
|
|
385
|
+
'client_version' => nil,
|
|
386
|
+
'protocol_version' => protocol_version
|
|
387
|
+
}
|
|
388
|
+
name, version = client_identity(request)
|
|
389
|
+
event['client_name'] = name
|
|
390
|
+
event['client_version'] = version
|
|
391
|
+
event
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def finish_event(event, request)
|
|
395
|
+
props = resolve_event_properties(request)
|
|
396
|
+
event['properties'] = props unless props.nil?
|
|
397
|
+
TransportIdentity.stamp(event, @headers)
|
|
398
|
+
self.class.capture_event(@data, event, actor: actor_for(event['session_id']))
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# The identity this request resolved for the session the event is filed
|
|
402
|
+
# under. {#prepare_request} always runs first for that session, so the key
|
|
403
|
+
# is present; a missing one means nobody identified and stays nil.
|
|
404
|
+
def actor_for(session_id)
|
|
405
|
+
@identified_in_request[session_id]
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def resolve_event_properties(request)
|
|
409
|
+
callback = @options.event_properties
|
|
410
|
+
return nil unless callback
|
|
411
|
+
|
|
412
|
+
result = Callbacks.call(callback, request, extra)
|
|
413
|
+
result.is_a?(Hash) && !result.empty? ? result : nil
|
|
414
|
+
rescue StandardError => e
|
|
415
|
+
Log.debug(@options, "event_properties callback error: #{e.message}")
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# --- session / identity -----------------------------------------------
|
|
420
|
+
|
|
421
|
+
# Settle session and identity before the tool body runs, and pin the session
|
|
422
|
+
# to the request scope. {Analytics#capture} reads both from there, so a
|
|
423
|
+
# custom event emitted inside a tool belongs to its caller rather than to
|
|
424
|
+
# whichever request finished last (or is running concurrently), and carries
|
|
425
|
+
# the same identified person as the `$mcp_tool_call` that follows it.
|
|
426
|
+
#
|
|
427
|
+
# `conversation_id` is passed when the agent echoed one back: that anchor is
|
|
428
|
+
# already known, so priming resolves the same session the tool call will be
|
|
429
|
+
# recorded under. A minted handle is not known to have reached the agent
|
|
430
|
+
# until the call returns, so it stays out of here. {#prepare_request} runs
|
|
431
|
+
# again after the call; the second run is idempotent.
|
|
432
|
+
def prime_session(request, conversation_id)
|
|
433
|
+
session_id = prepare_request(request, conversation_id: conversation_id)
|
|
434
|
+
@scope[:session_id] = session_id if @scope.is_a?(Hash)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Resolve the session id, run identify, then lazily emit initialize.
|
|
438
|
+
def prepare_request(request, conversation_id: nil, skip_initialize: false, token: nil)
|
|
439
|
+
token ||= @token
|
|
440
|
+
session_id, source = Session.resolve(@data, mcp_session_id(token), token: token,
|
|
441
|
+
conversation_id: conversation_id)
|
|
442
|
+
warn_stateless_session_not_wired if source == 'generated' && http?
|
|
443
|
+
|
|
444
|
+
# A tool call prepares twice: once before the body to pin the session, and
|
|
445
|
+
# once after it, when the conversation anchor is known. A customer's
|
|
446
|
+
# `identify` callback runs once per session per request, so preparing
|
|
447
|
+
# twice never asks it the same question twice.
|
|
448
|
+
unless @identified_in_request.key?(session_id)
|
|
449
|
+
identify_event, actor = Identity.identify_for_request(@data, session_id, request, extra)
|
|
450
|
+
# Pin what this request resolved. Every event it emits afterwards -
|
|
451
|
+
# including one a tool body captures through {Analytics} - is attributed
|
|
452
|
+
# to this, never to a re-read of the cache another request may have
|
|
453
|
+
# overwritten while the handler ran.
|
|
454
|
+
@identified_in_request[session_id] = actor
|
|
455
|
+
@scope[:actor] = actor if @scope.is_a?(Hash)
|
|
456
|
+
self.class.capture_event(@data, identify_event, actor: actor) if identify_event
|
|
457
|
+
end
|
|
458
|
+
maybe_emit_initialize(session_id, request) unless skip_initialize
|
|
459
|
+
session_id
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def maybe_emit_initialize(session_id, request)
|
|
463
|
+
# Claiming is atomic: two threads opening the same session concurrently
|
|
464
|
+
# must not both get past the check and emit an initialize each.
|
|
465
|
+
return unless @data.claim_session_initialized(session_id)
|
|
466
|
+
|
|
467
|
+
name, version = client_identity(request)
|
|
468
|
+
event = {
|
|
469
|
+
'event_type' => EventType::MCP_INITIALIZE,
|
|
470
|
+
'session_id' => session_id,
|
|
471
|
+
'client_name' => name,
|
|
472
|
+
'client_version' => version,
|
|
473
|
+
'protocol_version' => protocol_version,
|
|
474
|
+
'timestamp' => Time.now.utc
|
|
475
|
+
}
|
|
476
|
+
props = resolve_event_properties({ method: 'initialize', params: {} })
|
|
477
|
+
event['properties'] = props unless props.nil?
|
|
478
|
+
TransportIdentity.stamp(event, @headers)
|
|
479
|
+
self.class.capture_event(@data, event, actor: actor_for(session_id))
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Era is decided by the version the client *asked for*:
|
|
483
|
+
# a client declaring the 2026-07-28 revision or later must not be answered
|
|
484
|
+
# with an `Mcp-Session-Id`, even though this gem counter-offers a legacy version.
|
|
485
|
+
def mint_session_token(client_name, client_version, protocol_version, requested_version)
|
|
486
|
+
return nil unless http? && @scope
|
|
487
|
+
return nil if header_session_id || @session&.session_id
|
|
488
|
+
return nil unless self.class.legacy_era?(requested_version)
|
|
489
|
+
|
|
490
|
+
payload = SessionTokenPayload.new(
|
|
491
|
+
session_id: Session.new_session_id,
|
|
492
|
+
client_name: client_name.is_a?(String) ? client_name : nil,
|
|
493
|
+
client_version: client_version.is_a?(String) ? client_version : nil,
|
|
494
|
+
protocol_version: protocol_version.is_a?(String) ? protocol_version : nil
|
|
495
|
+
)
|
|
496
|
+
@scope[:mint] = SessionToken.encode(payload)
|
|
497
|
+
payload
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def warn_stateless_session_not_wired
|
|
501
|
+
return if @data.warned_no_stateless_session
|
|
502
|
+
|
|
503
|
+
@data.warned_no_stateless_session = true
|
|
504
|
+
Log.warn(
|
|
505
|
+
@options,
|
|
506
|
+
'Warning: an MCP request arrived over streamable HTTP with no session id, so PostHog generated a ' \
|
|
507
|
+
'per-process $session_id that will fragment across requests and pods. In stateless mode the ' \
|
|
508
|
+
'client must replay the Mcp-Session-Id header PostHog::MCP mints at initialize; for a custom Rack ' \
|
|
509
|
+
'stack add PostHog::MCP::RackMiddleware. Enabling conversation ids ' \
|
|
510
|
+
'(PostHog::MCP.instrument(server, enable_conversation_id: true)) also anchors the session without ' \
|
|
511
|
+
'any middleware. See https://posthog.com/docs/mcp-analytics/installation#ruby.'
|
|
512
|
+
)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# --- request context ---------------------------------------------------
|
|
516
|
+
|
|
517
|
+
def header_session_id
|
|
518
|
+
SessionToken.read_header(@headers)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# The transport's own session id (never our token).
|
|
522
|
+
def mcp_session_id(token)
|
|
523
|
+
transport_id = @session.respond_to?(:session_id) ? @session.session_id : nil
|
|
524
|
+
return transport_id if transport_id.is_a?(String) && !transport_id.empty?
|
|
525
|
+
|
|
526
|
+
token ? nil : header_session_id
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def http?
|
|
530
|
+
@scope.is_a?(Hash) && @scope[:transport] == :http
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def envelope_meta
|
|
534
|
+
meta = fetch(@params, :_meta)
|
|
535
|
+
meta.is_a?(Hash) ? meta : nil
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def client_identity(request)
|
|
539
|
+
info = envelope_client_info || session_client || server_client
|
|
540
|
+
info = fetch(request_params(request), :clientInfo) if info.nil? && @kind == :initialize
|
|
541
|
+
name = info.is_a?(Hash) ? fetch(info, :name) : nil
|
|
542
|
+
version = info.is_a?(Hash) ? fetch(info, :version) : nil
|
|
543
|
+
name ||= @token&.client_name
|
|
544
|
+
version ||= @token&.client_version
|
|
545
|
+
[name, version]
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def envelope_client_info
|
|
549
|
+
meta = envelope_meta
|
|
550
|
+
return nil unless meta
|
|
551
|
+
|
|
552
|
+
meta[META_CLIENT_INFO_KEY] || meta[META_CLIENT_INFO_KEY.to_sym]
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def session_client
|
|
556
|
+
@session.respond_to?(:client) ? @session.client : nil
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def server_client
|
|
560
|
+
@server.instance_variable_defined?(:@client) ? @server.instance_variable_get(:@client) : nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def protocol_version
|
|
564
|
+
meta = envelope_meta
|
|
565
|
+
from_meta = meta ? (meta[META_PROTOCOL_VERSION_KEY] || meta[META_PROTOCOL_VERSION_KEY.to_sym]) : nil
|
|
566
|
+
return from_meta if from_meta.is_a?(String)
|
|
567
|
+
|
|
568
|
+
from_session = @session.respond_to?(:protocol_version) ? @session.protocol_version : nil
|
|
569
|
+
return from_session if from_session.is_a?(String)
|
|
570
|
+
|
|
571
|
+
if @server.instance_variable_defined?(:@client_protocol_version)
|
|
572
|
+
from_server = @server.instance_variable_get(:@client_protocol_version)
|
|
573
|
+
return from_server if from_server.is_a?(String)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
@headers['mcp-protocol-version'] || @token&.protocol_version
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def extra
|
|
580
|
+
@extra ||= {
|
|
581
|
+
'session_id' => (@session.respond_to?(:session_id) ? @session.session_id : nil) || header_session_id,
|
|
582
|
+
'request_id' => @request_id,
|
|
583
|
+
'protocol_version' => protocol_version,
|
|
584
|
+
'headers' => @headers,
|
|
585
|
+
'session' => @session
|
|
586
|
+
}
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def request_params(request)
|
|
590
|
+
params = request.is_a?(Hash) ? (request[:params] || request['params']) : nil
|
|
591
|
+
params.is_a?(Hash) ? params : {}
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def request_with_arguments(arguments)
|
|
595
|
+
params = @params.merge(SchemaMutation.key_for(@params, :arguments) => arguments)
|
|
596
|
+
@request.merge(SchemaMutation.key_for(@request, :params) => params)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def generic_resource_name
|
|
600
|
+
case @kind
|
|
601
|
+
when :prompts_get then fetch(@params, :name)
|
|
602
|
+
when :resources_read then fetch(@params, :uri)
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def duration_ms
|
|
607
|
+
(self.class.monotonic_now - @start) * 1000.0
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# --- tools -------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
# True only for the `get_more_tools` class {Tools.register} added; an
|
|
613
|
+
# application tool that shares the name is an ordinary tool.
|
|
614
|
+
def virtual_tool?(name)
|
|
615
|
+
return false if @data.virtual_tool.nil?
|
|
616
|
+
|
|
617
|
+
tools = @server.respond_to?(:tools) ? @server.tools : nil
|
|
618
|
+
tools.is_a?(Hash) && tools[name].equal?(@data.virtual_tool)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Injected argument names the analytics layer owns for this tool: the ones
|
|
622
|
+
# it injected at tools/list, or (never listed) the ones the tool's own
|
|
623
|
+
# schema does not declare. A composed or referenced schema is never
|
|
624
|
+
# injected into, so nothing in it is ours to strip either - the same guard
|
|
625
|
+
# {SchemaMutation.add_parameter} uses, so a call before the first
|
|
626
|
+
# tools/list behaves exactly like one after it.
|
|
627
|
+
def owned_params_for(name)
|
|
628
|
+
cached = @data.tool_owned_params[name]
|
|
629
|
+
return cached if cached
|
|
630
|
+
|
|
631
|
+
tools = @server.respond_to?(:tools) ? @server.tools : nil
|
|
632
|
+
tool = tools.is_a?(Hash) ? tools[name] : nil
|
|
633
|
+
schema = tool.respond_to?(:input_schema) ? tool.input_schema&.to_h : nil
|
|
634
|
+
return [] unless SchemaMutation.injectable?(schema)
|
|
635
|
+
|
|
636
|
+
owned = []
|
|
637
|
+
owned << 'context' if @options.context_enabled? && !SchemaMutation.declares_param?(schema, 'context')
|
|
638
|
+
owned << ConversationId::PARAM_NAME if @options.enable_conversation_id &&
|
|
639
|
+
!SchemaMutation.declares_param?(schema, ConversationId::PARAM_NAME)
|
|
640
|
+
owned << ModelCapture::PARAM_NAME if @options.capture_model_enabled? &&
|
|
641
|
+
!SchemaMutation.declares_param?(schema, ModelCapture::PARAM_NAME)
|
|
642
|
+
owned
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# Remove SDK-owned arguments in place before the tool receives them as
|
|
646
|
+
# keywords (an unknown keyword would raise). Returns the stripped values.
|
|
647
|
+
def strip_injected_arguments(arguments, owned)
|
|
648
|
+
stripped = {}
|
|
649
|
+
return stripped unless arguments.is_a?(Hash)
|
|
650
|
+
|
|
651
|
+
owned.each do |param|
|
|
652
|
+
[param.to_sym, param].each do |key|
|
|
653
|
+
next unless arguments.key?(key)
|
|
654
|
+
|
|
655
|
+
value = arguments.delete(key)
|
|
656
|
+
stripped[param] = value if stripped[param].nil?
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
stripped
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def mutate_tool(tool)
|
|
663
|
+
return tool unless tool.is_a?(Hash)
|
|
664
|
+
|
|
665
|
+
name = fetch(tool, :name)
|
|
666
|
+
return tool if virtual_tool?(name)
|
|
667
|
+
|
|
668
|
+
schema = fetch(tool, :inputSchema)
|
|
669
|
+
owned = []
|
|
670
|
+
if @options.context_enabled?
|
|
671
|
+
updated = SchemaMutation.add_context_parameter(
|
|
672
|
+
schema, tool_name: name, description: @options.context_description, options: @options
|
|
673
|
+
)
|
|
674
|
+
owned << 'context' unless updated.equal?(schema)
|
|
675
|
+
schema = updated
|
|
676
|
+
end
|
|
677
|
+
if @options.enable_conversation_id
|
|
678
|
+
updated = SchemaMutation.add_conversation_id_parameter(schema, tool_name: name, options: @options)
|
|
679
|
+
owned << ConversationId::PARAM_NAME unless updated.equal?(schema)
|
|
680
|
+
schema = updated
|
|
681
|
+
end
|
|
682
|
+
if @options.capture_model_enabled?
|
|
683
|
+
updated = SchemaMutation.add_model_parameter(
|
|
684
|
+
schema, tool_name: name, description: @options.model_description, options: @options
|
|
685
|
+
)
|
|
686
|
+
owned << ModelCapture::PARAM_NAME unless updated.equal?(schema)
|
|
687
|
+
schema = updated
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
mutated = tool.merge(SchemaMutation.key_for(tool, :inputSchema) => schema)
|
|
691
|
+
declared = nil
|
|
692
|
+
if @options.enable_conversation_id
|
|
693
|
+
output_schema = fetch(tool, :outputSchema)
|
|
694
|
+
new_output, declared = SchemaMutation.add_output_instructions(output_schema, tool_name: name,
|
|
695
|
+
options: @options)
|
|
696
|
+
mutated = mutated.merge(SchemaMutation.key_for(tool, :outputSchema) => new_output) if declared && new_output
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
meta = fetch(tool, :_meta)
|
|
700
|
+
category = meta.is_a?(Hash) ? (meta[:category] || meta['category']) : nil
|
|
701
|
+
@data.remember_tool(name, description: fetch(tool, :description), category: category, owned_params: owned,
|
|
702
|
+
output_instructions: declared)
|
|
703
|
+
mutated
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# --- helpers -----------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
def fetch(hash, key)
|
|
709
|
+
SchemaMutation.fetch(hash, key)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def tool_result_error?(result)
|
|
713
|
+
result.is_a?(Hash) && (result[:isError] == true || result['isError'] == true)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def no_response?(result)
|
|
717
|
+
defined?(::JsonRpcHandler::NO_RESPONSE) && result.equal?(::JsonRpcHandler::NO_RESPONSE)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def safely(fallback = nil)
|
|
721
|
+
yield
|
|
722
|
+
rescue StandardError => e
|
|
723
|
+
Log.debug(@options,
|
|
724
|
+
"PostHog MCP analytics step failed (event dropped, request unaffected): #{e.class}: #{e.message}")
|
|
725
|
+
fallback
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
end
|