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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a764f79cce8846c6a9bcf750e778de9f88c082518eac5c6c4d271c15743456a4
4
- data.tar.gz: c1621b94ab8dc5689cd4b335d1bb4fd79b854c430ea83754c81feb138b510150
3
+ metadata.gz: 3d50e6d30ef0e2cf6796b7b7b67397c40c11290f289152d124c3ff5417ff6150
4
+ data.tar.gz: 23729136fae41b35dcf3152354d59fd5e2753d7429831b1f474bc01de56c6d5a
5
5
  SHA512:
6
- metadata.gz: 5b0c8dd68029607b4126f3f0f9423e560b3a278919edd449498cd7bf3cb11f9edbd14da33b705579a6c719e89d6167c4ac92c47a1f660505c30e1ae27c7e5339
7
- data.tar.gz: e88ec3f95170142f0e5fed4aeff5a2e6ad8c1821c818b257c01e0066dedb03e06b0522ebf235ad3379800aeaee010e9f179d3ffc294d9e33c6dd43fb5f35deb9
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` injects `mcpeyeIntent` into discoverable tool schemas and wraps
95
- discoverable handlers automatically. Ruby MCP server shapes vary, so when the
96
- internals can't be introspected `track` returns a working tracker unchanged (and
97
- 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`.
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
- # Per-request identity: evaluated once per flush, so a thread/request-local
149
- # value is attributed correctly. Pass an OPAQUE, non-PII id (coerced to a string).
150
- identify: -> { { userId: Current.user_id&.to_s, client: Current.client, serverVersion: MyApp::VERSION } },
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
@@ -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? }, events: [...] }
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
- # 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.
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mcpeye
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.4"
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.2
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-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