mcpeye 0.1.2 → 0.1.3
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 +24 -4
- data/lib/mcpeye/tracker.rb +252 -8
- 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: 5b65ed38eb2861006ebcd79b5592922e1281d71fce912dec5543b01cba961244
|
|
4
|
+
data.tar.gz: 3184681a41e2669cf3b9c240acad0bd4418d811fdbc97bb988ac82b5533ec42b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe8f68cdae22753dd5ff53e284e0c493e313e4f9dca104e50f3a9be6d5ae6147d19b8215f0f288989ae8e30c2e3c4134d02a85ee4cbd12cebc2f07241e9dd2e8
|
|
7
|
+
data.tar.gz: dd3ca7ff1636b5a7b1cd424c2a854e095cddb331c199c4c7775737a0f2e7bd88a1cf861e5b59b112cf04a114cfb424c565173145356829dd6c735447b0008d68
|
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
|
|
data/lib/mcpeye/tracker.rb
CHANGED
|
@@ -44,6 +44,115 @@ module Mcpeye
|
|
|
44
44
|
# custom Rack handler, ...), so #instrument duck-types the common shapes and
|
|
45
45
|
# degrades gracefully when it cannot introspect a server — you can always
|
|
46
46
|
# capture calls manually with #wrap / #record.
|
|
47
|
+
|
|
48
|
+
# Prepended to an official `MCP::Server`'s SINGLETON class so its `call_tool` is
|
|
49
|
+
# captured at the dispatch layer — the Ruby analog of the TS/Python protocol-level
|
|
50
|
+
# hook. The official gem invokes `call_tool` directly (mcp 0.20 server.rb), running
|
|
51
|
+
# required-arg + schema validation there, so wrapping it (not the tool classes) also
|
|
52
|
+
# captures the validation failures the gem returns BEFORE a tool's own `.call` —
|
|
53
|
+
# exactly the failed asks mcpeye exists to surface.
|
|
54
|
+
#
|
|
55
|
+
# Prepended PER SERVER (this instance's singleton), never to the global `MCP::Tool`
|
|
56
|
+
# subclasses, so a tool class shared across servers is never bound to one tracker.
|
|
57
|
+
# Fail-open: the host's return value is always passed through unchanged, and the
|
|
58
|
+
# injected `mcpeyeIntent` is stripped from the arguments BEFORE `super`, so the gem
|
|
59
|
+
# never splats it into the host tool's keyword signature (which would raise
|
|
60
|
+
# `unknown keyword: :mcpeyeIntent` and break the call).
|
|
61
|
+
module OfficialServerCapture
|
|
62
|
+
def call_tool(request, **kwargs)
|
|
63
|
+
tracker = @__mcpeye_tracker
|
|
64
|
+
return super if tracker.nil?
|
|
65
|
+
|
|
66
|
+
name = (request[:name] || request["name"]).to_s
|
|
67
|
+
raw_args = request[:arguments] || request["arguments"]
|
|
68
|
+
args = raw_args.is_a?(Hash) ? raw_args : {}
|
|
69
|
+
|
|
70
|
+
# Strip the injected intent in place (request[:arguments] is the same object),
|
|
71
|
+
# so the gem validates/dispatches WITHOUT mcpeyeIntent.
|
|
72
|
+
intent = nil
|
|
73
|
+
begin
|
|
74
|
+
v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME)
|
|
75
|
+
v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME.to_sym) if v.nil?
|
|
76
|
+
intent = v if v.is_a?(String) && !v.strip.empty?
|
|
77
|
+
rescue StandardError
|
|
78
|
+
intent = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reserved capability tool: never dispatch to the host; answer locally.
|
|
82
|
+
if name == Mcpeye::RequestCapability::TOOL_NAME && tracker.capture_missing_capabilities?
|
|
83
|
+
return tracker.answer_request_capability_official(args)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
87
|
+
begin
|
|
88
|
+
response = super
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
|
|
91
|
+
begin
|
|
92
|
+
tracker.record(name, args, is_error: true, error_message: e.message, intent: intent, duration_ms: dur)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
raise
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
|
|
101
|
+
is_error, error_text, result_payload = Mcpeye::OfficialServerCapture.classify(response)
|
|
102
|
+
if is_error
|
|
103
|
+
tracker.record(name, args, is_error: true, error_message: error_text, intent: intent, duration_ms: dur)
|
|
104
|
+
else
|
|
105
|
+
tracker.record(name, args, result: result_payload, intent: intent, duration_ms: dur)
|
|
106
|
+
end
|
|
107
|
+
rescue StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
response
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The official gem's call_tool returns EITHER a Hash (`{content:, isError:}`,
|
|
115
|
+
# the common 0.2x case) OR a Tool::Response object. Classify both into
|
|
116
|
+
# [is_error, error_text, result_payload]. Fail-open: unknown shapes -> success.
|
|
117
|
+
def self.classify(response)
|
|
118
|
+
if response.respond_to?(:error?)
|
|
119
|
+
payload =
|
|
120
|
+
if response.respond_to?(:structured_content) && response.structured_content
|
|
121
|
+
response.structured_content
|
|
122
|
+
elsif response.respond_to?(:content)
|
|
123
|
+
{ "content" => response.content }
|
|
124
|
+
else
|
|
125
|
+
response
|
|
126
|
+
end
|
|
127
|
+
[!!response.error?, text_of_content(response.respond_to?(:content) ? response.content : nil), payload]
|
|
128
|
+
elsif response.is_a?(Hash)
|
|
129
|
+
flag = response["isError"]
|
|
130
|
+
flag = response[:isError] if flag.nil?
|
|
131
|
+
[!!flag, text_of_content(response["content"] || response[:content]), response]
|
|
132
|
+
else
|
|
133
|
+
[false, nil, response]
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError
|
|
136
|
+
[false, nil, response]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Join the text parts of an MCP content array (Hash or struct items).
|
|
140
|
+
def self.text_of_content(content)
|
|
141
|
+
return nil unless content.is_a?(Array)
|
|
142
|
+
|
|
143
|
+
parts = content.filter_map do |item|
|
|
144
|
+
if item.is_a?(Hash)
|
|
145
|
+
item[:text] || item["text"]
|
|
146
|
+
elsif item.respond_to?(:text)
|
|
147
|
+
item.text
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
parts.empty? ? nil : parts.join("\n")
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
47
156
|
class Tracker
|
|
48
157
|
# POST once the buffer reaches this many events (eager flush). When a
|
|
49
158
|
# background flush thread is running it is woken to drain off the hot path;
|
|
@@ -143,26 +252,42 @@ module Mcpeye
|
|
|
143
252
|
# shape matches — you can still use #wrap / #record. Reports once if nothing
|
|
144
253
|
# could be introspected.
|
|
145
254
|
def instrument(server)
|
|
255
|
+
# Official mcp gem (MCP::Server + MCP::Tool subclasses): dispatch-level,
|
|
256
|
+
# per-server capture. Distinct from the duck-typed path below, whose tools
|
|
257
|
+
# are handler-Hashes the official gem never produces.
|
|
258
|
+
return instrument_official(server) if official_mcp_server?(server)
|
|
259
|
+
|
|
146
260
|
injected = inject_count(server)
|
|
147
261
|
# Add the reserved tool AFTER intent injection (so it never gets an mcpeyeIntent
|
|
148
262
|
# param) and BEFORE handler wrapping (so its pre-wrapped handler is skipped, not
|
|
149
263
|
# double-wrapped). A no-op when capture_missing_capabilities is off.
|
|
150
264
|
append_request_capability_tool(server)
|
|
151
265
|
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
|
|
266
|
+
warn_uninstrumentable(server) if injected.zero? && wrapped.zero?
|
|
160
267
|
server
|
|
161
268
|
rescue StandardError => e
|
|
162
269
|
@on_error.call(e)
|
|
163
270
|
server
|
|
164
271
|
end
|
|
165
272
|
|
|
273
|
+
# Whether the reserved mcpeye_request_capability tool is enabled. Read by the
|
|
274
|
+
# official-server capture module (which lives outside the Tracker instance).
|
|
275
|
+
def capture_missing_capabilities?
|
|
276
|
+
@capture_missing_capabilities
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Record a reserved-capability call and return the canned ack as a plain
|
|
280
|
+
# CallToolResult Hash. The official gem serializes call_tool's return value
|
|
281
|
+
# directly (it does NOT call #to_h), so a Hash — not a Tool::Response object —
|
|
282
|
+
# is what reaches the client.
|
|
283
|
+
def answer_request_capability_official(_args = {})
|
|
284
|
+
handle_request_capability(_args)
|
|
285
|
+
{ "content" => [{ "type" => "text", "text" => RequestCapability::ACK }], "isError" => false }
|
|
286
|
+
rescue StandardError => e
|
|
287
|
+
@on_error.call(e)
|
|
288
|
+
{ "content" => [{ "type" => "text", "text" => RequestCapability::ACK }], "isError" => false }
|
|
289
|
+
end
|
|
290
|
+
|
|
166
291
|
# Inject the optional mcpeyeIntent property into every discoverable tool's
|
|
167
292
|
# input schema. Returns the server (fail-open). Collision-safe (never clobbers
|
|
168
293
|
# a tool that already declares mcpeyeIntent), never touches `required`, and
|
|
@@ -731,6 +856,125 @@ module Mcpeye
|
|
|
731
856
|
{ "content" => [{ "type" => "text", "text" => RequestCapability::ACK }] }
|
|
732
857
|
end
|
|
733
858
|
|
|
859
|
+
# --- official mcp gem (MCP::Server) support --------------------------
|
|
860
|
+
|
|
861
|
+
def official_mcp_server?(server)
|
|
862
|
+
defined?(MCP::Server) && server.is_a?(MCP::Server)
|
|
863
|
+
rescue StandardError
|
|
864
|
+
false
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# Dispatch-level, per-server instrumentation of an official MCP::Server.
|
|
868
|
+
def instrument_official(server)
|
|
869
|
+
server.instance_variable_set(:@__mcpeye_tracker, self)
|
|
870
|
+
listed = wrap_official_list_tools(server)
|
|
871
|
+
called = wrap_official_call_tool(server)
|
|
872
|
+
warn_uninstrumentable(server) unless listed || called
|
|
873
|
+
server
|
|
874
|
+
rescue StandardError => e
|
|
875
|
+
@on_error.call(e)
|
|
876
|
+
server
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Capture: prepend the singleton-class hook so this server's call_tool is wrapped.
|
|
880
|
+
def wrap_official_call_tool(server)
|
|
881
|
+
sc = server.singleton_class
|
|
882
|
+
sc.prepend(OfficialServerCapture) unless sc.include?(OfficialServerCapture)
|
|
883
|
+
true
|
|
884
|
+
rescue StandardError => e
|
|
885
|
+
@on_error.call(e)
|
|
886
|
+
false
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Advertise: replace this server's "tools/list" handler entry so the listed
|
|
890
|
+
# schemas carry mcpeyeIntent and the reserved tool. @handlers is read at dispatch
|
|
891
|
+
# time, so swapping the entry is seen; mutating the RESPONSE never touches the
|
|
892
|
+
# global tool classes.
|
|
893
|
+
def wrap_official_list_tools(server)
|
|
894
|
+
handlers = server.instance_variable_get(:@handlers)
|
|
895
|
+
return false unless handlers.is_a?(Hash)
|
|
896
|
+
|
|
897
|
+
orig = handlers["tools/list"]
|
|
898
|
+
return false if orig.nil?
|
|
899
|
+
return true if orig.respond_to?(:mcpeye_wrapped?) && orig.mcpeye_wrapped?
|
|
900
|
+
|
|
901
|
+
add_reserved = @capture_missing_capabilities
|
|
902
|
+
wrapped = lambda do |request|
|
|
903
|
+
result = orig.call(request)
|
|
904
|
+
begin
|
|
905
|
+
augment_official_list_result(result, add_reserved)
|
|
906
|
+
rescue StandardError => e
|
|
907
|
+
@on_error.call(e)
|
|
908
|
+
end
|
|
909
|
+
result
|
|
910
|
+
end
|
|
911
|
+
wrapped.define_singleton_method(:mcpeye_wrapped?) { true }
|
|
912
|
+
handlers["tools/list"] = wrapped
|
|
913
|
+
true
|
|
914
|
+
rescue StandardError => e
|
|
915
|
+
@on_error.call(e)
|
|
916
|
+
false
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def augment_official_list_result(result, add_reserved)
|
|
920
|
+
return unless result.is_a?(Hash)
|
|
921
|
+
|
|
922
|
+
tools = result[:tools] || result["tools"]
|
|
923
|
+
return unless tools.is_a?(Array)
|
|
924
|
+
|
|
925
|
+
tools.each do |tool|
|
|
926
|
+
next unless tool.is_a?(Hash)
|
|
927
|
+
|
|
928
|
+
key = if tool.key?(:inputSchema) then :inputSchema
|
|
929
|
+
elsif tool.key?("inputSchema") then "inputSchema"
|
|
930
|
+
end
|
|
931
|
+
next if key.nil?
|
|
932
|
+
|
|
933
|
+
schema = tool[key]
|
|
934
|
+
tool[key] = inject_intent_into_schema(schema) if schema.is_a?(Hash)
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
if add_reserved && tools.none? { |t| t.is_a?(Hash) && (t[:name] || t["name"]) == RequestCapability::TOOL_NAME }
|
|
938
|
+
tools << RequestCapability.descriptor
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Key-style-aware intent injection: official `to_h` schemas use symbol keys, so
|
|
943
|
+
# write back under the SAME key style (never produce both :properties and
|
|
944
|
+
# "properties", which would serialize to a duplicate JSON key). Collision-safe.
|
|
945
|
+
def inject_intent_into_schema(schema)
|
|
946
|
+
return schema unless Intent.object_shaped?(schema)
|
|
947
|
+
|
|
948
|
+
s = schema.dup
|
|
949
|
+
prop_key = s.key?(:properties) ? :properties : "properties"
|
|
950
|
+
props = (s[prop_key] || {}).dup
|
|
951
|
+
unless props.key?(Intent::INTENT_PARAM_NAME) || props.key?(Intent::INTENT_PARAM_NAME.to_sym)
|
|
952
|
+
props[Intent::INTENT_PARAM_NAME] = Intent.param_json_schema
|
|
953
|
+
end
|
|
954
|
+
s[prop_key] = props
|
|
955
|
+
type_key = s.key?(:type) ? :type : "type"
|
|
956
|
+
s[type_key] ||= "object"
|
|
957
|
+
s
|
|
958
|
+
rescue StandardError
|
|
959
|
+
schema
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
# Loud + fail-open: route a single, clear diagnostic through on_error (whose
|
|
963
|
+
# DEFAULT warns to stderr, so this is loud out of the box) when no supported
|
|
964
|
+
# shape could be instrumented. Capture never raises into the host — but it
|
|
965
|
+
# never silently no-ops either. Deduped via report_once.
|
|
966
|
+
def warn_uninstrumentable(server)
|
|
967
|
+
cls = (server.class.name rescue nil) || "the given server"
|
|
968
|
+
report_once(
|
|
969
|
+
:no_introspect,
|
|
970
|
+
RuntimeError.new(
|
|
971
|
+
"could not introspect #{cls} — no tool calls will be captured. " \
|
|
972
|
+
"Pass a supported MCP server (the official mcp gem's MCP::Server, or a " \
|
|
973
|
+
"tools-hash/fast-mcp server), or capture manually with #wrap / #record. See the README."
|
|
974
|
+
)
|
|
975
|
+
)
|
|
976
|
+
end
|
|
977
|
+
|
|
734
978
|
def discover_tools(server)
|
|
735
979
|
%i[tools registered_tools].each do |m|
|
|
736
980
|
return server.public_send(m) if server.respond_to?(m)
|
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.3
|
|
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
|