txlemetry-ruby 4.0.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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +48 -0
  3. data/NOTICE +5 -0
  4. data/bin/txlemetry +110 -0
  5. data/lib/txlemetry/backoff_policy.rb +49 -0
  6. data/lib/txlemetry/client.rb +1266 -0
  7. data/lib/txlemetry/defaults.rb +50 -0
  8. data/lib/txlemetry/exception_capture.rb +246 -0
  9. data/lib/txlemetry/feature_flag.rb +85 -0
  10. data/lib/txlemetry/feature_flag_error.rb +37 -0
  11. data/lib/txlemetry/feature_flag_evaluations.rb +237 -0
  12. data/lib/txlemetry/feature_flag_result.rb +81 -0
  13. data/lib/txlemetry/feature_flags.rb +1516 -0
  14. data/lib/txlemetry/field_parser.rb +219 -0
  15. data/lib/txlemetry/flag_definition_cache.rb +88 -0
  16. data/lib/txlemetry/internal/context.rb +119 -0
  17. data/lib/txlemetry/logging.rb +77 -0
  18. data/lib/txlemetry/mcp/README.md +53 -0
  19. data/lib/txlemetry/mcp/analytics.rb +116 -0
  20. data/lib/txlemetry/mcp/client.rb +257 -0
  21. data/lib/txlemetry/mcp/constants.rb +111 -0
  22. data/lib/txlemetry/mcp/conversation_id.rb +89 -0
  23. data/lib/txlemetry/mcp/event_builder.rb +168 -0
  24. data/lib/txlemetry/mcp/exceptions.rb +112 -0
  25. data/lib/txlemetry/mcp/identity.rb +194 -0
  26. data/lib/txlemetry/mcp/ids.rb +56 -0
  27. data/lib/txlemetry/mcp/instrumentation.rb +736 -0
  28. data/lib/txlemetry/mcp/intent.rb +101 -0
  29. data/lib/txlemetry/mcp/log.rb +37 -0
  30. data/lib/txlemetry/mcp/options.rb +128 -0
  31. data/lib/txlemetry/mcp/rack_middleware.rb +112 -0
  32. data/lib/txlemetry/mcp/request_scope.rb +72 -0
  33. data/lib/txlemetry/mcp/sanitization.rb +504 -0
  34. data/lib/txlemetry/mcp/schema_mutation.rb +179 -0
  35. data/lib/txlemetry/mcp/server_extension.rb +54 -0
  36. data/lib/txlemetry/mcp/session.rb +71 -0
  37. data/lib/txlemetry/mcp/session_token.rb +106 -0
  38. data/lib/txlemetry/mcp/sink.rb +98 -0
  39. data/lib/txlemetry/mcp/tools.rb +91 -0
  40. data/lib/txlemetry/mcp/tracking_data.rb +89 -0
  41. data/lib/txlemetry/mcp/truncation.rb +326 -0
  42. data/lib/txlemetry/mcp.rb +224 -0
  43. data/lib/txlemetry/message_batch.rb +80 -0
  44. data/lib/txlemetry/noop_worker.rb +39 -0
  45. data/lib/txlemetry/response.rb +17 -0
  46. data/lib/txlemetry/send_feature_flags_options.rb +49 -0
  47. data/lib/txlemetry/send_worker.rb +218 -0
  48. data/lib/txlemetry/transport.rb +216 -0
  49. data/lib/txlemetry/utils.rb +183 -0
  50. data/lib/txlemetry/version.rb +5 -0
  51. data/lib/txlemetry.rb +16 -0
  52. metadata +105 -0
@@ -0,0 +1,736 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
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: tools_list_envelope(result), empty: empty)
230
+ end
231
+ result
232
+ end
233
+
234
+ # `resultType` is a lifecycle discriminator the gem stamps on every modern-wire reply, not envelope data.
235
+ def tools_list_envelope(result)
236
+ return nil unless result.is_a?(Hash)
237
+
238
+ envelope = result.reject { |key, value| %w[tools resultType].include?(key.to_s) || value.nil? }
239
+ envelope unless envelope.empty?
240
+ end
241
+
242
+ def dispatch_initialize
243
+ client_info = fetch(@params, :clientInfo)
244
+ client_name = client_info.is_a?(Hash) ? fetch(client_info, :name) : nil
245
+ client_version = client_info.is_a?(Hash) ? fetch(client_info, :version) : nil
246
+ requested_version = fetch(@params, :protocolVersion)
247
+
248
+ result = begin
249
+ yield
250
+ rescue StandardError => e
251
+ safely do
252
+ session_id = prepare_request(@request, skip_initialize: true)
253
+ record_initialize(session_id, client_name, client_version, requested_version, error: e)
254
+ end
255
+ raise
256
+ end
257
+
258
+ safely do
259
+ negotiated = (result.is_a?(Hash) ? fetch(result, :protocolVersion) : nil) || requested_version
260
+ minted = mint_session_token(client_name, client_version, negotiated, requested_version)
261
+ session_id = prepare_request(@request, skip_initialize: true, token: minted)
262
+ record_initialize(session_id, client_name, client_version, negotiated, response: result)
263
+ end
264
+ result
265
+ end
266
+
267
+ def dispatch_generic
268
+ result = begin
269
+ yield
270
+ rescue StandardError => e
271
+ safely { record_generic(prepare_request(@request), error: e) }
272
+ raise
273
+ end
274
+ safely { record_generic(prepare_request(@request), result: result) }
275
+ result
276
+ end
277
+
278
+ # --- recording ---------------------------------------------------------
279
+
280
+ def record_tool_call(session_id, name, request, result: nil, error: nil, conversation_id: nil, stripped: {},
281
+ error_source: nil)
282
+ event = base_event(EventType::MCP_TOOLS_CALL, session_id, request)
283
+ event['resource_name'] = name
284
+ event['tool_description'] = @data.tool_descriptions[name]
285
+ event['tool_category'] = @data.tool_categories[name]
286
+ event['parameters'] = Sanitization.build_captured_mcp_parameters(request)
287
+ event['conversation_id'] = conversation_id
288
+ event['is_error'] = false
289
+
290
+ intent = Intent.resolve(@data, request, extra, stripped['context'])
291
+ if intent
292
+ event['user_intent'] = intent[0]
293
+ event['user_intent_source'] = intent[1]
294
+ end
295
+ if @options.capture_model_enabled?
296
+ model = ModelCapture.resolve(request, stripped[ModelCapture::PARAM_NAME])
297
+ if model
298
+ event['llm_model'] = model[0]
299
+ event['llm_model_source'] = model[1]
300
+ end
301
+ end
302
+
303
+ if error
304
+ event['is_error'] = true
305
+ event['error'] = Exceptions.capture_exception(error)
306
+ elsif !result.nil? && !no_response?(result)
307
+ event['response'] = result
308
+ source = error_source.nil? ? result : error_source
309
+ if tool_result_error?(source)
310
+ event['is_error'] = true
311
+ event['error'] = Exceptions.capture_exception(Sanitization.stringify_keys(source))
312
+ end
313
+ end
314
+
315
+ finish_event(event, request)
316
+ end
317
+
318
+ def record_missing_capability(name, arguments, request)
319
+ session_id = prepare_request(request)
320
+ event = base_event(EventType::MCP_MISSING_CAPABILITY, session_id, request)
321
+ event.delete('duration')
322
+ event['resource_name'] = name
323
+ event['parameters'] = Sanitization.build_captured_mcp_parameters(request)
324
+ context = arguments.is_a?(Hash) ? (arguments[:context] || arguments['context']) : nil
325
+ if context.is_a?(String) && !context.strip.empty?
326
+ event['user_intent'] = context.strip
327
+ event['user_intent_source'] = 'context_parameter'
328
+ end
329
+ if @options.capture_model_enabled?
330
+ model = ModelCapture.resolve(request, self_reported_model(arguments))
331
+ if model
332
+ event['llm_model'] = model[0]
333
+ event['llm_model_source'] = model[1]
334
+ end
335
+ end
336
+ finish_event(event, request)
337
+ end
338
+
339
+ # The virtual tool declares `llm_model` itself, so the argument is never
340
+ # stripped and is read straight off the call.
341
+ def self_reported_model(arguments)
342
+ return nil unless arguments.is_a?(Hash)
343
+
344
+ arguments[ModelCapture::PARAM_NAME] || arguments[ModelCapture::PARAM_NAME.to_sym]
345
+ end
346
+
347
+ def record_tools_list(session_id, names:, response: nil, empty: false, error: nil)
348
+ event = base_event(EventType::MCP_TOOLS_LIST, session_id, @request)
349
+ event['listed_tool_names'] = names
350
+ event['parameters'] = Sanitization.build_captured_mcp_parameters(@request)
351
+ event['response'] = response unless response.nil? || no_response?(response)
352
+ event['is_error'] = !error.nil? || empty
353
+ event['timestamp'] = Time.now.utc
354
+ if error
355
+ event['error'] = Exceptions.capture_exception(error)
356
+ elsif empty
357
+ event['error'] = Exceptions.capture_exception('tools/list returned no tools')
358
+ end
359
+ finish_event(event, @request)
360
+ end
361
+
362
+ def record_initialize(session_id, client_name, client_version, protocol_version, response: nil, error: nil)
363
+ @data.mark_session_initialized(session_id)
364
+ event = base_event(EventType::MCP_INITIALIZE, session_id, @request)
365
+ event['client_name'] = client_name
366
+ event['client_version'] = client_version
367
+ event['protocol_version'] = protocol_version
368
+ event['parameters'] = Sanitization.build_captured_mcp_parameters(@request)
369
+ event['response'] = response unless response.nil? || no_response?(response)
370
+ if error
371
+ event['is_error'] = true
372
+ event['error'] = Exceptions.capture_exception(error)
373
+ end
374
+ finish_event(event, @request)
375
+ end
376
+
377
+ def record_generic(session_id, result: nil, error: nil)
378
+ event = base_event(GENERIC_EVENT_TYPES.fetch(@kind), session_id, @request)
379
+ event['resource_name'] = generic_resource_name
380
+ event['parameters'] = Sanitization.build_captured_mcp_parameters(@request)
381
+ event['response'] = result unless result.nil? || no_response?(result)
382
+ event['is_error'] = !error.nil?
383
+ event['error'] = Exceptions.capture_exception(error) if error
384
+ finish_event(event, @request)
385
+ end
386
+
387
+ def base_event(event_type, session_id, request)
388
+ event = {
389
+ 'event_type' => event_type,
390
+ 'session_id' => session_id,
391
+ 'duration' => duration_ms,
392
+ 'client_name' => nil,
393
+ 'client_version' => nil,
394
+ 'protocol_version' => protocol_version
395
+ }
396
+ name, version = client_identity(request)
397
+ event['client_name'] = name
398
+ event['client_version'] = version
399
+ event
400
+ end
401
+
402
+ def finish_event(event, request)
403
+ props = resolve_event_properties(request)
404
+ event['properties'] = props unless props.nil?
405
+ TransportIdentity.stamp(event, @headers)
406
+ self.class.capture_event(@data, event, actor: actor_for(event['session_id']))
407
+ end
408
+
409
+ # The identity this request resolved for the session the event is filed
410
+ # under. {#prepare_request} always runs first for that session, so the key
411
+ # is present; a missing one means nobody identified and stays nil.
412
+ def actor_for(session_id)
413
+ @identified_in_request[session_id]
414
+ end
415
+
416
+ def resolve_event_properties(request)
417
+ callback = @options.event_properties
418
+ return nil unless callback
419
+
420
+ result = Callbacks.call(callback, request, extra)
421
+ result.is_a?(Hash) && !result.empty? ? result : nil
422
+ rescue StandardError => e
423
+ Log.debug(@options, "event_properties callback error: #{e.message}")
424
+ nil
425
+ end
426
+
427
+ # --- session / identity -----------------------------------------------
428
+
429
+ # Settle session and identity before the tool body runs, and pin the session
430
+ # to the request scope. {Analytics#capture} reads both from there, so a
431
+ # custom event emitted inside a tool belongs to its caller rather than to
432
+ # whichever request finished last (or is running concurrently), and carries
433
+ # the same identified person as the `$mcp_tool_call` that follows it.
434
+ #
435
+ # `conversation_id` is passed when the agent echoed one back: that anchor is
436
+ # already known, so priming resolves the same session the tool call will be
437
+ # recorded under. A minted handle is not known to have reached the agent
438
+ # until the call returns, so it stays out of here. {#prepare_request} runs
439
+ # again after the call; the second run is idempotent.
440
+ def prime_session(request, conversation_id)
441
+ session_id = prepare_request(request, conversation_id: conversation_id)
442
+ @scope[:session_id] = session_id if @scope.is_a?(Hash)
443
+ end
444
+
445
+ # Resolve the session id, run identify, then lazily emit initialize.
446
+ def prepare_request(request, conversation_id: nil, skip_initialize: false, token: nil)
447
+ token ||= @token
448
+ session_id, source = Session.resolve(@data, mcp_session_id(token), token: token,
449
+ conversation_id: conversation_id)
450
+ warn_stateless_session_not_wired if source == 'generated' && http?
451
+
452
+ # A tool call prepares twice: once before the body to pin the session, and
453
+ # once after it, when the conversation anchor is known. A customer's
454
+ # `identify` callback runs once per session per request, so preparing
455
+ # twice never asks it the same question twice.
456
+ unless @identified_in_request.key?(session_id)
457
+ identify_event, actor = Identity.identify_for_request(@data, session_id, request, extra)
458
+ # Pin what this request resolved. Every event it emits afterwards -
459
+ # including one a tool body captures through {Analytics} - is attributed
460
+ # to this, never to a re-read of the cache another request may have
461
+ # overwritten while the handler ran.
462
+ @identified_in_request[session_id] = actor
463
+ @scope[:actor] = actor if @scope.is_a?(Hash)
464
+ self.class.capture_event(@data, identify_event, actor: actor) if identify_event
465
+ end
466
+ maybe_emit_initialize(session_id, request) unless skip_initialize
467
+ session_id
468
+ end
469
+
470
+ def maybe_emit_initialize(session_id, request)
471
+ # Claiming is atomic: two threads opening the same session concurrently
472
+ # must not both get past the check and emit an initialize each.
473
+ return unless @data.claim_session_initialized(session_id)
474
+
475
+ name, version = client_identity(request)
476
+ event = {
477
+ 'event_type' => EventType::MCP_INITIALIZE,
478
+ 'session_id' => session_id,
479
+ 'client_name' => name,
480
+ 'client_version' => version,
481
+ 'protocol_version' => protocol_version,
482
+ 'timestamp' => Time.now.utc
483
+ }
484
+ props = resolve_event_properties({ method: 'initialize', params: {} })
485
+ event['properties'] = props unless props.nil?
486
+ TransportIdentity.stamp(event, @headers)
487
+ self.class.capture_event(@data, event, actor: actor_for(session_id))
488
+ end
489
+
490
+ # Era is decided by the version the client *asked for*:
491
+ # a client declaring the 2026-07-28 revision or later must not be answered
492
+ # with an `Mcp-Session-Id`, even though this gem counter-offers a legacy version.
493
+ def mint_session_token(client_name, client_version, protocol_version, requested_version)
494
+ return nil unless http? && @scope
495
+ return nil if header_session_id || @session&.session_id
496
+ return nil unless self.class.legacy_era?(requested_version)
497
+
498
+ payload = SessionTokenPayload.new(
499
+ session_id: Session.new_session_id,
500
+ client_name: client_name.is_a?(String) ? client_name : nil,
501
+ client_version: client_version.is_a?(String) ? client_version : nil,
502
+ protocol_version: protocol_version.is_a?(String) ? protocol_version : nil
503
+ )
504
+ @scope[:mint] = SessionToken.encode(payload)
505
+ payload
506
+ end
507
+
508
+ def warn_stateless_session_not_wired
509
+ return if @data.warned_no_stateless_session
510
+
511
+ @data.warned_no_stateless_session = true
512
+ Log.warn(
513
+ @options,
514
+ 'Warning: an MCP request arrived over streamable HTTP with no session id, so Txlemetry generated a ' \
515
+ 'per-process $session_id that will fragment across requests and pods. In stateless mode the ' \
516
+ 'client must replay the Mcp-Session-Id header Txlemetry::MCP mints at initialize; for a custom Rack ' \
517
+ 'stack add Txlemetry::MCP::RackMiddleware. Conversation ids, which are enabled by default, also anchor ' \
518
+ 'the session without any middleware. See https://txlemetry.com/docs/mcp-analytics/installation#ruby.'
519
+ )
520
+ end
521
+
522
+ # --- request context ---------------------------------------------------
523
+
524
+ def header_session_id
525
+ SessionToken.read_header(@headers)
526
+ end
527
+
528
+ # The transport's own session id (never our token).
529
+ def mcp_session_id(token)
530
+ transport_id = @session.respond_to?(:session_id) ? @session.session_id : nil
531
+ return transport_id if transport_id.is_a?(String) && !transport_id.empty?
532
+
533
+ token ? nil : header_session_id
534
+ end
535
+
536
+ def http?
537
+ @scope.is_a?(Hash) && @scope[:transport] == :http
538
+ end
539
+
540
+ def envelope_meta
541
+ meta = fetch(@params, :_meta)
542
+ meta.is_a?(Hash) ? meta : nil
543
+ end
544
+
545
+ def client_identity(request)
546
+ info = envelope_client_info || session_client || server_client
547
+ info = fetch(request_params(request), :clientInfo) if info.nil? && @kind == :initialize
548
+ name = info.is_a?(Hash) ? fetch(info, :name) : nil
549
+ version = info.is_a?(Hash) ? fetch(info, :version) : nil
550
+ name ||= @token&.client_name
551
+ version ||= @token&.client_version
552
+ [name, version]
553
+ end
554
+
555
+ def envelope_client_info
556
+ meta = envelope_meta
557
+ return nil unless meta
558
+
559
+ meta[META_CLIENT_INFO_KEY] || meta[META_CLIENT_INFO_KEY.to_sym]
560
+ end
561
+
562
+ def session_client
563
+ @session.respond_to?(:client) ? @session.client : nil
564
+ end
565
+
566
+ def server_client
567
+ @server.instance_variable_defined?(:@client) ? @server.instance_variable_get(:@client) : nil
568
+ end
569
+
570
+ def protocol_version
571
+ meta = envelope_meta
572
+ from_meta = meta ? (meta[META_PROTOCOL_VERSION_KEY] || meta[META_PROTOCOL_VERSION_KEY.to_sym]) : nil
573
+ return from_meta if from_meta.is_a?(String)
574
+
575
+ from_session = @session.respond_to?(:protocol_version) ? @session.protocol_version : nil
576
+ return from_session if from_session.is_a?(String)
577
+
578
+ if @server.instance_variable_defined?(:@client_protocol_version)
579
+ from_server = @server.instance_variable_get(:@client_protocol_version)
580
+ return from_server if from_server.is_a?(String)
581
+ end
582
+
583
+ @headers['mcp-protocol-version'] || @token&.protocol_version
584
+ end
585
+
586
+ def extra
587
+ @extra ||= {
588
+ 'session_id' => (@session.respond_to?(:session_id) ? @session.session_id : nil) || header_session_id,
589
+ 'request_id' => @request_id,
590
+ 'protocol_version' => protocol_version,
591
+ 'headers' => @headers,
592
+ 'session' => @session
593
+ }
594
+ end
595
+
596
+ def request_params(request)
597
+ params = request.is_a?(Hash) ? (request[:params] || request['params']) : nil
598
+ params.is_a?(Hash) ? params : {}
599
+ end
600
+
601
+ def request_with_arguments(arguments)
602
+ params = @params.merge(SchemaMutation.key_for(@params, :arguments) => arguments)
603
+ @request.merge(SchemaMutation.key_for(@request, :params) => params)
604
+ end
605
+
606
+ def generic_resource_name
607
+ case @kind
608
+ when :prompts_get then fetch(@params, :name)
609
+ when :resources_read then fetch(@params, :uri)
610
+ end
611
+ end
612
+
613
+ def duration_ms
614
+ (self.class.monotonic_now - @start) * 1000.0
615
+ end
616
+
617
+ # --- tools -------------------------------------------------------------
618
+
619
+ # True only for the `get_more_tools` class {Tools.register} added; an
620
+ # application tool that shares the name is an ordinary tool.
621
+ def virtual_tool?(name)
622
+ return false if @data.virtual_tool.nil?
623
+
624
+ tools = @server.respond_to?(:tools) ? @server.tools : nil
625
+ tools.is_a?(Hash) && tools[name].equal?(@data.virtual_tool)
626
+ end
627
+
628
+ # Injected argument names the analytics layer owns for this tool: the ones
629
+ # it injected at tools/list, or (never listed) the ones the tool's own
630
+ # schema does not declare. A composed or referenced schema is never
631
+ # injected into, so nothing in it is ours to strip either - the same guard
632
+ # {SchemaMutation.add_parameter} uses, so a call before the first
633
+ # tools/list behaves exactly like one after it.
634
+ def owned_params_for(name)
635
+ cached = @data.tool_owned_params[name]
636
+ return cached if cached
637
+
638
+ tools = @server.respond_to?(:tools) ? @server.tools : nil
639
+ tool = tools.is_a?(Hash) ? tools[name] : nil
640
+ schema = tool.respond_to?(:input_schema) ? tool.input_schema&.to_h : nil
641
+ return [] unless SchemaMutation.injectable?(schema)
642
+
643
+ owned = []
644
+ owned << 'context' if @options.context_enabled? && !SchemaMutation.declares_param?(schema, 'context')
645
+ owned << ConversationId::PARAM_NAME if @options.enable_conversation_id &&
646
+ !SchemaMutation.declares_param?(schema, ConversationId::PARAM_NAME)
647
+ owned << ModelCapture::PARAM_NAME if @options.capture_model_enabled? &&
648
+ !SchemaMutation.declares_param?(schema, ModelCapture::PARAM_NAME)
649
+ owned
650
+ end
651
+
652
+ # Remove SDK-owned arguments in place before the tool receives them as
653
+ # keywords (an unknown keyword would raise). Returns the stripped values.
654
+ def strip_injected_arguments(arguments, owned)
655
+ stripped = {}
656
+ return stripped unless arguments.is_a?(Hash)
657
+
658
+ owned.each do |param|
659
+ [param.to_sym, param].each do |key|
660
+ next unless arguments.key?(key)
661
+
662
+ value = arguments.delete(key)
663
+ stripped[param] = value if stripped[param].nil?
664
+ end
665
+ end
666
+ stripped
667
+ end
668
+
669
+ def mutate_tool(tool)
670
+ return tool unless tool.is_a?(Hash)
671
+
672
+ name = fetch(tool, :name)
673
+ return tool if virtual_tool?(name)
674
+
675
+ schema = fetch(tool, :inputSchema)
676
+ owned = []
677
+ if @options.context_enabled?
678
+ updated = SchemaMutation.add_context_parameter(
679
+ schema, tool_name: name, description: @options.context_description, options: @options
680
+ )
681
+ owned << 'context' unless updated.equal?(schema)
682
+ schema = updated
683
+ end
684
+ if @options.enable_conversation_id
685
+ updated = SchemaMutation.add_conversation_id_parameter(schema, tool_name: name, options: @options)
686
+ owned << ConversationId::PARAM_NAME unless updated.equal?(schema)
687
+ schema = updated
688
+ end
689
+ if @options.capture_model_enabled?
690
+ updated = SchemaMutation.add_model_parameter(
691
+ schema, tool_name: name, description: @options.model_description, options: @options
692
+ )
693
+ owned << ModelCapture::PARAM_NAME unless updated.equal?(schema)
694
+ schema = updated
695
+ end
696
+
697
+ mutated = tool.merge(SchemaMutation.key_for(tool, :inputSchema) => schema)
698
+ declared = nil
699
+ if @options.enable_conversation_id
700
+ output_schema = fetch(tool, :outputSchema)
701
+ new_output, declared = SchemaMutation.add_output_instructions(output_schema, tool_name: name,
702
+ options: @options)
703
+ mutated = mutated.merge(SchemaMutation.key_for(tool, :outputSchema) => new_output) if declared && new_output
704
+ end
705
+
706
+ meta = fetch(tool, :_meta)
707
+ category = meta.is_a?(Hash) ? (meta[:category] || meta['category']) : nil
708
+ @data.remember_tool(name, description: fetch(tool, :description), category: category, owned_params: owned,
709
+ output_instructions: declared)
710
+ mutated
711
+ end
712
+
713
+ # --- helpers -----------------------------------------------------------
714
+
715
+ def fetch(hash, key)
716
+ SchemaMutation.fetch(hash, key)
717
+ end
718
+
719
+ def tool_result_error?(result)
720
+ result.is_a?(Hash) && (result[:isError] == true || result['isError'] == true)
721
+ end
722
+
723
+ def no_response?(result)
724
+ defined?(::JsonRpcHandler::NO_RESPONSE) && result.equal?(::JsonRpcHandler::NO_RESPONSE)
725
+ end
726
+
727
+ def safely(fallback = nil)
728
+ yield
729
+ rescue StandardError => e
730
+ Log.debug(@options,
731
+ "Txlemetry MCP analytics step failed (event dropped, request unaffected): #{e.class}: #{e.message}")
732
+ fallback
733
+ end
734
+ end
735
+ end
736
+ end