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.
- checksums.yaml +7 -0
- data/README.md +265 -0
- data/lib/mcpeye/intent.rb +94 -0
- data/lib/mcpeye/redaction.rb +112 -0
- data/lib/mcpeye/request_capability.rb +74 -0
- data/lib/mcpeye/tracker.rb +844 -0
- data/lib/mcpeye/version.rb +5 -0
- data/lib/mcpeye.rb +66 -0
- metadata +55 -0
|
@@ -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
|