mcpeye 0.1.5 → 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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +52 -3
- data/lib/mcpeye/intent.rb +121 -0
- data/lib/mcpeye/tracker.rb +329 -24
- 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: 42505a75e113368b219cf57a34a12d8e503a67ba53819bf3ba134855ebdd8593
|
|
4
|
+
data.tar.gz: e9450434dfe0ed2686821ca35cf2aae489d7e9348f9a904987f4f68c9fd5837d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
data/lib/mcpeye/tracker.rb
CHANGED
|
@@ -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
|
-
#
|
|
76
|
-
#
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
346
|
-
|
|
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,
|
|
353
|
-
|
|
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,
|
|
356
|
-
|
|
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:,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
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.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-
|
|
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
|