mcpeye 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1e03d773ff0e269c253e95b6c9ab2bc1397a128ff04dd10fe1946ca2abf225d
4
- data.tar.gz: '08536ff4f7bf9b3dd60191719a9b7e1197e1d4340e96816a2aa1a80283805ced'
3
+ metadata.gz: 5b65ed38eb2861006ebcd79b5592922e1281d71fce912dec5543b01cba961244
4
+ data.tar.gz: 3184681a41e2669cf3b9c240acad0bd4418d811fdbc97bb988ac82b5533ec42b
5
5
  SHA512:
6
- metadata.gz: 44cecb923f26ef7dd4356731807879b19369c7e38cadc95ac21625645f20d8745bfd625e804402ba0a8771ba8eeba223aabd497e15fe193b36ef2ba6938ce55c
7
- data.tar.gz: 95b4cbd7133bbf670c015aa04a6cc38eb000c34a96f527d19a48dffcd4e447992034f2179cda63bbe7194d96d799decfca1c5dad1826485a3b3c3fd3d707777e
6
+ metadata.gz: fe8f68cdae22753dd5ff53e284e0c493e313e4f9dca104e50f3a9be6d5ae6147d19b8215f0f288989ae8e30c2e3c4134d02a85ee4cbd12cebc2f07241e9dd2e8
7
+ data.tar.gz: dd3ca7ff1636b5a7b1cd424c2a854e095cddb331c199c4c7775737a0f2e7bd88a1cf861e5b59b112cf04a114cfb424c565173145356829dd6c735447b0008d68
data/README.md CHANGED
@@ -8,8 +8,7 @@ agents try to do through your MCP tools — and, crucially, the asks your tools
8
8
  instance. There the worker clusters sessions into the **Intent Gap Report**: the
9
9
  top user asks your tools attempted but failed to deliver.
10
10
 
11
- This gem is the dogfooding SDK for the mcpeye team's own Rails-based MCP server,
12
- and for any Ruby shop running one. It is **pure stdlib at runtime** (`net/http`,
11
+ This gem instruments any Ruby / Rails MCP server. It is **pure stdlib at runtime** (`net/http`,
13
12
  `json`, `securerandom`) and **never raises into, or alters, the host server**.
14
13
  Capture is O(1); set `flush_interval:` to ship telemetry off the tool-call thread
15
14
  (see [Options](#options) for the zero-thread default's one caveat).
@@ -92,10 +91,30 @@ tracker = Mcpeye.track(
92
91
  # idempotent.)
93
92
  ```
94
93
 
95
- `Mcpeye.track` injects `mcpeyeIntent` into discoverable tool schemas and wraps
96
- discoverable handlers automatically. Ruby MCP server shapes vary, so when the
97
- internals can't be introspected `track` returns a working tracker unchanged (and
98
- reports once via `on_error`) — instrument manually with `#wrap` / `#record`.
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`.
99
118
 
100
119
  ## Options
101
120
 
@@ -134,7 +153,7 @@ Identity values (`userId`/`client`/`serverVersion`) are coerced to strings befor
134
153
  they're sent (the ingest contract requires strings), so an integer id is fine —
135
154
  but pass an **opaque, non-PII** value.
136
155
 
137
- ## Dogfooding in a Rails MCP server
156
+ ## Use in a Rails MCP server
138
157
 
139
158
  `config/initializers/mcpeye.rb`:
140
159
 
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mcpeye
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
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.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-21 00:00:00.000000000 Z
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