mcpeye 0.1.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,844 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "securerandom"
7
+
8
+ require_relative "intent"
9
+ require_relative "request_capability"
10
+ require_relative "redaction"
11
+
12
+ module Mcpeye
13
+ # Tracker instruments a Ruby/Rails MCP server:
14
+ #
15
+ # 1. Injects the optional `mcpeyeIntent` parameter into each tool's input
16
+ # schema (so the agent self-reports intent at near-zero cost).
17
+ # 2. Adds the reserved `mcpeye_request_capability` tool (active missing-
18
+ # capability capture) with a handler that records the ask and returns a
19
+ # canned acknowledgement. Toggle with `capture_missing_capabilities:`.
20
+ # 3. Wraps each tool handler to capture the call (name, arguments, result,
21
+ # error, duration, the self-reported intent), redacting client-side.
22
+ # 4. Buffers CapturedToolCall events and POSTs the IngestPayload JSON to
23
+ # "#{ingest_url}/ingest" with the x-mcpeye-secret header via Net::HTTP.
24
+ #
25
+ # The wire shape matches @mcpeye/core's IngestPayload exactly (and is
26
+ # byte-compatible with the TS and Python SDKs):
27
+ # { projectId, identity: { userId?, client?, serverVersion? }, events: [...] }
28
+ #
29
+ # Prime directive: this NEVER raises into, or alters, the host MCP server. The
30
+ # only intentional raise is an empty `project_id` at construction (fail loud
31
+ # before any traffic). Every other failure — bad server shape, redaction error,
32
+ # transport failure, a buggy `identify`/`on_error` — is swallowed and routed to
33
+ # the single `on_error` sink (default: a `[mcpeye]` warning). A host handler's
34
+ # OWN exception is the one thing re-raised, unchanged.
35
+ #
36
+ # Latency: capturing a call is O(1) (redact + buffer append under a mutex).
37
+ # Shipping happens OFF the tool-call thread when a background flush is running
38
+ # (`flush_interval:`). With the default `flush_interval: nil` (zero-thread), the
39
+ # eager flush at `flush_threshold` runs SYNCHRONOUSLY on the caller's thread —
40
+ # one `Net::HTTP` POST bounded by the 5s/10s open/read timeouts — so set
41
+ # `flush_interval:` for fully non-blocking capture on a busy server.
42
+ #
43
+ # The exact surface of a Ruby MCP server object varies (mcp gem, fast-mcp, a
44
+ # custom Rack handler, ...), so #instrument duck-types the common shapes and
45
+ # degrades gracefully when it cannot introspect a server — you can always
46
+ # capture calls manually with #wrap / #record.
47
+ class Tracker
48
+ # POST once the buffer reaches this many events (eager flush). When a
49
+ # background flush thread is running it is woken to drain off the hot path;
50
+ # otherwise the flush happens inline on the caller's thread.
51
+ DEFAULT_FLUSH_THRESHOLD = 20
52
+
53
+ # Hard cap on buffered events; oldest are dropped past this while the API is
54
+ # down, so a permanently-unreachable ingest can never grow memory without
55
+ # bound and OOM the host. Mirrors the TS/Python maxBufferedEvents.
56
+ DEFAULT_MAX_BUFFER = 10_000
57
+
58
+ # A single captured field (arguments or result) is capped at this many UTF-8
59
+ # bytes. One tool returning a multi-MB blob must never blow the ingest body
60
+ # limit (a permanent 413 requeue loop that halts ALL telemetry) or OOM the
61
+ # buffer — past the cap we ship a small marker. Mirrors TS/Python MAX_FIELD_BYTES.
62
+ MAX_FIELD_BYTES = 32_768
63
+ # Margin past the cap to redact, so a secret straddling the cut is still
64
+ # matched in full before truncation. Mirrors TS/Python REDACT_MARGIN_BYTES.
65
+ REDACT_MARGIN_BYTES = 4_096
66
+ TRUNCATED_SUFFIX = "…[truncated]"
67
+
68
+ attr_reader :project_id, :ingest_url, :redact, :identity
69
+
70
+ # project_id — mcpeye project the data belongs to (required; raises on empty).
71
+ # ingest_url — base URL of the self-hosted mcpeye API (no trailing /ingest).
72
+ # Defaults to ENV["MCPEYE_INGEST_URL"].
73
+ # ingest_secret — shared secret sent as x-mcpeye-secret.
74
+ # Defaults to ENV["MCPEYE_INGEST_SECRET"].
75
+ # redact — when true (default) scrub arguments/result/intent/error client-side.
76
+ # identity — static Hash { userId:, client:, serverVersion: }.
77
+ # identify — optional callable evaluated once per flush, returning the
78
+ # identity Hash (per-request/thread-local attribution). A
79
+ # raising identify yields {} for that flush, never breaks it.
80
+ # flush_threshold — eager-flush buffer size (default 20).
81
+ # flush_interval — background flush interval in seconds (default nil = no
82
+ # background thread; stay zero-thread unless asked).
83
+ # denylist_fields — extra exact field names whose values are always dropped.
84
+ # max_buffer — hard buffer cap (default 10_000).
85
+ # capture_missing_capabilities — when true (default) add the reserved
86
+ # `mcpeye_request_capability` tool so the agent can voice a
87
+ # capability no existing tool covers, and answer that call
88
+ # locally. Set false to keep the extra tool out of the manifest.
89
+ # on_error — diagnostics sink for swallowed errors. Default warns with a
90
+ # `[mcpeye]` prefix. Wrapped so it can never throw into the host.
91
+ def initialize(project_id,
92
+ ingest_url: nil,
93
+ ingest_secret: nil,
94
+ redact: true,
95
+ identity: {},
96
+ identify: nil,
97
+ flush_threshold: DEFAULT_FLUSH_THRESHOLD,
98
+ flush_interval: nil,
99
+ denylist_fields: [],
100
+ max_buffer: DEFAULT_MAX_BUFFER,
101
+ capture_missing_capabilities: true,
102
+ on_error: nil)
103
+ raise ArgumentError, "project_id is required" if project_id.nil? || project_id.to_s.empty?
104
+
105
+ @project_id = project_id.to_s
106
+ @ingest_url = (ingest_url || ENV["MCPEYE_INGEST_URL"]).to_s
107
+ @ingest_secret = ingest_secret || ENV["MCPEYE_INGEST_SECRET"]
108
+ @redact = redact
109
+ @identity = normalize_identity(identity)
110
+ @identify = identify
111
+ @flush_threshold = flush_threshold
112
+ @flush_interval = flush_interval
113
+ @denylist_fields = denylist_fields || []
114
+ @max_buffer = max_buffer
115
+ @capture_missing_capabilities = capture_missing_capabilities
116
+ @on_error = wrap_on_error(on_error)
117
+
118
+ @buffer = []
119
+ @mutex = Mutex.new
120
+ # Eager-flush signalling for the background thread: a sticky flag (set under
121
+ # @mutex when the threshold is hit) + a ConditionVariable, so a wakeup raised
122
+ # while the thread is mid-POST is never lost (TS/Python parity — Python uses a
123
+ # persistent Event for the same reason).
124
+ @cond = ConditionVariable.new
125
+ @flush_requested = false
126
+ @flush_thread = nil
127
+ @stopped = false
128
+ @drop_warned = false
129
+ @reported_once = {}
130
+ # Names of tools that declare their OWN `mcpeyeIntent` param (a collision with
131
+ # our reserved name). Populated during schema inspection; consulted on the
132
+ # auto-wrap path so we never strip a field the tool legitimately owns.
133
+ @own_intent_tools = {}
134
+ # Names WE injected mcpeyeIntent into, so a second instrument pass doesn't
135
+ # misclassify our own injected param as tool-owned (mirrors Python's
136
+ # injected_tools).
137
+ @injected_tools = {}
138
+ end
139
+
140
+ # Best-effort instrumentation of an MCP server object. Injects the mcpeyeIntent
141
+ # param into discoverable tool schemas and wraps discoverable handlers so calls
142
+ # are captured automatically. Returns the server unchanged (fail-open) when no
143
+ # shape matches — you can still use #wrap / #record. Reports once if nothing
144
+ # could be introspected.
145
+ def instrument(server)
146
+ injected = inject_count(server)
147
+ # Add the reserved tool AFTER intent injection (so it never gets an mcpeyeIntent
148
+ # param) and BEFORE handler wrapping (so its pre-wrapped handler is skipped, not
149
+ # double-wrapped). A no-op when capture_missing_capabilities is off.
150
+ append_request_capability_tool(server)
151
+ wrapped = wrap_handler_count(server)
152
+ if injected.zero? && wrapped.zero?
153
+ report_once(
154
+ :no_introspect,
155
+ RuntimeError.new(
156
+ "could not introspect server tools; instrument is a no-op — capture calls manually with #wrap/#record"
157
+ )
158
+ )
159
+ end
160
+ server
161
+ rescue StandardError => e
162
+ @on_error.call(e)
163
+ server
164
+ end
165
+
166
+ # Inject the optional mcpeyeIntent property into every discoverable tool's
167
+ # input schema. Returns the server (fail-open). Collision-safe (never clobbers
168
+ # a tool that already declares mcpeyeIntent), never touches `required`, and
169
+ # skips frozen schemas rather than raising FrozenError into boot.
170
+ def inject_intent_param(server)
171
+ inject_count(server)
172
+ server
173
+ rescue StandardError => e
174
+ @on_error.call(e)
175
+ server
176
+ end
177
+
178
+ # Wrap an arbitrary tool handler block so the call is captured.
179
+ #
180
+ # handler = tracker.wrap("search_contacts") { |args| do_the_real_work(args) }
181
+ #
182
+ # The returned proc takes the tool arguments Hash, strips the injected
183
+ # mcpeyeIntent out as `intent`, runs the original handler with the cleaned
184
+ # arguments, records the captured call (redacted), and returns the original
185
+ # result unchanged. A handler that raises is recorded as is_error and the
186
+ # identical exception is re-raised; a result-level isError is captured with the
187
+ # result omitted. Capture never raises into the host.
188
+ #
189
+ # When `own_intent: true` the tool legitimately declares its OWN `mcpeyeIntent`
190
+ # parameter, so we pass the arguments through verbatim (never strip it — that
191
+ # would break the tool) and do not claim its value as agent intent. The
192
+ # auto-instrument path sets this for colliding tools; the manual path defaults
193
+ # to stripping.
194
+ def wrap(tool_name, own_intent: false, &handler)
195
+ raise ArgumentError, "a handler block is required" unless block_given?
196
+
197
+ wrapped = proc do |args = {}|
198
+ if own_intent
199
+ cleaned = args.is_a?(Hash) ? args : {}
200
+ intent = nil
201
+ else
202
+ cleaned, intent = split_intent(args)
203
+ end
204
+ started = monotonic_ms
205
+ begin
206
+ result = handler.call(cleaned)
207
+ rescue StandardError => e
208
+ # Host handler raised: record the failure, then re-raise the identical
209
+ # exception so the agent/client sees the real error (never swallowed).
210
+ record(tool_name, cleaned, is_error: true, error_message: e.message,
211
+ intent: intent, duration_ms: monotonic_ms - started)
212
+ raise
213
+ end
214
+
215
+ is_err, err_msg = result_error_info(result)
216
+ if is_err
217
+ record(tool_name, cleaned, is_error: true, error_message: err_msg,
218
+ intent: intent, duration_ms: monotonic_ms - started)
219
+ else
220
+ record(tool_name, cleaned, result: result,
221
+ intent: intent, duration_ms: monotonic_ms - started)
222
+ end
223
+ result
224
+ end
225
+
226
+ # Mark the proc so wrap_handler_count never double-wraps it (a second
227
+ # instrument, or track + a manual instrument, would otherwise stack wrappers
228
+ # and double-capture every call with distinct callIds the server can't dedup).
229
+ wrapped.define_singleton_method(:mcpeye_wrapped?) { true }
230
+ wrapped
231
+ end
232
+
233
+ # Capture a single tool call into the buffer (redacting + size-bounding if
234
+ # enabled). Triggers an eager flush once the buffer hits the threshold: woken on
235
+ # the background thread when one is running (off the hot path), otherwise flushed
236
+ # SYNCHRONOUSLY on this (the caller's) thread — set flush_interval: to avoid that
237
+ # blocking POST. Returns the captured event Hash, or nil if the event could not
238
+ # be built (reported via on_error, dropped) — never raises into the host.
239
+ def record(tool_name, arguments = {},
240
+ result: nil,
241
+ is_error: false,
242
+ error_message: nil,
243
+ intent: nil,
244
+ duration_ms: nil)
245
+ event = build_event(tool_name, arguments,
246
+ result: result, is_error: is_error,
247
+ error_message: error_message, intent: intent,
248
+ duration_ms: duration_ms)
249
+
250
+ flush_inline = false
251
+ @mutex.synchronize do
252
+ @buffer << event
253
+ trim_locked
254
+ if @buffer.length >= @flush_threshold
255
+ if @flush_thread&.alive?
256
+ # Ask the background thread to drain now, off the hot path. The sticky
257
+ # flag means the request is honored even if it arrives mid-POST.
258
+ @flush_requested = true
259
+ @cond.signal
260
+ else
261
+ flush_inline = true
262
+ end
263
+ end
264
+ end
265
+ flush if flush_inline
266
+ event
267
+ rescue StandardError => e
268
+ # A redaction/serialization error must never propagate into the host
269
+ # handler: report it, drop this one event, and return nil.
270
+ @on_error.call(e)
271
+ nil
272
+ end
273
+
274
+ # POST the buffered events as a single IngestPayload. No-op (returns nil) when
275
+ # the buffer is empty or ingest_url is unset. Returns the Net::HTTPResponse on
276
+ # success. NEVER raises: transport failures requeue the batch at the front
277
+ # (order-preserved, subject to the cap) and report via on_error; a poison
278
+ # (unserializable) batch is dropped with a report.
279
+ def flush
280
+ if blank?(@ingest_url)
281
+ report_once(
282
+ :url_blank,
283
+ RuntimeError.new("ingest_url not configured — events stay buffered, nothing is flowing")
284
+ )
285
+ return nil
286
+ end
287
+
288
+ batch = nil
289
+ @mutex.synchronize do
290
+ return nil if @buffer.empty?
291
+
292
+ batch = @buffer
293
+ @buffer = []
294
+ end
295
+
296
+ warn_secret_once if blank?(@ingest_secret)
297
+
298
+ body =
299
+ begin
300
+ JSON.generate(build_payload(batch))
301
+ rescue StandardError => e
302
+ # Structurally un-serializable payload: drop it (not re-buffered, to
303
+ # avoid a poison-batch loop), report, never raise.
304
+ @on_error.call(e)
305
+ return nil
306
+ end
307
+
308
+ begin
309
+ response = post_ingest(body)
310
+ code = response.code.to_i
311
+ raise "ingest responded #{code}" unless (200..299).cover?(code)
312
+
313
+ response
314
+ rescue StandardError => e
315
+ @on_error.call(e)
316
+ requeue(batch)
317
+ nil
318
+ end
319
+ end
320
+
321
+ # Start the background flush thread (no-op if flush_interval is unset or a live
322
+ # thread already exists). Safe to call after a fork (the inherited thread is
323
+ # dead, so a fresh one is spawned) — call it from on_worker_boot in a forking
324
+ # server (Puma/Unicorn). Returns the thread (or nil when disabled).
325
+ def start_flush_thread
326
+ return nil if @flush_interval.nil?
327
+ return @flush_thread if @flush_thread&.alive?
328
+
329
+ @stopped = false
330
+ @flush_thread = Thread.new { flush_loop }
331
+ end
332
+
333
+ # Stop the background thread (bounded wait) and do a final flush. Idempotent.
334
+ def stop
335
+ @mutex.synchronize do
336
+ @stopped = true
337
+ @cond.broadcast
338
+ end
339
+ if @flush_thread
340
+ begin
341
+ @flush_thread.join(2)
342
+ rescue StandardError
343
+ nil
344
+ end
345
+ @flush_thread = nil
346
+ end
347
+ flush
348
+ nil
349
+ rescue StandardError => e
350
+ @on_error.call(e)
351
+ nil
352
+ end
353
+
354
+ # Whether #stop has been called (so the auto at_exit drain can skip).
355
+ def stopped?
356
+ @stopped
357
+ end
358
+
359
+ # Invoked from the at_exit hook registered by Mcpeye.track. Flushes unless the
360
+ # tracker was already stopped, so a manual #stop never double-flushes. Public
361
+ # so the auto-drain behavior is testable.
362
+ def at_exit_drain
363
+ return if @stopped
364
+
365
+ flush
366
+ rescue StandardError => e
367
+ @on_error.call(e)
368
+ nil
369
+ end
370
+
371
+ # Number of events waiting to be flushed (useful in tests / Rails shutdown).
372
+ def pending
373
+ @mutex.synchronize { @buffer.length }
374
+ end
375
+
376
+ private
377
+
378
+ # --- capture ---------------------------------------------------------
379
+
380
+ def build_event(tool_name, arguments, result:, is_error:, error_message:, intent:, duration_ms:)
381
+ args = arguments.is_a?(Hash) ? arguments : {}
382
+ event = {
383
+ "callId" => SecureRandom.uuid,
384
+ "toolName" => tool_name.to_s,
385
+ # arguments must stay a Hash on the wire; cap markers are themselves Hashes.
386
+ "arguments" => guard_and_redact(args),
387
+ "isError" => !!is_error,
388
+ "timestamp" => epoch_ms
389
+ }
390
+ # Round (not truncate) a fractional duration to a non-negative int (Python/TS parity).
391
+ event["durationMs"] = [0, duration_ms.round].max unless duration_ms.nil?
392
+ # Omit result when the call errored (match TS `result: isError ? undefined`).
393
+ event["result"] = guard_and_redact(result) if !is_error && !result.nil?
394
+ event["errorMessage"] = guard_error_message(error_message.to_s) unless error_message.nil?
395
+ # Only a non-blank intent is recorded (whitespace-only is dropped, TS/Python parity).
396
+ event["intent"] = guard_error_message(intent.to_s) if intent && !intent.to_s.strip.empty?
397
+ event
398
+ end
399
+
400
+ # Pull the injected mcpeyeIntent out of an arguments Hash, returning
401
+ # [cleaned_arguments, intent_or_nil]. Tolerates string OR symbol keys. Intent
402
+ # normalization matches the TS/Python reference: only a non-empty,
403
+ # non-whitespace STRING counts; anything else is dropped (not coerced).
404
+ # Fail-open: a non-Hash (or a raising dup) yields [{}, nil].
405
+ def split_intent(args)
406
+ return [{}, nil] unless args.is_a?(Hash)
407
+
408
+ copy = args.dup
409
+ raw = copy.delete(Intent::INTENT_PARAM_NAME)
410
+ raw = copy.delete(Intent::INTENT_PARAM_NAME.to_sym) if raw.nil?
411
+ intent = raw.is_a?(String) && !raw.strip.empty? ? raw : nil
412
+ [copy, intent]
413
+ rescue StandardError
414
+ [{}, nil]
415
+ end
416
+
417
+ # Best-effort detection of a result-level error from a tool's return value
418
+ # (an MCP result Hash carrying a truthy "isError"/:isError). Returns
419
+ # [is_error, error_message]. Mirrors the TS textFromResult join.
420
+ def result_error_info(result)
421
+ return [false, nil] unless result.is_a?(Hash)
422
+
423
+ flagged = result["isError"]
424
+ flagged = result[:isError] if flagged.nil?
425
+ return [false, nil] unless flagged
426
+
427
+ [true, text_from_result(result)]
428
+ rescue StandardError
429
+ [false, nil]
430
+ end
431
+
432
+ def text_from_result(result)
433
+ content = result["content"]
434
+ content = result[:content] if content.nil?
435
+ return nil unless content.is_a?(Array)
436
+
437
+ parts = content.filter_map do |item|
438
+ next unless item.is_a?(Hash)
439
+
440
+ text = item["text"]
441
+ text = item[:text] if text.nil?
442
+ text if text.is_a?(String)
443
+ end
444
+ parts.empty? ? nil : parts.join("\n")
445
+ end
446
+
447
+ # --- redaction + size bounding (TS/Python parity) --------------------
448
+
449
+ # Size/serialize-guard a captured value, then redact it. The cap runs first so
450
+ # a bounded, serializable value is all the redactor ever walks; a substituted
451
+ # marker has nothing to redact, so it is returned as-is. Mirrors TS/Python
452
+ # guardAndRedact.
453
+ def guard_and_redact(value)
454
+ capped = cap_value(value)
455
+ return capped unless capped.equal?(value) # a marker was substituted
456
+
457
+ @redact ? Redaction.redact_value(value, denylist_fields: @denylist_fields) : value
458
+ end
459
+
460
+ # Bound the size/serializability of a captured value. Returns the value
461
+ # untouched when it is small and JSON-serializable; otherwise a tiny marker.
462
+ # A circular/unserializable value (which would otherwise send the recursive
463
+ # redactor into a SystemStackError) is replaced with a marker and never walked.
464
+ # Mirrors TS/Python capValue.
465
+ def cap_value(value)
466
+ json =
467
+ begin
468
+ JSON.generate(value)
469
+ rescue StandardError
470
+ # Circular refs, NaN/Infinity, non-serializable types — never propagate.
471
+ return { "[unserializable]" => true }
472
+ end
473
+ return value if json.nil?
474
+
475
+ bytes = json.bytesize
476
+ return { "[truncated]" => true, "bytes" => bytes } if bytes > MAX_FIELD_BYTES
477
+
478
+ value
479
+ end
480
+
481
+ # Redact and byte-bound a free-text string (errorMessage / intent), keeping it
482
+ # a readable string. Redact BEFORE truncating so a secret straddling the cap is
483
+ # scrubbed in full; slice to cap+margin first so redaction runs over a bounded
484
+ # window. Mirrors TS/Python guardErrorMessage.
485
+ def guard_error_message(msg)
486
+ window = MAX_FIELD_BYTES + REDACT_MARGIN_BYTES
487
+ s = msg.bytesize > window ? slice_utf8(msg, window) : msg
488
+ s = Redaction.redact_string(s) if @redact
489
+ if s.bytesize > MAX_FIELD_BYTES
490
+ s = slice_utf8(s, MAX_FIELD_BYTES - TRUNCATED_SUFFIX.bytesize) + TRUNCATED_SUFFIX
491
+ end
492
+ s
493
+ end
494
+
495
+ # Longest prefix of `str` whose UTF-8 encoding fits in `max_bytes`, never
496
+ # splitting a code point (scrub drops a trailing partial sequence).
497
+ def slice_utf8(str, max_bytes)
498
+ return "" if max_bytes <= 0
499
+ return str if str.bytesize <= max_bytes
500
+
501
+ str.byteslice(0, max_bytes).scrub("")
502
+ end
503
+
504
+ # --- buffer / shipping ----------------------------------------------
505
+
506
+ def build_payload(batch)
507
+ {
508
+ "projectId" => @project_id,
509
+ "identity" => resolve_identity,
510
+ "events" => batch
511
+ }
512
+ end
513
+
514
+ # Evaluate identity once per flush: the `identify` callable when given (a
515
+ # raising one yields {}), else the static identity Hash. Mirrors the TS
516
+ # identify() option.
517
+ def resolve_identity
518
+ return @identity unless @identify
519
+
520
+ raw = @identify.call
521
+ raw.nil? ? @identity : normalize_identity(raw)
522
+ rescue StandardError => e
523
+ @on_error.call(e)
524
+ {}
525
+ end
526
+
527
+ # Prepend a failed batch to the front of the buffer (oldest-first, so it
528
+ # retries first) and re-apply the cap. Caller must NOT hold @mutex.
529
+ def requeue(batch)
530
+ return if batch.nil? || batch.empty?
531
+
532
+ @mutex.synchronize do
533
+ @buffer = batch + @buffer
534
+ trim_locked
535
+ end
536
+ end
537
+
538
+ # Drop the oldest events past the cap. Caller holds @mutex. Warns once.
539
+ def trim_locked
540
+ overflow = @buffer.length - @max_buffer
541
+ return if overflow <= 0
542
+
543
+ @buffer.shift(overflow)
544
+ return if @drop_warned
545
+
546
+ @drop_warned = true
547
+ @on_error.call(
548
+ RuntimeError.new("buffer cap (#{@max_buffer}) exceeded — dropping oldest events (ingest API unreachable?)")
549
+ )
550
+ end
551
+
552
+ def post_ingest(body)
553
+ uri = URI.join(ensure_trailing_slash(@ingest_url), "ingest")
554
+ http = Net::HTTP.new(uri.host, uri.port)
555
+ http.use_ssl = uri.scheme == "https"
556
+ http.open_timeout = 5
557
+ http.read_timeout = 10
558
+
559
+ request = Net::HTTP::Post.new(uri)
560
+ request["content-type"] = "application/json"
561
+ request["x-mcpeye-secret"] = @ingest_secret.to_s unless blank?(@ingest_secret)
562
+ request.body = body
563
+
564
+ http.request(request)
565
+ end
566
+
567
+ def flush_loop
568
+ Thread.current.report_on_exception = false
569
+ until @stopped
570
+ @mutex.synchronize do
571
+ # Wait for an eager-flush request or the interval, whichever comes first.
572
+ @cond.wait(@mutex, @flush_interval) unless @flush_requested || @stopped
573
+ # Clear BEFORE flushing so a request arriving during the POST re-arms it.
574
+ @flush_requested = false
575
+ end
576
+ break if @stopped
577
+
578
+ begin
579
+ flush
580
+ rescue StandardError => e
581
+ @on_error.call(e)
582
+ end
583
+ end
584
+ end
585
+
586
+ # --- duck-typed server introspection --------------------------------
587
+
588
+ # Returns the number of object-shaped tool schemas FOUND (whether newly
589
+ # injected, already carrying mcpeyeIntent, or owned by the tool) — used to
590
+ # decide whether the server was introspectable at all. Fully guarded (per-tool
591
+ # rescue) so one weird tool never aborts the rest or crashes boot. Uses the same
592
+ # object-detection as Intent.inject_intent_param so the two paths can't drift.
593
+ def inject_count(server)
594
+ tools = discover_tools(server)
595
+ return 0 unless tools
596
+
597
+ found = 0
598
+ each_tool(tools) do |tool|
599
+ begin
600
+ schema = tool_input_schema(tool)
601
+ next unless Intent.object_shaped?(schema)
602
+
603
+ found += 1
604
+ name = tool_name_of(tool)
605
+ # Never inject mcpeyeIntent into the reserved tool (its ask lives in
606
+ # `capability`). Guards the second-instrument case where the tool we added
607
+ # on a prior pass re-enters this loop.
608
+ next if @capture_missing_capabilities && name == RequestCapability::TOOL_NAME
609
+
610
+ prop_key = schema.key?(:properties) && !schema.key?("properties") ? :properties : "properties"
611
+ props = schema[prop_key]
612
+
613
+ # mcpeyeIntent already present in properties: the tool owns the name —
614
+ # UNLESS we injected it ourselves on a prior pass. Record genuine
615
+ # ownership so the call path won't strip the tool's argument; either way
616
+ # leave the schema untouched.
617
+ if props.is_a?(Hash) &&
618
+ (props.key?(Intent::INTENT_PARAM_NAME) || props.key?(Intent::INTENT_PARAM_NAME.to_sym))
619
+ @own_intent_tools[name] = true if name && !@injected_tools.key?(name)
620
+ next
621
+ end
622
+
623
+ # Otherwise inject in place (skip a frozen schema/props rather than raise).
624
+ next if schema.frozen?
625
+
626
+ props = schema[prop_key] = {} if props.nil?
627
+ next unless props.is_a?(Hash) && !props.frozen?
628
+
629
+ props[Intent::INTENT_PARAM_NAME] = Intent.param_json_schema
630
+ # Synthesize the object type for an empty/typeless schema (TS/Python parity).
631
+ schema["type"] ||= "object"
632
+ @injected_tools[name] = true if name
633
+ rescue StandardError => e
634
+ @on_error.call(e)
635
+ next
636
+ end
637
+ end
638
+ found
639
+ end
640
+
641
+ # Returns the number of tools FOUND with a name + callable handler (whether
642
+ # newly wrapped, already one of ours, or frozen) — used to decide whether the
643
+ # server was introspectable. Only rewrites a mutable Hash tool; an
644
+ # already-wrapped handler is left as-is (no double-capture), and a frozen tool
645
+ # (can't be written back) is skipped fail-open.
646
+ def wrap_handler_count(server)
647
+ tools = discover_tools(server)
648
+ return 0 unless tools.respond_to?(:each)
649
+
650
+ found = 0
651
+ each_tool(tools) do |tool|
652
+ begin
653
+ next unless tool.is_a?(Hash)
654
+
655
+ name = tool["name"] || tool[:name]
656
+ key = ["handler", :handler, "call", :call].find { |k| tool.key?(k) && tool[k].respond_to?(:call) }
657
+ next unless name && key
658
+
659
+ handler = tool[key]
660
+ found += 1
661
+
662
+ # Already one of ours (a second instrument, or track + a manual
663
+ # instrument) — leave it, or we'd stack wrappers and double-capture every
664
+ # call with distinct callIds the server can't dedup.
665
+ next if handler.respond_to?(:mcpeye_wrapped?) && handler.mcpeye_wrapped?
666
+ # Can't write the wrapped proc back into a frozen tool — skip fail-open.
667
+ next if tool.frozen?
668
+
669
+ tool[key] = wrap(name, own_intent: @own_intent_tools.key?(name)) { |args| handler.call(args) }
670
+ rescue StandardError => e
671
+ @on_error.call(e)
672
+ next
673
+ end
674
+ end
675
+ found
676
+ end
677
+
678
+ # --- active missing-capability capture (feature 12) ------------------
679
+
680
+ # Add the reserved mcpeye_request_capability tool to the discovered registry so
681
+ # the agent can voice a capability no existing tool covers. No-op when the option
682
+ # is off, when no mutable Hash/Array registry is discoverable, or on a collision
683
+ # (the host already exposes the reserved name — then its own tool handles the
684
+ # call). The tool carries a PRE-WRAPPED handler so wrap_handler_count skips it
685
+ # (no double-capture). Idempotent: a second instrument finds it present, adds
686
+ # nothing.
687
+ def append_request_capability_tool(server)
688
+ return unless @capture_missing_capabilities
689
+
690
+ tools = discover_tools(server)
691
+ return if tools.nil?
692
+
693
+ present = false
694
+ each_tool(tools) { |tool| present = true if tool_name_of(tool) == RequestCapability::TOOL_NAME }
695
+ return if present
696
+
697
+ descriptor = RequestCapability.descriptor.merge("handler" => request_capability_handler)
698
+ if tools.is_a?(Hash)
699
+ tools[RequestCapability::TOOL_NAME] = descriptor unless tools.frozen?
700
+ elsif tools.respond_to?(:<<)
701
+ tools << descriptor unless tools.frozen?
702
+ end
703
+ rescue StandardError => e
704
+ @on_error.call(e)
705
+ end
706
+
707
+ # A handler proc for the reserved tool: dedupe + record + return the canned ack.
708
+ # Marked mcpeye_wrapped? so wrap_handler_count never re-wraps it (which would
709
+ # double-capture). The proc binds `self` to this Tracker at creation.
710
+ def request_capability_handler
711
+ handler = proc { |args = {}| handle_request_capability(args) }
712
+ handler.define_singleton_method(:mcpeye_wrapped?) { true }
713
+ handler
714
+ end
715
+
716
+ # Answer a mcpeye_request_capability call locally: capture it as a normal tool
717
+ # call (so it lands in the report) and return the canned ack. Guarded so a
718
+ # capture bug never breaks the ack.
719
+ def handle_request_capability(args)
720
+ args = {} unless args.is_a?(Hash)
721
+ ack = { "content" => [{ "type" => "text", "text" => RequestCapability::ACK }] }
722
+ # Capture EVERY reserved call -- no SDK-side dedupe. A Tracker is process-scoped
723
+ # and outlives many ingest sessions, so deduping by capability text here would
724
+ # silently drop a later session's identical ask and undercount real demand (the
725
+ # report counts DISTINCT sessions). Intra-session spam is collapsed report-side
726
+ # by session id, like every other tool call.
727
+ record(RequestCapability::TOOL_NAME, args, result: ack, intent: nil, duration_ms: 0)
728
+ ack
729
+ rescue StandardError => e
730
+ @on_error.call(e)
731
+ { "content" => [{ "type" => "text", "text" => RequestCapability::ACK }] }
732
+ end
733
+
734
+ def discover_tools(server)
735
+ %i[tools registered_tools].each do |m|
736
+ return server.public_send(m) if server.respond_to?(m)
737
+ end
738
+ if server.respond_to?(:instance_variable_get)
739
+ %i[@tools @registered_tools].each do |ivar|
740
+ v = server.instance_variable_get(ivar)
741
+ return v if v
742
+ end
743
+ end
744
+ nil
745
+ rescue StandardError
746
+ nil
747
+ end
748
+
749
+ def each_tool(tools)
750
+ collection = tools.respond_to?(:values) ? tools.values : tools
751
+ Array(collection).each { |tool| yield tool }
752
+ end
753
+
754
+ def tool_input_schema(tool)
755
+ if tool.is_a?(Hash)
756
+ return tool["inputSchema"] || tool[:input_schema] || tool["schema"] || tool[:schema] || tool[:inputSchema]
757
+ end
758
+
759
+ %i[input_schema inputSchema schema].each do |m|
760
+ return tool.public_send(m) if tool.respond_to?(m)
761
+ end
762
+ nil
763
+ rescue StandardError
764
+ nil
765
+ end
766
+
767
+ def tool_name_of(tool)
768
+ return tool["name"] || tool[:name] if tool.is_a?(Hash)
769
+
770
+ tool.respond_to?(:name) ? tool.name : nil
771
+ rescue StandardError
772
+ nil
773
+ end
774
+
775
+ # --- diagnostics -----------------------------------------------------
776
+
777
+ def wrap_on_error(callback)
778
+ sink = callback || method(:default_on_error)
779
+ lambda do |err|
780
+ sink.call(err)
781
+ rescue StandardError
782
+ # The diagnostics sink must NEVER throw back into the host.
783
+ end
784
+ end
785
+
786
+ def default_on_error(err)
787
+ if err.is_a?(Exception)
788
+ warn "[mcpeye] #{err.class}: #{err.message}"
789
+ else
790
+ warn "[mcpeye] #{err}"
791
+ end
792
+ end
793
+
794
+ def report_once(key, err)
795
+ return if @reported_once[key]
796
+
797
+ @reported_once[key] = true
798
+ @on_error.call(err)
799
+ end
800
+
801
+ def warn_secret_once
802
+ report_once(:no_secret, RuntimeError.new("ingest_secret not set — ingest will reject with 401"))
803
+ end
804
+
805
+ # --- misc ------------------------------------------------------------
806
+
807
+ # Build the identity Hash for the wire. The ingest contract requires userId,
808
+ # client, and serverVersion to be STRINGS, so coerce non-nil values with to_s
809
+ # (e.g. a Rails integer user id -> "123") and drop nil/blank. Without this a
810
+ # non-string value (the common case: `userId: Current.user_id`, an Integer)
811
+ # makes /ingest reject the whole batch as 400 invalid_body, which flush treats
812
+ # as a transient failure and requeues forever until the cap drops events.
813
+ def normalize_identity(identity)
814
+ h = identity.is_a?(Hash) ? identity : {}
815
+ out = {}
816
+ %w[userId client serverVersion].each do |k|
817
+ v = h[k.to_sym]
818
+ v = h[k] if v.nil?
819
+ next if v.nil?
820
+
821
+ s = v.to_s
822
+ out[k] = s unless s.empty?
823
+ end
824
+ out
825
+ end
826
+
827
+ def ensure_trailing_slash(url)
828
+ s = url.to_s
829
+ s.end_with?("/") ? s : "#{s}/"
830
+ end
831
+
832
+ def blank?(value)
833
+ value.nil? || value.to_s.strip.empty?
834
+ end
835
+
836
+ def epoch_ms
837
+ (Time.now.to_f * 1000).to_i
838
+ end
839
+
840
+ def monotonic_ms
841
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
842
+ end
843
+ end
844
+ end