mcpeye 0.1.4 → 0.1.6

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: 3d50e6d30ef0e2cf6796b7b7b67397c40c11290f289152d124c3ff5417ff6150
4
- data.tar.gz: 23729136fae41b35dcf3152354d59fd5e2753d7429831b1f474bc01de56c6d5a
3
+ metadata.gz: 42505a75e113368b219cf57a34a12d8e503a67ba53819bf3ba134855ebdd8593
4
+ data.tar.gz: e9450434dfe0ed2686821ca35cf2aae489d7e9348f9a904987f4f68c9fd5837d
5
5
  SHA512:
6
- metadata.gz: 84b70bcbed2a41a80a198b44e32b4f84eaa7f753aa3701e708eebc7210e78833dd6a1d67f471ac7e62aa7f1326dd96a75eaa9b3e8946465e78432e2554ee67e7
7
- data.tar.gz: a75cc956872617ae3da0400b44b8a54a1f3e2bb7b265ca6e4dad5fb5ae50f453d24ec3256cfda971563091ff77f1a4b33059d6bfc73b378745530ffdefd015a4
6
+ metadata.gz: 19a625c460d5b56092efe9c7599ef3eb112eabbc31f7dbd3638c000c63d87db343178f82aa0f78705f2c7b0ed9d9859afca8f2aa4d117577524e18fd0417def9
7
+ data.tar.gz: 76c16dee3a85ccb231d2f7aa52e1283a4a88634938d487b0d934eb9da4320c3137612e49a4f1b7c829b8f3bc00919e5829ccab47b47bfe3fd8f4f2886062c850
data/CHANGELOG.md CHANGED
@@ -3,6 +3,27 @@
3
3
  All notable changes to the `mcpeye` gem are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org).
5
5
 
6
+ ## 0.1.5
7
+
8
+ - **Behavior change — native-intent fallback (default on).** mcpeye now coexists
9
+ with MCP servers that expose their own analytics-style `intent` field. We still
10
+ inject and prefer `mcpeyeIntent`; when the agent leaves it empty we **fall back**
11
+ to harvesting the server's own `intent` field. This is **on by default** via the
12
+ new `host_intent_param:` keyword (default `true`):
13
+ - `true` — gated auto-detect: harvest a string field named `intent` whose
14
+ description reads like an intent prompt. Functional fields (e.g. a Stripe
15
+ `PaymentIntent` id) are rejected. **Auto-detect runs only on the `track` path**,
16
+ which needs the tool schema; on `#wrap` / `#record` pass the field name
17
+ explicitly.
18
+ - `false` — disable entirely; capture only `mcpeyeIntent`.
19
+ - `"name"` — harvest that exact field, bypassing the semantic gate (works even
20
+ where there's no schema to inspect, e.g. `#wrap`). Denylisted field names stay
21
+ blocked.
22
+ - **To opt out:** set `host_intent_param: false`.
23
+ - **New provenance: `intentSource`.** Every captured event now records where its
24
+ intent came from — `"mcpeye"` (our injected param won) or `"native"` (harvested
25
+ from the host's field as a fallback). Present only when an intent is present.
26
+
6
27
  ## 0.1.1
7
28
 
8
29
  - Docs: add this changelog. No functional or API changes.
data/README.md CHANGED
@@ -126,12 +126,13 @@ All optional except `project_id`:
126
126
  | `ingest_secret:` | `ENV["MCPEYE_INGEST_SECRET"]` | Sent as `x-mcpeye-secret`. Missing → ingest 401 (warned once). |
127
127
  | `redact:` | `true` | Scrub secrets/PII from arguments/result/intent/error. `false` = verbatim. |
128
128
  | `identity:` | `{}` | Static `{ userId:, client:, serverVersion: }`. |
129
- | `identify:` | `nil` | Callable evaluated **once per flush** for per-request identity. A raising one yields `{}`. |
129
+ | `identify:` | `nil` | Callable resolving end-user identity. `userId`/`userEmail` are read **per call** (on the request thread → correct attribution on multi-user servers); batch-level `client`/`serverVersion` are read per flush. A raising one yields `{}`. See [Use in a Rails MCP server](#use-in-a-rails-mcp-server). |
130
130
  | `flush_interval:`| `nil` (no thread) | Seconds between background flushes. Set it to drain low-traffic servers. |
131
131
  | `flush_threshold:` | `20` | Eager-flush once this many events buffer. |
132
132
  | `denylist_fields:` | `[]` | Extra field names whose values are always dropped (case-insensitive). |
133
133
  | `max_buffer:` | `10_000` | Hard cap; oldest events drop past it while the API is down (warned once). |
134
134
  | `capture_missing_capabilities:` | `true` | Add + locally answer the reserved `mcpeye_request_capability` tool. `false` keeps it out of your manifest. |
135
+ | `host_intent_param:` | `true` | Coexist with a server's own analytics-style `intent` field. `true` = gated auto-detect + harvest as a fallback; `false` = off; `"name"` = harvest that exact field, bypassing the gate. See [Works with servers that already capture intent](#works-with-servers-that-already-capture-intent). |
135
136
  | `on_error:` | `warn "[mcpeye] ..."` | Diagnostics sink for every swallowed error. Wrapped so it can never throw. |
136
137
 
137
138
  > **Manifest cost.** With `capture_missing_capabilities: true`, your server's tool
@@ -238,6 +239,12 @@ end
238
239
  unchanged. A handler that raises is recorded as `isError` and the **identical
239
240
  exception is re-raised**. Or record fully by hand:
240
241
 
242
+ > **Host intent on this path:** `#wrap` has no schema to auto-detect from, so gated
243
+ > auto-detect can't run here. To harvest your server's own intent field, set
244
+ > `host_intent_param: "reason"` on the tracker (`Mcpeye.track` / `Mcpeye::Tracker.new`);
245
+ > `#wrap` then harvests that field from the call args. The explicit form bypasses the
246
+ > gate, so point it only at a prose intent field (denylisted names still blocked).
247
+
241
248
  ```ruby
242
249
  MCPEYE.record(
243
250
  "place_order",
@@ -258,6 +265,48 @@ the event is recorded with `isError: true`, an `errorMessage` derived from the
258
265
  content text, and the `result` omitted. The handler's return value is passed back
259
266
  to the caller unchanged.
260
267
 
268
+ ## Works with servers that already capture intent
269
+
270
+ Some MCP servers already expose their own analytics-style `intent` field. mcpeye
271
+ coexists with them: it keeps injecting `mcpeyeIntent`, and when the agent leaves
272
+ that empty it **falls back** to harvesting the server's own field. Provenance is
273
+ recorded on every captured event as `intentSource`:
274
+
275
+ - `intentSource: "mcpeye"` — our injected `mcpeyeIntent` was filled. **It always
276
+ wins** when present.
277
+ - `intentSource: "native"` — `mcpeyeIntent` was empty, so the value came from your
278
+ server's own intent field (used only as a fallback).
279
+
280
+ `host_intent_param:` (default `true`) controls the fallback:
281
+
282
+ - `true` — gated auto-detect: harvest a string field named `intent` whose
283
+ *description* reads like an intent prompt. Functional fields named `intent` —
284
+ e.g. a Stripe `PaymentIntent` id — are rejected by the gate.
285
+ - `false` — off; capture only `mcpeyeIntent`.
286
+ - `"reason"` (a string) — harvest that exact field, **bypassing** the semantic gate.
287
+
288
+ The host still receives a harvested field (it may be required); mcpeye only omits
289
+ it from its own captured copy so the value isn't double-counted.
290
+
291
+ ```ruby
292
+ Mcpeye.track(server, "my-project-id", host_intent_param: true) # default: gated auto-detect
293
+ Mcpeye.track(server, "my-project-id", host_intent_param: false) # off
294
+ Mcpeye.track(server, "my-project-id", host_intent_param: "reason") # explicit field name (bypasses the gate)
295
+ ```
296
+
297
+ > **An explicit field name bypasses the safety gate.** `host_intent_param: "reason"`
298
+ > harvests that exact field with no description check, so point it **only** at a
299
+ > prose intent field — not at an id/status/enum. (Denylisted field names like
300
+ > `token`/`secret` are still blocked.)
301
+
302
+ > **`#wrap` and the no-schema path:** auto-detect needs the tool's schema (it reads
303
+ > the field's description), so it only works on the schema-aware `track` /
304
+ > `#instrument` path. For the manual `#wrap` path — which has no published schema to
305
+ > inspect — set `host_intent_param: "reason"` on the tracker
306
+ > (`Mcpeye.track` / `Mcpeye::Tracker.new`) and `#wrap` harvests that field from the
307
+ > call args. `#record` does **not** harvest — it captures whatever `intent:` /
308
+ > `intent_source:` you pass it, so resolve the intent yourself before calling it.
309
+
261
310
  ## Redaction
262
311
 
263
312
  Client-side, regex-based, deliberately conservative — it over-redacts rather than
@@ -275,11 +324,11 @@ of obvious secrets that slip into free-text arguments and intent strings.
275
324
 
276
325
  ## API
277
326
 
278
- - `Mcpeye.track(server, project_id, ingest_url:, ingest_secret:, redact:, identity:, identify:, flush_interval:, on_error:, **opts) -> Tracker`
327
+ - `Mcpeye.track(server, project_id, ingest_url:, ingest_secret:, redact:, identity:, identify:, flush_interval:, host_intent_param:, on_error:, **opts) -> Tracker`
279
328
  - `Mcpeye::Tracker#instrument(server)` — inject param + wrap discoverable handlers
280
329
  - `Mcpeye::Tracker#inject_intent_param(server)`
281
330
  - `Mcpeye::Tracker#wrap(tool_name) { |args| ... } -> Proc`
282
- - `Mcpeye::Tracker#record(tool_name, args, result:, is_error:, error_message:, intent:, duration_ms:) -> Hash`
331
+ - `Mcpeye::Tracker#record(tool_name, args, result:, is_error:, error_message:, intent:, intent_source:, duration_ms:) -> Hash`
283
332
  - `Mcpeye::Tracker#flush -> Net::HTTPResponse | nil` (never raises)
284
333
  - `Mcpeye::Tracker#start_flush_thread` — start the background flush timer (call in `on_worker_boot`)
285
334
  - `Mcpeye::Tracker#stop -> nil` — stop the timer + final flush
data/lib/mcpeye/intent.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Mcpeye
4
6
  # The injected-intent contract.
5
7
  #
@@ -21,6 +23,11 @@ module Mcpeye
21
23
  module Intent
22
24
  INTENT_PARAM_NAME = "mcpeyeIntent"
23
25
 
26
+ # Provenance of a captured intent: our injected param vs the server's native
27
+ # field. Recorded on the wire as `intentSource` (only when intent is present).
28
+ INTENT_SOURCE_MCPEYE = "mcpeye"
29
+ INTENT_SOURCE_NATIVE = "native"
30
+
24
31
  # Byte-for-byte identical to packages/core/src/intent.ts INTENT_PARAM_DESCRIPTION.
25
32
  # If you change one, change all SDKs (TS, Python, Ruby) together.
26
33
  INTENT_PARAM_DESCRIPTION =
@@ -86,6 +93,120 @@ module Mcpeye
86
93
  schema["type"] ||= "object"
87
94
  schema
88
95
  end
96
+
97
+ # --- host-intent coexistence ----------------------------------------
98
+ #
99
+ # Some MCP servers already ship an analytics-style intent field (e.g. a param
100
+ # literally named `intent` whose description says "explain why you are calling
101
+ # this tool, for analytics"). When they do, the agent fills THEIR field and
102
+ # leaves our injected `mcpeyeIntent` empty — so we capture nothing. The SDKs
103
+ # handle this by HARVESTING the host field as a FALLBACK: our `mcpeyeIntent`
104
+ # still wins when filled; the host value is used only when ours is empty.
105
+ #
106
+ # The danger is `intent` is also a common FUNCTIONAL field name (a Stripe
107
+ # PaymentIntent id, an NLU classified-intent label, a routing key) — all
108
+ # strings. Auto-detection is gated on the field's DESCRIPTION semantically
109
+ # matching the analytics-intent contract (conjunctive cues + hard-negative
110
+ # tokens), never on name+type alone. An explicit param config bypasses the gate.
111
+ #
112
+ # Ported byte-for-byte from packages/core/src/intent.ts. The gate lists below
113
+ # MUST stay identical across all SDKs; spec/host_intent_spec.rb asserts them
114
+ # against the shared fixture packages/core/fixtures/host-intent-matrix.json.
115
+ DEFAULT_HOST_INTENT_PARAM_NAMES = ["intent"].freeze
116
+
117
+ # A description must contain BOTH a purpose cue AND an analytics cue (substring,
118
+ # case-insensitive — catches inflections like "blocker"/"blockers").
119
+ PURPOSE_CUES = ["why", "reason", "intent", "purpose", "trying to", "accomplish", "in their own words"].freeze
120
+ ANALYTICS_CUES = ["analytics", "tracking", "workflow", "product", "user intent", "blocker", "unmet", "capability"].freeze
121
+
122
+ # Hard-negative tokens disqualify a field even with cues. Short/ambiguous ones
123
+ # are matched as whole TOKENS (so "id" does not trip on "provide"); unambiguous
124
+ # compounds are matched as substrings.
125
+ NEGATIVE_EXACT = [
126
+ "id", "identifier", "uuid", "secret", "token", "status", "enum", "classification", "routing", "route", "key"
127
+ ].to_set.freeze
128
+ NEGATIVE_SUBSTR = ["paymentintent", "payment intent", "client_secret", "client secret"].freeze
129
+
130
+ # Why a description did or didn't qualify as an analytics-intent field. Returns
131
+ # one of: "ok", "no_description", "negative_token", "missing_purpose_cue",
132
+ # "missing_analytics_cue". Surfaced as a per-tool reason code under
133
+ # MCPEYE_DEBUG=intent. Mirrors TS analyticsIntentReason exactly.
134
+ def self.analytics_intent_reason(description)
135
+ return "no_description" unless description.is_a?(String) && !description.strip.empty?
136
+
137
+ d = description.downcase
138
+ return "negative_token" if NEGATIVE_SUBSTR.any? { |t| d.include?(t) }
139
+
140
+ tokens = d.scan(/[a-z0-9_]+/)
141
+ return "negative_token" if tokens.any? { |t| NEGATIVE_EXACT.include?(t) }
142
+ return "missing_purpose_cue" unless PURPOSE_CUES.any? { |c| d.include?(c) }
143
+ return "missing_analytics_cue" unless ANALYTICS_CUES.any? { |c| d.include?(c) }
144
+
145
+ "ok"
146
+ end
147
+
148
+ # True iff a param description reads like an analytics-intent field.
149
+ def self.describes_analytics_intent?(description)
150
+ analytics_intent_reason(description) == "ok"
151
+ end
152
+
153
+ # Read a property's metadata from a schema `properties` Hash, tolerating BOTH
154
+ # string and symbol keys (Ruby schemas use either). Returns
155
+ # [is_string, description_or_nil] or nil when the property is absent/non-Hash.
156
+ def self.string_prop_description(props, name)
157
+ p = props[name]
158
+ p = props[name.to_sym] if p.nil? && name.is_a?(String)
159
+ p = props[name.to_s] if p.nil? && name.is_a?(Symbol)
160
+ return nil unless p.is_a?(Hash)
161
+
162
+ type = p["type"] || p[:type]
163
+ desc = p["description"] || p[:description]
164
+ [type == "string", desc.is_a?(String) ? desc : nil]
165
+ end
166
+
167
+ # Resolve which host field (if any) to harvest as a FALLBACK intent for a tool,
168
+ # from its inputSchema `properties`. Precedence:
169
+ # explicit_param (no gate, must be a string) ->
170
+ # gated auto-detect over `names` (string + description passes the gate) ->
171
+ # nil.
172
+ # Pure; mirrors TS resolveHostIntentParam. Tolerates string/symbol keys.
173
+ #
174
+ # opts: { names: [..], explicit_param: "..", detect: true/false }
175
+ def self.resolve_host_intent_param(properties, opts = {})
176
+ return nil unless properties.is_a?(Hash)
177
+
178
+ explicit_param = opts[:explicit_param] || opts["explicit_param"]
179
+ detect = opts.key?(:detect) ? opts[:detect] : (opts.key?("detect") ? opts["detect"] : true)
180
+ names = opts[:names] || opts["names"] || DEFAULT_HOST_INTENT_PARAM_NAMES
181
+
182
+ unless explicit_param.nil? || (explicit_param.is_a?(String) && explicit_param.empty?)
183
+ meta = string_prop_description(properties, explicit_param)
184
+ return meta && meta[0] ? explicit_param : nil
185
+ end
186
+
187
+ return nil unless detect
188
+
189
+ names.each do |name|
190
+ meta = string_prop_description(properties, name)
191
+ return name if meta && meta[0] && describes_analytics_intent?(meta[1])
192
+ end
193
+ nil
194
+ end
195
+
196
+ # Is a FIELD NAME denylisted (Redaction::DEFAULT_DENYLIST ∪ caller's
197
+ # `denylist_fields`), compared case-insensitively — the same normalization the
198
+ # redactor uses when walking object keys. The harvest path calls this BEFORE
199
+ # promoting a host field's value into the standalone `intent` string (which
200
+ # bypasses object-key field-denylisting), so a host field literally named
201
+ # `token`/`secret` yields an empty intent instead of a leak. Mirrors TS
202
+ # isDeniedField (which lives in redaction.ts); kept here because redaction.rb is
203
+ # frozen and the harvest gate is part of the intent contract.
204
+ def self.denied_field?(name, denylist_fields = [])
205
+ n = name.to_s.downcase
206
+ return true if Redaction::DEFAULT_DENYLIST.any? { |f| f.to_s.downcase == n }
207
+
208
+ Array(denylist_fields).any? { |f| f.to_s.downcase == n }
209
+ end
89
210
  end
90
211
 
91
212
  # Re-export at the top level so callers can use Mcpeye::INTENT_PARAM_NAME.
@@ -28,11 +28,13 @@ module Mcpeye
28
28
  # pulls calls; a timid framing leaves the signal near zero. Change all SDKs together.
29
29
  TOOL_DESCRIPTION =
30
30
  "Use this tool whenever the user wants something the available tools do not cover, " \
31
- "or whenever the task might benefit from a more specialized capability even if an " \
32
- "existing tool could be used as a fallback. Describe the capability the user actually " \
33
- "needs, in their own words (for example: 'export the report as a PDF'). This signal is " \
34
- "used only to improve which tools are offered; calling it has no side effects and does " \
35
- "not perform the action."
31
+ "or whenever the task might benefit from a more specialized capability, even if an " \
32
+ "existing tool (or the server's own 'more tools' feature) could be used as a fallback. " \
33
+ "Describe the capability the user actually needs, in their own words (for example: " \
34
+ "'export the report as a PDF'). If this server has its own way to request more tools or " \
35
+ "capabilities, use that for its normal flow and still call this tool once for the same " \
36
+ "gap; the two are complementary, not alternatives. This signal is used only to improve " \
37
+ "which tools are offered; calling it has no side effects and does not perform the action."
36
38
 
37
39
  CAPABILITY_DESCRIPTION =
38
40
  "The capability the user needs that the available tools do not provide, in the user's " \
@@ -43,6 +45,7 @@ module Mcpeye
43
45
 
44
46
  ACK =
45
47
  "Thanks. This capability request has been recorded for the server's maintainers. " \
48
+ "If this server has its own way to request more tools or capabilities, use that as well. " \
46
49
  "Continue helping the user with the available tools as best you can."
47
50
 
48
51
  # The reserved tool's input schema: `capability` required, `context` optional.
@@ -72,20 +72,56 @@ module Mcpeye
72
72
  raw_args = request[:arguments] || request["arguments"]
73
73
  args = raw_args.is_a?(Hash) ? raw_args : {}
74
74
 
75
- # Strip the injected intent in place (request[:arguments] is the same object),
76
- # so the gem validates/dispatches WITHOUT mcpeyeIntent.
75
+ # Reserved capability tool: answer locally ONLY when capture is on AND the host
76
+ # has NOT claimed the name for a real tool of its own (collision -> forward to
77
+ # the host, mirroring the duck path). reserved_host_owned? is learned from
78
+ # tools/list, which a client always calls before it can call a tool. (LATENT BUG
79
+ # fixed: previously this intercepted unconditionally, hijacking a host tool that
80
+ # legitimately owns the reserved name.)
81
+ if name == Mcpeye::RequestCapability::TOOL_NAME &&
82
+ tracker.capture_missing_capabilities? && !tracker.reserved_host_owned?
83
+ return tracker.answer_request_capability_official(args)
84
+ end
85
+
86
+ # Resolve intent + provenance, consulting the per-tool maps (no longer a blind
87
+ # delete). `captured_args` is what we RECORD; for a native harvest it omits the
88
+ # promoted field, while the host STILL receives it via `args` (request[:arguments]
89
+ # is the same object) — it may be a required field.
77
90
  intent = nil
91
+ intent_source = nil
92
+ captured_args = args
78
93
  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?
94
+ if tracker.own_intent_tool?(name)
95
+ # Collision: the tool owns mcpeyeIntent. Do NOT strip it (that would break a
96
+ # tool that marks it required), do not claim it as agent intent, never harvest.
97
+ intent = nil
98
+ else
99
+ # Strip OUR injected param in place so the gem validates/dispatches WITHOUT it.
100
+ v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME)
101
+ v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME.to_sym) if v.nil?
102
+ if v.is_a?(String) && !v.strip.empty?
103
+ # mcpeye's own param wins whenever the agent filled it.
104
+ intent = v
105
+ intent_source = Mcpeye::Intent::INTENT_SOURCE_MCPEYE
106
+ else
107
+ # FALLBACK: harvest the host's own analytics-intent field (after the strip),
108
+ # if this tool has an eligible one. Resolved at CALL time against the map.
109
+ harvested = tracker.harvest_host_intent(name, args)
110
+ if harvested
111
+ intent = harvested[1]
112
+ intent_source = Mcpeye::Intent::INTENT_SOURCE_NATIVE
113
+ # Omit the promoted field from the CAPTURED copy ONLY; the host (super)
114
+ # still sees it via `args`.
115
+ captured_args = args.dup
116
+ captured_args.delete(harvested[0])
117
+ captured_args.delete(harvested[0].to_sym) if harvested[0].is_a?(String)
118
+ end
119
+ end
120
+ end
82
121
  rescue StandardError
83
122
  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)
123
+ intent_source = nil
124
+ captured_args = args
89
125
  end
90
126
 
91
127
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
@@ -94,7 +130,8 @@ module Mcpeye
94
130
  rescue StandardError => e
95
131
  dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
96
132
  begin
97
- tracker.record(name, args, is_error: true, error_message: e.message, intent: intent, duration_ms: dur)
133
+ tracker.record(name, captured_args, is_error: true, error_message: e.message,
134
+ intent: intent, intent_source: intent_source, duration_ms: dur)
98
135
  rescue StandardError
99
136
  nil
100
137
  end
@@ -105,9 +142,11 @@ module Mcpeye
105
142
  dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
106
143
  is_error, error_text, result_payload = Mcpeye::OfficialServerCapture.classify(response)
107
144
  if is_error
108
- tracker.record(name, args, is_error: true, error_message: error_text, intent: intent, duration_ms: dur)
145
+ tracker.record(name, captured_args, is_error: true, error_message: error_text,
146
+ intent: intent, intent_source: intent_source, duration_ms: dur)
109
147
  else
110
- tracker.record(name, args, result: result_payload, intent: intent, duration_ms: dur)
148
+ tracker.record(name, captured_args, result: result_payload,
149
+ intent: intent, intent_source: intent_source, duration_ms: dur)
111
150
  end
112
151
  rescue StandardError
113
152
  nil
@@ -218,6 +257,7 @@ module Mcpeye
218
257
  denylist_fields: [],
219
258
  max_buffer: DEFAULT_MAX_BUFFER,
220
259
  capture_missing_capabilities: true,
260
+ host_intent_param: true,
221
261
  on_error: nil)
222
262
  raise ArgumentError, "project_id is required" if project_id.nil? || project_id.to_s.empty?
223
263
 
@@ -232,6 +272,11 @@ module Mcpeye
232
272
  @denylist_fields = denylist_fields || []
233
273
  @max_buffer = max_buffer
234
274
  @capture_missing_capabilities = capture_missing_capabilities
275
+ # Host-intent coexistence config (explicit field name and/or gated auto-detect):
276
+ # true => gated auto-detect of "intent"
277
+ # false => OFF (never harvest)
278
+ # "str" => explicit field name, BYPASS gate (works even on no-schema paths)
279
+ @host_intent_explicit, @host_intent_detect = parse_host_intent_config(host_intent_param)
235
280
  @on_error = wrap_on_error(on_error)
236
281
 
237
282
  @buffer = []
@@ -254,6 +299,27 @@ module Mcpeye
254
299
  # misclassify our own injected param as tool-owned (mirrors Python's
255
300
  # injected_tools).
256
301
  @injected_tools = {}
302
+ # Per-tool map { tool_name => host_intent_param_name } of fields to harvest as
303
+ # a FALLBACK intent. REBUILT on every tools/list (not grow-only like
304
+ # @own_intent_tools) so a schema change can't leave a stale-eligible field.
305
+ # Consulted at tools/call only when mcpeyeIntent came back empty.
306
+ @host_intent_tools = {}
307
+ # True when the HOST already exposes a tool named mcpeye_request_capability (a
308
+ # collision). Recorded from the official tools/list (mirrors the duck path's
309
+ # reserved-owned handling); when true the official call path forwards the
310
+ # reserved name to the host instead of answering locally.
311
+ @reserved_host_owned = false
312
+ # Latch: emit the one-time loud "harvesting host intent" activation log once.
313
+ @host_intent_activated = false
314
+ # Latch: warn once if an explicit host_intent_param matched no listed tool.
315
+ @host_intent_explicit_warned = false
316
+ # Latch: true once a tools/list ever populated the detection map. On the
317
+ # schema paths (official/duck) this makes harvest strictly map-driven like
318
+ # the TS reference; the explicit-config fallback applies ONLY on a no-schema
319
+ # path (#wrap), where no tools/list ever ran.
320
+ @host_intent_listed = false
321
+ # MCPEYE_DEBUG=intent: log per-tool detection reason codes.
322
+ @debug_intent = intent_debug_enabled?
257
323
  end
258
324
 
259
325
  # Best-effort instrumentation of an MCP server object. Injects the mcpeyeIntent
@@ -330,11 +396,42 @@ module Mcpeye
330
396
  raise ArgumentError, "a handler block is required" unless block_given?
331
397
 
332
398
  wrapped = proc do |args = {}|
399
+ # `intent_source` is the provenance of `intent`: "mcpeye" (our injected
400
+ # param) | "native" (a harvested host field) | nil (no intent).
401
+ # `captured_args` is what we RECORD — it may omit a harvested host field so
402
+ # the worker summary doesn't double-count it; the HANDLER still receives that
403
+ # field via `cleaned` (it may be a required arg). Never mutate `cleaned`.
404
+ intent_source = nil
333
405
  if own_intent
406
+ # Collision: the tool owns mcpeyeIntent. Pass args through verbatim, do not
407
+ # claim its value as agent intent, and (per precedence) never host-harvest.
334
408
  cleaned = args.is_a?(Hash) ? args : {}
335
409
  intent = nil
410
+ captured_args = cleaned
336
411
  else
337
412
  cleaned, intent = split_intent(args)
413
+ if intent
414
+ # mcpeye's own param wins whenever the agent filled it.
415
+ intent_source = Intent::INTENT_SOURCE_MCPEYE
416
+ captured_args = cleaned
417
+ else
418
+ # FALLBACK: the agent left mcpeyeIntent empty — harvest the host server's
419
+ # own analytics-intent field, if this tool has an eligible one. Resolved
420
+ # at CALL time against the (mutable) tracker, since host eligibility is
421
+ # learned at LIST time, after this wrapper was created.
422
+ harvested = harvest_host_intent(tool_name, cleaned)
423
+ if harvested
424
+ intent = harvested[1]
425
+ intent_source = Intent::INTENT_SOURCE_NATIVE
426
+ # Omit the promoted field from the CAPTURED copy ONLY. The handler still
427
+ # gets it via `cleaned`.
428
+ captured_args = cleaned.dup
429
+ captured_args.delete(harvested[0])
430
+ captured_args.delete(harvested[0].to_sym) if harvested[0].is_a?(String)
431
+ else
432
+ captured_args = cleaned
433
+ end
434
+ end
338
435
  end
339
436
  started = monotonic_ms
340
437
  begin
@@ -342,18 +439,21 @@ module Mcpeye
342
439
  rescue StandardError => e
343
440
  # Host handler raised: record the failure, then re-raise the identical
344
441
  # exception so the agent/client sees the real error (never swallowed).
345
- record(tool_name, cleaned, is_error: true, error_message: e.message,
346
- intent: intent, duration_ms: monotonic_ms - started)
442
+ record(tool_name, captured_args, is_error: true, error_message: e.message,
443
+ intent: intent, intent_source: intent_source,
444
+ duration_ms: monotonic_ms - started)
347
445
  raise
348
446
  end
349
447
 
350
448
  is_err, err_msg = result_error_info(result)
351
449
  if is_err
352
- record(tool_name, cleaned, is_error: true, error_message: err_msg,
353
- intent: intent, duration_ms: monotonic_ms - started)
450
+ record(tool_name, captured_args, is_error: true, error_message: err_msg,
451
+ intent: intent, intent_source: intent_source,
452
+ duration_ms: monotonic_ms - started)
354
453
  else
355
- record(tool_name, cleaned, result: result,
356
- intent: intent, duration_ms: monotonic_ms - started)
454
+ record(tool_name, captured_args, result: result,
455
+ intent: intent, intent_source: intent_source,
456
+ duration_ms: monotonic_ms - started)
357
457
  end
358
458
  result
359
459
  end
@@ -376,11 +476,12 @@ module Mcpeye
376
476
  is_error: false,
377
477
  error_message: nil,
378
478
  intent: nil,
479
+ intent_source: nil,
379
480
  duration_ms: nil)
380
481
  event = build_event(tool_name, arguments,
381
482
  result: result, is_error: is_error,
382
483
  error_message: error_message, intent: intent,
383
- duration_ms: duration_ms)
484
+ intent_source: intent_source, duration_ms: duration_ms)
384
485
 
385
486
  flush_inline = false
386
487
  @mutex.synchronize do
@@ -512,7 +613,8 @@ module Mcpeye
512
613
 
513
614
  # --- capture ---------------------------------------------------------
514
615
 
515
- def build_event(tool_name, arguments, result:, is_error:, error_message:, intent:, duration_ms:)
616
+ def build_event(tool_name, arguments, result:, is_error:, error_message:, intent:, intent_source: nil,
617
+ duration_ms:)
516
618
  args = arguments.is_a?(Hash) ? arguments : {}
517
619
  event = {
518
620
  "callId" => SecureRandom.uuid,
@@ -528,7 +630,12 @@ module Mcpeye
528
630
  event["result"] = guard_and_redact(result) if !is_error && !result.nil?
529
631
  event["errorMessage"] = guard_error_message(error_message.to_s) unless error_message.nil?
530
632
  # Only a non-blank intent is recorded (whitespace-only is dropped, TS/Python parity).
531
- event["intent"] = guard_error_message(intent.to_s) if intent && !intent.to_s.strip.empty?
633
+ if intent && !intent.to_s.strip.empty?
634
+ event["intent"] = guard_error_message(intent.to_s)
635
+ # Provenance ("mcpeye"|"native") is only meaningful alongside an intent (wire
636
+ # invariant: intentSource present only when intent present).
637
+ event["intentSource"] = intent_source.to_s if intent_source
638
+ end
532
639
  # Per-CALL end-user identity. build_event runs on the caller's (request) thread
533
640
  # — via the official call_tool hook or the wrap proc — so `identify` is evaluated
534
641
  # where the host's per-request user context is live (unlike the per-flush batch
@@ -762,6 +869,11 @@ module Mcpeye
762
869
  return 0 unless tools
763
870
 
764
871
  found = 0
872
+ # Pairs of [tool_name, original_properties] gathered from THIS pass, used to
873
+ # REBUILD the per-tool host-intent map (the duck path's analog of the official
874
+ # tools/list augmentation). Detection reads the `intent` field, not the
875
+ # `mcpeyeIntent` we inject, so capturing the live props hash is sufficient.
876
+ host_intent_pairs = []
765
877
  each_tool(tools) do |tool|
766
878
  begin
767
879
  schema = tool_input_schema(tool)
@@ -776,6 +888,7 @@ module Mcpeye
776
888
 
777
889
  prop_key = schema.key?(:properties) && !schema.key?("properties") ? :properties : "properties"
778
890
  props = schema[prop_key]
891
+ host_intent_pairs << [name, props] if name && props.is_a?(Hash)
779
892
 
780
893
  # mcpeyeIntent already present in properties: the tool owns the name —
781
894
  # UNLESS we injected it ourselves on a prior pass. Record genuine
@@ -802,6 +915,8 @@ module Mcpeye
802
915
  next
803
916
  end
804
917
  end
918
+ # REBUILD the host-intent map from this pass's schemas (clears stale entries).
919
+ detect_host_intent_tools(host_intent_pairs)
805
920
  found
806
921
  end
807
922
 
@@ -964,6 +1079,10 @@ module Mcpeye
964
1079
  tools = result[:tools] || result["tools"]
965
1080
  return unless tools.is_a?(Array)
966
1081
 
1082
+ # Gather [name, original_properties] pairs from the PRE-augmentation schemas,
1083
+ # to REBUILD the per-tool host-intent map (the official-path analog of the duck
1084
+ # path's per-tool recording — which previously recorded NOTHING here).
1085
+ host_intent_pairs = []
967
1086
  tools.each do |tool|
968
1087
  next unless tool.is_a?(Hash)
969
1088
 
@@ -973,10 +1092,32 @@ module Mcpeye
973
1092
  next if key.nil?
974
1093
 
975
1094
  schema = tool[key]
976
- tool[key] = inject_intent_into_schema(schema) if schema.is_a?(Hash)
1095
+ if schema.is_a?(Hash)
1096
+ name = tool[:name] || tool["name"]
1097
+ props = schema[:properties] || schema["properties"]
1098
+ if name && props.is_a?(Hash)
1099
+ host_intent_pairs << [name, props]
1100
+ # Collision: the host tool declares its OWN mcpeyeIntent. Record it so the
1101
+ # official call path won't strip a field the tool legitimately owns.
1102
+ if props.key?(Intent::INTENT_PARAM_NAME) || props.key?(Intent::INTENT_PARAM_NAME.to_sym)
1103
+ @own_intent_tools[name.to_s] = true
1104
+ end
1105
+ end
1106
+ tool[key] = inject_intent_into_schema(schema)
1107
+ end
977
1108
  end
978
1109
 
979
- if add_reserved && tools.none? { |t| t.is_a?(Hash) && (t[:name] || t["name"]) == RequestCapability::TOOL_NAME }
1110
+ # REBUILD the host-intent map each list (clears stale entries). Done BEFORE the
1111
+ # reserved-tool append so detection never sees our own injected reserved tool.
1112
+ detect_host_intent_tools(host_intent_pairs)
1113
+
1114
+ # Record whether the HOST already owns the reserved name (a collision), so the
1115
+ # official call path forwards it to the host instead of answering locally —
1116
+ # mirroring the duck path's reserved-owned handling.
1117
+ @reserved_host_owned =
1118
+ tools.any? { |t| t.is_a?(Hash) && (t[:name] || t["name"]).to_s == RequestCapability::TOOL_NAME }
1119
+
1120
+ if add_reserved && !@reserved_host_owned
980
1121
  tools << RequestCapability.descriptor
981
1122
  end
982
1123
  end
@@ -1088,6 +1229,170 @@ module Mcpeye
1088
1229
  report_once(:no_secret, RuntimeError.new("ingest_secret not set — ingest will reject with 401"))
1089
1230
  end
1090
1231
 
1232
+ # --- host-intent coexistence -----------------------------------------
1233
+
1234
+ # Parse the host_intent_param keyword into [explicit_param_or_nil, detect_bool]:
1235
+ # false => [nil, false] (OFF — never harvest)
1236
+ # ""/" " => [nil, true] (blank string => default gated detect)
1237
+ # "reason" => ["reason", false] (explicit field, BYPASS gate)
1238
+ # true/other => [nil, true] (gated auto-detect of "intent")
1239
+ # Mirrors the TS parseHostIntentConfig.
1240
+ def parse_host_intent_config(value)
1241
+ return [nil, false] if value == false
1242
+
1243
+ if value.is_a?(String)
1244
+ s = value.strip
1245
+ return s.empty? ? [nil, true] : [s, false]
1246
+ end
1247
+
1248
+ [nil, true] # nil | true | anything else
1249
+ end
1250
+
1251
+ # True when MCPEYE_DEBUG opts into host-intent detection diagnostics
1252
+ # ("1", "all", or a comma/space list including "intent"). Mirrors TS.
1253
+ def intent_debug_enabled?
1254
+ d = (ENV["MCPEYE_DEBUG"] || "").downcase
1255
+ d == "1" || d == "all" || d.split(/[\s,]+/).include?("intent")
1256
+ rescue StandardError
1257
+ false
1258
+ end
1259
+
1260
+ # Rebuild @host_intent_tools from a list of [tool_name, properties_hash] pairs
1261
+ # (the ORIGINAL, pre-augmentation schemas of THIS tools/list). Clears stale
1262
+ # entries first (NOT grow-only). Skips the reserved tool. Emits the one-time
1263
+ # activation log and, under MCPEYE_DEBUG=intent, per-tool rejection reasons.
1264
+ # Fail-open: never raises into the host list handler.
1265
+ def detect_host_intent_tools(tool_props_pairs)
1266
+ @host_intent_tools = {}
1267
+ return if @host_intent_explicit.nil? && !@host_intent_detect # host_intent_param: false
1268
+
1269
+ listed = 0
1270
+ tool_props_pairs.each do |name, props|
1271
+ next if name.nil? || name.to_s.empty?
1272
+
1273
+ listed += 1
1274
+ next if @capture_missing_capabilities && name.to_s == RequestCapability::TOOL_NAME
1275
+
1276
+ param = Intent.resolve_host_intent_param(
1277
+ props,
1278
+ explicit_param: @host_intent_explicit, detect: @host_intent_detect
1279
+ )
1280
+ if param
1281
+ @host_intent_tools[name.to_s] = param
1282
+ elsif @debug_intent
1283
+ log_host_intent_rejection(name, props)
1284
+ end
1285
+ rescue StandardError => e
1286
+ @on_error.call(e)
1287
+ end
1288
+
1289
+ # A real tools/list ran: from now on harvest is map-only on schema paths.
1290
+ @host_intent_listed = true if listed.positive?
1291
+ announce_host_intent_activation
1292
+ warn_host_intent_explicit_mismatch(listed)
1293
+ rescue StandardError => e
1294
+ @on_error.call(e)
1295
+ end
1296
+
1297
+ # One-time loud activation log: a silent change to WHAT we capture would erode
1298
+ # trust in the dashboard, so announce it the first time it actually fires.
1299
+ def announce_host_intent_activation
1300
+ return if @host_intent_tools.empty? || @host_intent_activated
1301
+
1302
+ @host_intent_activated = true
1303
+ tool_list = @host_intent_tools.map { |t, p| "#{t}.#{p}" }.join(", ")
1304
+ warn(
1305
+ "[mcpeye] using your server's own intent field as a fallback on " \
1306
+ "#{@host_intent_tools.size} tool(s): #{tool_list}. mcpeye's own mcpeyeIntent " \
1307
+ "still wins when the agent fills it; disable with host_intent_param: false."
1308
+ )
1309
+ end
1310
+
1311
+ # One-time warn if an EXPLICIT param matched nothing (likely a typo/misconfig).
1312
+ def warn_host_intent_explicit_mismatch(listed_count)
1313
+ return unless @host_intent_explicit
1314
+ return unless @host_intent_tools.empty? && listed_count.positive? && !@host_intent_explicit_warned
1315
+
1316
+ @host_intent_explicit_warned = true
1317
+ warn(
1318
+ "[mcpeye] host_intent_param \"#{@host_intent_explicit}\" did not match a string " \
1319
+ "field on any listed tool — no host intent will be harvested. Check the field name."
1320
+ )
1321
+ end
1322
+
1323
+ # MCPEYE_DEBUG=intent: explain why a present-but-rejected candidate wasn't harvested.
1324
+ def log_host_intent_rejection(name, props)
1325
+ return unless props.is_a?(Hash)
1326
+
1327
+ candidate = @host_intent_explicit || "intent"
1328
+ meta = props[candidate] || props[candidate.to_sym]
1329
+ return if meta.nil? # no candidate field -> nothing to explain
1330
+
1331
+ reason =
1332
+ if !meta.is_a?(Hash)
1333
+ "not_an_object"
1334
+ elsif (meta["type"] || meta[:type]) != "string"
1335
+ "not_string"
1336
+ else
1337
+ desc = meta["description"] || meta[:description]
1338
+ Intent.analytics_intent_reason(desc.is_a?(String) ? desc : nil)
1339
+ end
1340
+ warn(
1341
+ "[mcpeye:intent] tool \"#{name}\" field \"#{candidate}\" not harvested (#{reason}). " \
1342
+ "Set host_intent_param: \"#{candidate}\" to harvest it explicitly."
1343
+ )
1344
+ rescue StandardError => e
1345
+ @on_error.call(e)
1346
+ end
1347
+
1348
+ # The eligible host-intent param for a tool (call-time lookup), or nil. Public
1349
+ # so the duck-wrap proc and the official call hook — which resolve eligibility at
1350
+ # CALL time against this mutable tracker — share one source of truth.
1351
+ public def host_intent_param_for(tool_name)
1352
+ @host_intent_tools[tool_name.to_s]
1353
+ end
1354
+
1355
+ # Resolve the eligible host-intent value to promote for a call, or nil. Returns
1356
+ # [param_name, value] when the tool has an eligible, non-denylisted host field
1357
+ # whose arg is a non-empty string; else nil. Reads string OR symbol arg keys.
1358
+ # Mirrors TS harvestHostIntent (strictly map-driven once a tools/list has run),
1359
+ # plus an explicit-config fallback for the no-schema path (#wrap): there no
1360
+ # tools/list ever populated the map, so an EXPLICIT host_intent_param is honored
1361
+ # directly against the call args (the integrator opted in). On the schema paths
1362
+ # (official/duck) @host_intent_listed is true, so we stay map-only — a non-string
1363
+ # or absent explicit field is NOT harvested from a stray runtime arg, exactly
1364
+ # like TS. Gated auto-detect always requires a map entry.
1365
+ public def harvest_host_intent(tool_name, args)
1366
+ param = host_intent_param_for(tool_name)
1367
+ param = @host_intent_explicit if param.nil? && !@host_intent_listed
1368
+ return nil if param.nil?
1369
+ # PII guard: never promote a denylisted field name into the standalone intent.
1370
+ return nil if Intent.denied_field?(param, @denylist_fields)
1371
+ return nil unless args.is_a?(Hash)
1372
+
1373
+ v = args[param]
1374
+ v = args[param.to_sym] if v.nil? && param.is_a?(String)
1375
+ return [param, v] if v.is_a?(String) && !v.strip.empty?
1376
+
1377
+ nil
1378
+ rescue StandardError
1379
+ nil
1380
+ end
1381
+
1382
+ # Read by the official call hook (which lives outside the Tracker instance) to
1383
+ # decide whether to forward a reserved-name call to the host (collision) vs
1384
+ # answer it locally.
1385
+ public def reserved_host_owned?
1386
+ @reserved_host_owned
1387
+ end
1388
+
1389
+ # True when a tool declares its OWN mcpeyeIntent param (a collision). Read by the
1390
+ # official call hook so it never strips a field the tool legitimately owns and
1391
+ # never host-harvests for a collision tool (precedence: collision wins).
1392
+ public def own_intent_tool?(tool_name)
1393
+ @own_intent_tools.key?(tool_name.to_s)
1394
+ end
1395
+
1091
1396
  # --- misc ------------------------------------------------------------
1092
1397
 
1093
1398
  # Build the identity Hash for the wire. The ingest contract requires userId,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mcpeye
4
- VERSION = "0.1.4"
4
+ VERSION = "0.1.6"
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.4
4
+ version: 0.1.6
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-23 00:00:00.000000000 Z
11
+ date: 2026-06-25 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