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.
@@ -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