mcpeye 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +44 -7
- data/lib/mcpeye/tracker.rb +300 -14
- data/lib/mcpeye/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d50e6d30ef0e2cf6796b7b7b67397c40c11290f289152d124c3ff5417ff6150
|
|
4
|
+
data.tar.gz: 23729136fae41b35dcf3152354d59fd5e2753d7429831b1f474bc01de56c6d5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84b70bcbed2a41a80a198b44e32b4f84eaa7f753aa3701e708eebc7210e78833dd6a1d67f471ac7e62aa7f1326dd96a75eaa9b3e8946465e78432e2554ee67e7
|
|
7
|
+
data.tar.gz: a75cc956872617ae3da0400b44b8a54a1f3e2bb7b265ca6e4dad5fb5ae50f453d24ec3256cfda971563091ff77f1a4b33059d6bfc73b378745530ffdefd015a4
|
data/README.md
CHANGED
|
@@ -91,10 +91,30 @@ tracker = Mcpeye.track(
|
|
|
91
91
|
# idempotent.)
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
`Mcpeye.track`
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
`Mcpeye.track` works **out of the box with the official [`mcp` gem](https://github.com/modelcontextprotocol/ruby-sdk)**
|
|
95
|
+
(`MCP::Server` + `MCP::Tool` subclasses):
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
require "mcp"
|
|
99
|
+
require "mcpeye"
|
|
100
|
+
|
|
101
|
+
server = MCP::Server.new(name: "my-server", tools: [SearchTool, OrderTool])
|
|
102
|
+
Mcpeye.track(server, "my-project-id", ingest_url: "http://localhost:3001")
|
|
103
|
+
# That's it — every tools/call is captured and mcpeyeIntent is advertised in tools/list.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For the official gem it hooks the server's `tools/call` and `tools/list` **per
|
|
107
|
+
server instance** (never your global `MCP::Tool` classes), so it captures every
|
|
108
|
+
call — *including* the ones the gem rejects at schema validation, the failed asks
|
|
109
|
+
mcpeye exists to surface — and the injected `mcpeyeIntent` is stripped from the
|
|
110
|
+
arguments before your tool runs (it never reaches your `def self.call` signature).
|
|
111
|
+
It also auto-detects Hash / `fast-mcp` server shapes.
|
|
112
|
+
|
|
113
|
+
**Call `track` after your tools are registered** (for the official gem, any time
|
|
114
|
+
after `MCP::Server.new(tools: […])`). If a server shape can't be introspected,
|
|
115
|
+
`track` returns a working tracker and **warns loudly via `on_error`** (whose
|
|
116
|
+
default prints to stderr) — it never silently captures nothing — and you can
|
|
117
|
+
instrument manually with `#wrap` / `#record`.
|
|
98
118
|
|
|
99
119
|
## Options
|
|
100
120
|
|
|
@@ -145,9 +165,19 @@ MCPEYE = Mcpeye.track(
|
|
|
145
165
|
ENV.fetch("MCPEYE_PROJECT_ID"),
|
|
146
166
|
ingest_url: ENV.fetch("MCPEYE_INGEST_URL", "http://localhost:3001"),
|
|
147
167
|
ingest_secret: ENV["MCPEYE_INGEST_SECRET"],
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
|
|
168
|
+
# End-user identity. `userId`/`userEmail` are resolved PER CALL on the request
|
|
169
|
+
# thread (so a thread/request-local value like Rails Current / RequestStore is
|
|
170
|
+
# attributed correctly even on a multi-user / stateless server); client/serverVersion
|
|
171
|
+
# are read per flush. Pass an OPAQUE, stable userId. This is what powers the
|
|
172
|
+
# dashboard's search-by-id/email — without it, sessions read "user not identified".
|
|
173
|
+
identify: lambda {
|
|
174
|
+
{
|
|
175
|
+
userId: Current.user_id&.to_s, # who the end user is (for search)
|
|
176
|
+
userEmail: Current.user_email, # human-readable, optional (PII you store)
|
|
177
|
+
client: Current.client, # process/connection-level
|
|
178
|
+
serverVersion: MyApp::VERSION,
|
|
179
|
+
}
|
|
180
|
+
},
|
|
151
181
|
# Drop your own domain-sensitive fields on top of the built-in denylist:
|
|
152
182
|
denylist_fields: %w[ssn account_number],
|
|
153
183
|
# Forward diagnostics into your logger instead of stderr:
|
|
@@ -155,6 +185,13 @@ MCPEYE = Mcpeye.track(
|
|
|
155
185
|
)
|
|
156
186
|
```
|
|
157
187
|
|
|
188
|
+
> **Multi-user / stateless servers (e.g. a streamable-HTTP MCP).** Because `identify`
|
|
189
|
+
> runs per call on the request thread, set your per-request user context BEFORE the
|
|
190
|
+
> tool dispatches (e.g. in middleware: `RequestStore.store[:current_user_id] = ...`)
|
|
191
|
+
> and read it in `identify` (`RequestStore.store[:current_user_id]`). Each captured
|
|
192
|
+
> call is then attributed to the right user, even though one flushed batch may mix
|
|
193
|
+
> users.
|
|
194
|
+
|
|
158
195
|
### Puma / Unicorn (forking servers)
|
|
159
196
|
|
|
160
197
|
A thread does **not** survive `fork`, so in a clustered server start the flush
|
data/lib/mcpeye/tracker.rb
CHANGED
|
@@ -24,7 +24,12 @@ module Mcpeye
|
|
|
24
24
|
#
|
|
25
25
|
# The wire shape matches @mcpeye/core's IngestPayload exactly (and is
|
|
26
26
|
# byte-compatible with the TS and Python SDKs):
|
|
27
|
-
# { projectId, identity: { userId?, client?, serverVersion? },
|
|
27
|
+
# { projectId, identity: { userId?, userEmail?, client?, serverVersion? },
|
|
28
|
+
# events: [{ ..., userId?, userEmail? }] }
|
|
29
|
+
# Each event also carries its OWN userId/userEmail, resolved PER CALL on the
|
|
30
|
+
# request thread (see #resolve_call_identity) — so attribution is correct on a
|
|
31
|
+
# multi-user server where one flushed batch mixes users; the batch identity is the
|
|
32
|
+
# fallback.
|
|
28
33
|
#
|
|
29
34
|
# Prime directive: this NEVER raises into, or alters, the host MCP server. The
|
|
30
35
|
# only intentional raise is an empty `project_id` at construction (fail loud
|
|
@@ -44,6 +49,115 @@ module Mcpeye
|
|
|
44
49
|
# custom Rack handler, ...), so #instrument duck-types the common shapes and
|
|
45
50
|
# degrades gracefully when it cannot introspect a server — you can always
|
|
46
51
|
# capture calls manually with #wrap / #record.
|
|
52
|
+
|
|
53
|
+
# Prepended to an official `MCP::Server`'s SINGLETON class so its `call_tool` is
|
|
54
|
+
# captured at the dispatch layer — the Ruby analog of the TS/Python protocol-level
|
|
55
|
+
# hook. The official gem invokes `call_tool` directly (mcp 0.20 server.rb), running
|
|
56
|
+
# required-arg + schema validation there, so wrapping it (not the tool classes) also
|
|
57
|
+
# captures the validation failures the gem returns BEFORE a tool's own `.call` —
|
|
58
|
+
# exactly the failed asks mcpeye exists to surface.
|
|
59
|
+
#
|
|
60
|
+
# Prepended PER SERVER (this instance's singleton), never to the global `MCP::Tool`
|
|
61
|
+
# subclasses, so a tool class shared across servers is never bound to one tracker.
|
|
62
|
+
# Fail-open: the host's return value is always passed through unchanged, and the
|
|
63
|
+
# injected `mcpeyeIntent` is stripped from the arguments BEFORE `super`, so the gem
|
|
64
|
+
# never splats it into the host tool's keyword signature (which would raise
|
|
65
|
+
# `unknown keyword: :mcpeyeIntent` and break the call).
|
|
66
|
+
module OfficialServerCapture
|
|
67
|
+
def call_tool(request, **kwargs)
|
|
68
|
+
tracker = @__mcpeye_tracker
|
|
69
|
+
return super if tracker.nil?
|
|
70
|
+
|
|
71
|
+
name = (request[:name] || request["name"]).to_s
|
|
72
|
+
raw_args = request[:arguments] || request["arguments"]
|
|
73
|
+
args = raw_args.is_a?(Hash) ? raw_args : {}
|
|
74
|
+
|
|
75
|
+
# Strip the injected intent in place (request[:arguments] is the same object),
|
|
76
|
+
# so the gem validates/dispatches WITHOUT mcpeyeIntent.
|
|
77
|
+
intent = nil
|
|
78
|
+
begin
|
|
79
|
+
v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME)
|
|
80
|
+
v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME.to_sym) if v.nil?
|
|
81
|
+
intent = v if v.is_a?(String) && !v.strip.empty?
|
|
82
|
+
rescue StandardError
|
|
83
|
+
intent = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Reserved capability tool: never dispatch to the host; answer locally.
|
|
87
|
+
if name == Mcpeye::RequestCapability::TOOL_NAME && tracker.capture_missing_capabilities?
|
|
88
|
+
return tracker.answer_request_capability_official(args)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
92
|
+
begin
|
|
93
|
+
response = super
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
|
|
96
|
+
begin
|
|
97
|
+
tracker.record(name, args, is_error: true, error_message: e.message, intent: intent, duration_ms: dur)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
raise
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
|
|
106
|
+
is_error, error_text, result_payload = Mcpeye::OfficialServerCapture.classify(response)
|
|
107
|
+
if is_error
|
|
108
|
+
tracker.record(name, args, is_error: true, error_message: error_text, intent: intent, duration_ms: dur)
|
|
109
|
+
else
|
|
110
|
+
tracker.record(name, args, result: result_payload, intent: intent, duration_ms: dur)
|
|
111
|
+
end
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
response
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The official gem's call_tool returns EITHER a Hash (`{content:, isError:}`,
|
|
120
|
+
# the common 0.2x case) OR a Tool::Response object. Classify both into
|
|
121
|
+
# [is_error, error_text, result_payload]. Fail-open: unknown shapes -> success.
|
|
122
|
+
def self.classify(response)
|
|
123
|
+
if response.respond_to?(:error?)
|
|
124
|
+
payload =
|
|
125
|
+
if response.respond_to?(:structured_content) && response.structured_content
|
|
126
|
+
response.structured_content
|
|
127
|
+
elsif response.respond_to?(:content)
|
|
128
|
+
{ "content" => response.content }
|
|
129
|
+
else
|
|
130
|
+
response
|
|
131
|
+
end
|
|
132
|
+
[!!response.error?, text_of_content(response.respond_to?(:content) ? response.content : nil), payload]
|
|
133
|
+
elsif response.is_a?(Hash)
|
|
134
|
+
flag = response["isError"]
|
|
135
|
+
flag = response[:isError] if flag.nil?
|
|
136
|
+
[!!flag, text_of_content(response["content"] || response[:content]), response]
|
|
137
|
+
else
|
|
138
|
+
[false, nil, response]
|
|
139
|
+
end
|
|
140
|
+
rescue StandardError
|
|
141
|
+
[false, nil, response]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Join the text parts of an MCP content array (Hash or struct items).
|
|
145
|
+
def self.text_of_content(content)
|
|
146
|
+
return nil unless content.is_a?(Array)
|
|
147
|
+
|
|
148
|
+
parts = content.filter_map do |item|
|
|
149
|
+
if item.is_a?(Hash)
|
|
150
|
+
item[:text] || item["text"]
|
|
151
|
+
elsif item.respond_to?(:text)
|
|
152
|
+
item.text
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
parts.empty? ? nil : parts.join("\n")
|
|
156
|
+
rescue StandardError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
47
161
|
class Tracker
|
|
48
162
|
# POST once the buffer reaches this many events (eager flush). When a
|
|
49
163
|
# background flush thread is running it is woken to drain off the hot path;
|
|
@@ -73,10 +187,15 @@ module Mcpeye
|
|
|
73
187
|
# ingest_secret — shared secret sent as x-mcpeye-secret.
|
|
74
188
|
# Defaults to ENV["MCPEYE_INGEST_SECRET"].
|
|
75
189
|
# redact — when true (default) scrub arguments/result/intent/error client-side.
|
|
76
|
-
# identity — static Hash { userId:, client:, serverVersion: }
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
190
|
+
# identity — static Hash { userId:, userEmail:, client:, serverVersion: }
|
|
191
|
+
# (batch-level fallback).
|
|
192
|
+
# identify — optional callable for dynamic attribution. Evaluated PER CALL
|
|
193
|
+
# on the request thread for userId/userEmail (correct per-event
|
|
194
|
+
# attribution on a multi-user server) AND once per flush for the
|
|
195
|
+
# batch identity. Return { userId:, userEmail:, client:,
|
|
196
|
+
# serverVersion: }. A raising identify yields no identity for that
|
|
197
|
+
# call/flush, never breaks the host. Example:
|
|
198
|
+
# identify: -> { { userId: RequestStore.store[:current_user_id] } }
|
|
80
199
|
# flush_threshold — eager-flush buffer size (default 20).
|
|
81
200
|
# flush_interval — background flush interval in seconds (default nil = no
|
|
82
201
|
# background thread; stay zero-thread unless asked).
|
|
@@ -143,26 +262,42 @@ module Mcpeye
|
|
|
143
262
|
# shape matches — you can still use #wrap / #record. Reports once if nothing
|
|
144
263
|
# could be introspected.
|
|
145
264
|
def instrument(server)
|
|
265
|
+
# Official mcp gem (MCP::Server + MCP::Tool subclasses): dispatch-level,
|
|
266
|
+
# per-server capture. Distinct from the duck-typed path below, whose tools
|
|
267
|
+
# are handler-Hashes the official gem never produces.
|
|
268
|
+
return instrument_official(server) if official_mcp_server?(server)
|
|
269
|
+
|
|
146
270
|
injected = inject_count(server)
|
|
147
271
|
# Add the reserved tool AFTER intent injection (so it never gets an mcpeyeIntent
|
|
148
272
|
# param) and BEFORE handler wrapping (so its pre-wrapped handler is skipped, not
|
|
149
273
|
# double-wrapped). A no-op when capture_missing_capabilities is off.
|
|
150
274
|
append_request_capability_tool(server)
|
|
151
275
|
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
|
|
276
|
+
warn_uninstrumentable(server) if injected.zero? && wrapped.zero?
|
|
160
277
|
server
|
|
161
278
|
rescue StandardError => e
|
|
162
279
|
@on_error.call(e)
|
|
163
280
|
server
|
|
164
281
|
end
|
|
165
282
|
|
|
283
|
+
# Whether the reserved mcpeye_request_capability tool is enabled. Read by the
|
|
284
|
+
# official-server capture module (which lives outside the Tracker instance).
|
|
285
|
+
def capture_missing_capabilities?
|
|
286
|
+
@capture_missing_capabilities
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Record a reserved-capability call and return the canned ack as a plain
|
|
290
|
+
# CallToolResult Hash. The official gem serializes call_tool's return value
|
|
291
|
+
# directly (it does NOT call #to_h), so a Hash — not a Tool::Response object —
|
|
292
|
+
# is what reaches the client.
|
|
293
|
+
def answer_request_capability_official(_args = {})
|
|
294
|
+
handle_request_capability(_args)
|
|
295
|
+
{ "content" => [{ "type" => "text", "text" => RequestCapability::ACK }], "isError" => false }
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
@on_error.call(e)
|
|
298
|
+
{ "content" => [{ "type" => "text", "text" => RequestCapability::ACK }], "isError" => false }
|
|
299
|
+
end
|
|
300
|
+
|
|
166
301
|
# Inject the optional mcpeyeIntent property into every discoverable tool's
|
|
167
302
|
# input schema. Returns the server (fail-open). Collision-safe (never clobbers
|
|
168
303
|
# a tool that already declares mcpeyeIntent), never touches `required`, and
|
|
@@ -394,6 +529,14 @@ module Mcpeye
|
|
|
394
529
|
event["errorMessage"] = guard_error_message(error_message.to_s) unless error_message.nil?
|
|
395
530
|
# Only a non-blank intent is recorded (whitespace-only is dropped, TS/Python parity).
|
|
396
531
|
event["intent"] = guard_error_message(intent.to_s) if intent && !intent.to_s.strip.empty?
|
|
532
|
+
# Per-CALL end-user identity. build_event runs on the caller's (request) thread
|
|
533
|
+
# — via the official call_tool hook or the wrap proc — so `identify` is evaluated
|
|
534
|
+
# where the host's per-request user context is live (unlike the per-flush batch
|
|
535
|
+
# identity, which runs on the flush thread). This is what makes attribution
|
|
536
|
+
# correct on a multi-user server. Per-event values win over the batch identity.
|
|
537
|
+
uid, uemail = resolve_call_identity
|
|
538
|
+
event["userId"] = uid if uid
|
|
539
|
+
event["userEmail"] = uemail if uemail
|
|
397
540
|
event
|
|
398
541
|
end
|
|
399
542
|
|
|
@@ -524,6 +667,30 @@ module Mcpeye
|
|
|
524
667
|
{}
|
|
525
668
|
end
|
|
526
669
|
|
|
670
|
+
# Per-CALL end-user identity for ONE event, resolved on the caller's (request)
|
|
671
|
+
# thread. Uses `identify` (evaluated per call) when given, else the static
|
|
672
|
+
# `identity`. Returns [user_id, user_email] as normalized Strings or nils.
|
|
673
|
+
# Fail-open: a raising identify yields [nil, nil], never into the host call.
|
|
674
|
+
def resolve_call_identity
|
|
675
|
+
raw = @identify ? @identify.call : @identity
|
|
676
|
+
raw = {} unless raw.is_a?(Hash)
|
|
677
|
+
[norm_ident(raw[:userId] || raw["userId"]), norm_ident(raw[:userEmail] || raw["userEmail"])]
|
|
678
|
+
rescue StandardError => e
|
|
679
|
+
@on_error.call(e)
|
|
680
|
+
[nil, nil]
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# Coerce an identity value to a trimmed String <=256 chars, or nil if blank.
|
|
684
|
+
# Coerces a non-String id (e.g. an Integer user id) like normalize_identity.
|
|
685
|
+
def norm_ident(value)
|
|
686
|
+
return nil if value.nil?
|
|
687
|
+
|
|
688
|
+
s = value.to_s.strip
|
|
689
|
+
return nil if s.empty?
|
|
690
|
+
|
|
691
|
+
s.length > 256 ? s[0, 256] : s
|
|
692
|
+
end
|
|
693
|
+
|
|
527
694
|
# Prepend a failed batch to the front of the buffer (oldest-first, so it
|
|
528
695
|
# retries first) and re-apply the cap. Caller must NOT hold @mutex.
|
|
529
696
|
def requeue(batch)
|
|
@@ -731,6 +898,125 @@ module Mcpeye
|
|
|
731
898
|
{ "content" => [{ "type" => "text", "text" => RequestCapability::ACK }] }
|
|
732
899
|
end
|
|
733
900
|
|
|
901
|
+
# --- official mcp gem (MCP::Server) support --------------------------
|
|
902
|
+
|
|
903
|
+
def official_mcp_server?(server)
|
|
904
|
+
defined?(MCP::Server) && server.is_a?(MCP::Server)
|
|
905
|
+
rescue StandardError
|
|
906
|
+
false
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# Dispatch-level, per-server instrumentation of an official MCP::Server.
|
|
910
|
+
def instrument_official(server)
|
|
911
|
+
server.instance_variable_set(:@__mcpeye_tracker, self)
|
|
912
|
+
listed = wrap_official_list_tools(server)
|
|
913
|
+
called = wrap_official_call_tool(server)
|
|
914
|
+
warn_uninstrumentable(server) unless listed || called
|
|
915
|
+
server
|
|
916
|
+
rescue StandardError => e
|
|
917
|
+
@on_error.call(e)
|
|
918
|
+
server
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# Capture: prepend the singleton-class hook so this server's call_tool is wrapped.
|
|
922
|
+
def wrap_official_call_tool(server)
|
|
923
|
+
sc = server.singleton_class
|
|
924
|
+
sc.prepend(OfficialServerCapture) unless sc.include?(OfficialServerCapture)
|
|
925
|
+
true
|
|
926
|
+
rescue StandardError => e
|
|
927
|
+
@on_error.call(e)
|
|
928
|
+
false
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# Advertise: replace this server's "tools/list" handler entry so the listed
|
|
932
|
+
# schemas carry mcpeyeIntent and the reserved tool. @handlers is read at dispatch
|
|
933
|
+
# time, so swapping the entry is seen; mutating the RESPONSE never touches the
|
|
934
|
+
# global tool classes.
|
|
935
|
+
def wrap_official_list_tools(server)
|
|
936
|
+
handlers = server.instance_variable_get(:@handlers)
|
|
937
|
+
return false unless handlers.is_a?(Hash)
|
|
938
|
+
|
|
939
|
+
orig = handlers["tools/list"]
|
|
940
|
+
return false if orig.nil?
|
|
941
|
+
return true if orig.respond_to?(:mcpeye_wrapped?) && orig.mcpeye_wrapped?
|
|
942
|
+
|
|
943
|
+
add_reserved = @capture_missing_capabilities
|
|
944
|
+
wrapped = lambda do |request|
|
|
945
|
+
result = orig.call(request)
|
|
946
|
+
begin
|
|
947
|
+
augment_official_list_result(result, add_reserved)
|
|
948
|
+
rescue StandardError => e
|
|
949
|
+
@on_error.call(e)
|
|
950
|
+
end
|
|
951
|
+
result
|
|
952
|
+
end
|
|
953
|
+
wrapped.define_singleton_method(:mcpeye_wrapped?) { true }
|
|
954
|
+
handlers["tools/list"] = wrapped
|
|
955
|
+
true
|
|
956
|
+
rescue StandardError => e
|
|
957
|
+
@on_error.call(e)
|
|
958
|
+
false
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def augment_official_list_result(result, add_reserved)
|
|
962
|
+
return unless result.is_a?(Hash)
|
|
963
|
+
|
|
964
|
+
tools = result[:tools] || result["tools"]
|
|
965
|
+
return unless tools.is_a?(Array)
|
|
966
|
+
|
|
967
|
+
tools.each do |tool|
|
|
968
|
+
next unless tool.is_a?(Hash)
|
|
969
|
+
|
|
970
|
+
key = if tool.key?(:inputSchema) then :inputSchema
|
|
971
|
+
elsif tool.key?("inputSchema") then "inputSchema"
|
|
972
|
+
end
|
|
973
|
+
next if key.nil?
|
|
974
|
+
|
|
975
|
+
schema = tool[key]
|
|
976
|
+
tool[key] = inject_intent_into_schema(schema) if schema.is_a?(Hash)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
if add_reserved && tools.none? { |t| t.is_a?(Hash) && (t[:name] || t["name"]) == RequestCapability::TOOL_NAME }
|
|
980
|
+
tools << RequestCapability.descriptor
|
|
981
|
+
end
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Key-style-aware intent injection: official `to_h` schemas use symbol keys, so
|
|
985
|
+
# write back under the SAME key style (never produce both :properties and
|
|
986
|
+
# "properties", which would serialize to a duplicate JSON key). Collision-safe.
|
|
987
|
+
def inject_intent_into_schema(schema)
|
|
988
|
+
return schema unless Intent.object_shaped?(schema)
|
|
989
|
+
|
|
990
|
+
s = schema.dup
|
|
991
|
+
prop_key = s.key?(:properties) ? :properties : "properties"
|
|
992
|
+
props = (s[prop_key] || {}).dup
|
|
993
|
+
unless props.key?(Intent::INTENT_PARAM_NAME) || props.key?(Intent::INTENT_PARAM_NAME.to_sym)
|
|
994
|
+
props[Intent::INTENT_PARAM_NAME] = Intent.param_json_schema
|
|
995
|
+
end
|
|
996
|
+
s[prop_key] = props
|
|
997
|
+
type_key = s.key?(:type) ? :type : "type"
|
|
998
|
+
s[type_key] ||= "object"
|
|
999
|
+
s
|
|
1000
|
+
rescue StandardError
|
|
1001
|
+
schema
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# Loud + fail-open: route a single, clear diagnostic through on_error (whose
|
|
1005
|
+
# DEFAULT warns to stderr, so this is loud out of the box) when no supported
|
|
1006
|
+
# shape could be instrumented. Capture never raises into the host — but it
|
|
1007
|
+
# never silently no-ops either. Deduped via report_once.
|
|
1008
|
+
def warn_uninstrumentable(server)
|
|
1009
|
+
cls = (server.class.name rescue nil) || "the given server"
|
|
1010
|
+
report_once(
|
|
1011
|
+
:no_introspect,
|
|
1012
|
+
RuntimeError.new(
|
|
1013
|
+
"could not introspect #{cls} — no tool calls will be captured. " \
|
|
1014
|
+
"Pass a supported MCP server (the official mcp gem's MCP::Server, or a " \
|
|
1015
|
+
"tools-hash/fast-mcp server), or capture manually with #wrap / #record. See the README."
|
|
1016
|
+
)
|
|
1017
|
+
)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
734
1020
|
def discover_tools(server)
|
|
735
1021
|
%i[tools registered_tools].each do |m|
|
|
736
1022
|
return server.public_send(m) if server.respond_to?(m)
|
|
@@ -813,7 +1099,7 @@ module Mcpeye
|
|
|
813
1099
|
def normalize_identity(identity)
|
|
814
1100
|
h = identity.is_a?(Hash) ? identity : {}
|
|
815
1101
|
out = {}
|
|
816
|
-
%w[userId client serverVersion].each do |k|
|
|
1102
|
+
%w[userId userEmail client serverVersion].each do |k|
|
|
817
1103
|
v = h[k.to_sym]
|
|
818
1104
|
v = h[k] if v.nil?
|
|
819
1105
|
next if v.nil?
|
data/lib/mcpeye/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcpeye
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mcpeye
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: mcpeye captures what your agents try to do through your MCP tools — including
|
|
14
14
|
the asks your tools could NOT fulfill — and ships them to a self-hosted mcpeye instance
|