Module: Mcpeye::Intent
- Defined in:
- lib/mcpeye/intent.rb
Overview
The injected-intent contract.
mcpeye’s cheap capture trick: the SDK injects an optional ‘mcpeyeIntent` parameter into every tool’s input schema. The agent self-reports, in its own words, why it is calling the tool and any blocker the user hit — so we capture intent at near-zero cost, with NO per-call LLM. The LLM runs later, in the worker, only to cluster sessions into reports.
The description below is what the agent reads. It is deliberately specific about surfacing failures/blockers AND naming any capability the user wanted that no tool provides — those attempted-but-failed asks, phrased as the user’s own unmet need, are the hero signal (the Intent Gap Report).
Keep INTENT_PARAM_DESCRIPTION byte-for-byte in sync with @mcpeye/core (packages/core/src/intent.ts) and the other SDKs (Python: packages/sdk-python/src/mcpeye/intent.py). Every server speaks the same contract; spec/intent_spec.rb asserts this string against the canonical text.
Constant Summary collapse
- INTENT_PARAM_NAME =
"mcpeyeIntent"- INTENT_SOURCE_MCPEYE =
Provenance of a captured intent: our injected param vs the server’s native field. Recorded on the wire as ‘intentSource` (only when intent is present).
"mcpeye"- INTENT_SOURCE_NATIVE =
"native"- INTENT_PARAM_DESCRIPTION =
Byte-for-byte identical to packages/core/src/intent.ts INTENT_PARAM_DESCRIPTION. If you change one, change all SDKs (TS, Python, Ruby) together.
"Explain why you are calling this tool and how it fits into the user's overall workflow. " \ "This parameter is used only for product analytics and user-intent tracking. " \ "Write 25-35 words, in the third person. " \ "Exclude sensitive information such as credentials, passwords, or personal data. " \ "Describe any blocker or failure the user hit. " \ "Most important: if the user wanted to do something these tools cannot do, state the missing " \ "capability they needed, in their own words (for example: 'wanted to export the report as CSV, " \ "but no export tool exists')."
- DEFAULT_HOST_INTENT_PARAM_NAMES =
— host-intent coexistence —————————————-
Some MCP servers already ship an analytics-style intent field (e.g. a param literally named ‘intent` whose description says “explain why you are calling this tool, for analytics”). When they do, the agent fills THEIR field and leaves our injected `mcpeyeIntent` empty — so we capture nothing. The SDKs handle this by HARVESTING the host field as a FALLBACK: our `mcpeyeIntent` still wins when filled; the host value is used only when ours is empty.
The danger is ‘intent` is also a common FUNCTIONAL field name (a Stripe PaymentIntent id, an NLU classified-intent label, a routing key) — all strings. Auto-detection is gated on the field’s DESCRIPTION semantically matching the analytics-intent contract (conjunctive cues + hard-negative tokens), never on name+type alone. An explicit param config bypasses the gate.
Ported byte-for-byte from packages/core/src/intent.ts. The gate lists below MUST stay identical across all SDKs; spec/host_intent_spec.rb asserts them against the shared fixture packages/core/fixtures/host-intent-matrix.json.
["intent"].freeze
- PURPOSE_CUES =
A description must contain BOTH a purpose cue AND an analytics cue (substring, case-insensitive — catches inflections like “blocker”/“blockers”).
["why", "reason", "intent", "purpose", "trying to", "accomplish", "in their own words"].freeze
- ANALYTICS_CUES =
["analytics", "tracking", "workflow", "product", "user intent", "blocker", "unmet", "capability"].freeze
- NEGATIVE_EXACT =
Hard-negative tokens disqualify a field even with cues. Short/ambiguous ones are matched as whole TOKENS (so “id” does not trip on “provide”); unambiguous compounds are matched as substrings.
[ "id", "identifier", "uuid", "secret", "token", "status", "enum", "classification", "routing", "route", "key" ].to_set.freeze
- NEGATIVE_SUBSTR =
["paymentintent", "payment intent", "client_secret", "client secret"].freeze
Class Method Summary collapse
-
.analytics_intent_reason(description) ⇒ Object
Why a description did or didn’t qualify as an analytics-intent field.
-
.denied_field?(name, denylist_fields = []) ⇒ Boolean
Is a FIELD NAME denylisted (Redaction::DEFAULT_DENYLIST ∪ caller’s ‘denylist_fields`), compared case-insensitively — the same normalization the redactor uses when walking object keys.
-
.describes_analytics_intent?(description) ⇒ Boolean
True iff a param description reads like an analytics-intent field.
-
.inject_intent_param(input_schema) ⇒ Object
Return a copy of a JSON-Schema object with the ‘mcpeyeIntent` property merged in.
-
.object_shaped?(schema) ⇒ Boolean
Whether a JSON-Schema fragment is object-shaped, i.e.
-
.param_json_schema ⇒ Object
JSON-Schema fragment merged into each tool’s inputSchema by the SDKs.
-
.resolve_host_intent_param(properties, opts = {}) ⇒ Object
Resolve which host field (if any) to harvest as a FALLBACK intent for a tool, from its inputSchema ‘properties`.
-
.string_prop_description(props, name) ⇒ Object
Read a property’s metadata from a schema ‘properties` Hash, tolerating BOTH string and symbol keys (Ruby schemas use either).
Class Method Details
.analytics_intent_reason(description) ⇒ Object
Why a description did or didn’t qualify as an analytics-intent field. Returns one of: “ok”, “no_description”, “negative_token”, “missing_purpose_cue”, “missing_analytics_cue”. Surfaced as a per-tool reason code under MCPEYE_DEBUG=intent. Mirrors TS analyticsIntentReason exactly.
134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/mcpeye/intent.rb', line 134 def self.analytics_intent_reason(description) return "no_description" unless description.is_a?(String) && !description.strip.empty? d = description.downcase return "negative_token" if NEGATIVE_SUBSTR.any? { |t| d.include?(t) } tokens = d.scan(/[a-z0-9_]+/) return "negative_token" if tokens.any? { |t| NEGATIVE_EXACT.include?(t) } return "missing_purpose_cue" unless PURPOSE_CUES.any? { |c| d.include?(c) } return "missing_analytics_cue" unless ANALYTICS_CUES.any? { |c| d.include?(c) } "ok" end |
.denied_field?(name, denylist_fields = []) ⇒ Boolean
Is a FIELD NAME denylisted (Redaction::DEFAULT_DENYLIST ∪ caller’s ‘denylist_fields`), compared case-insensitively — the same normalization the redactor uses when walking object keys. The harvest path calls this BEFORE promoting a host field’s value into the standalone ‘intent` string (which bypasses object-key field-denylisting), so a host field literally named `token`/`secret` yields an empty intent instead of a leak. Mirrors TS isDeniedField (which lives in redaction.ts); kept here because redaction.rb is frozen and the harvest gate is part of the intent contract.
204 205 206 207 208 209 |
# File 'lib/mcpeye/intent.rb', line 204 def self.denied_field?(name, denylist_fields = []) n = name.to_s.downcase return true if Redaction::DEFAULT_DENYLIST.any? { |f| f.to_s.downcase == n } Array(denylist_fields).any? { |f| f.to_s.downcase == n } end |
.describes_analytics_intent?(description) ⇒ Boolean
True iff a param description reads like an analytics-intent field.
149 150 151 |
# File 'lib/mcpeye/intent.rb', line 149 def self.describes_analytics_intent?(description) analytics_intent_reason(description) == "ok" end |
.inject_intent_param(input_schema) ⇒ Object
Return a copy of a JSON-Schema object with the ‘mcpeyeIntent` property merged in. Non-destructive (the original schema and its properties are copied), mirroring the TS `augmentListToolsResult` / Python `inject_intent_param`:
-
Object-shaped schema (see ‘object_shaped?`): merge in `mcpeyeIntent`, defaulting `type` to “object”.
-
Anything else (explicit non-object type, or typeless-non-empty): returned unchanged — capture still works.
-
When ‘mcpeyeIntent` already exists (a tool owns the name), the property is left exactly as the tool declared it.
83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'lib/mcpeye/intent.rb', line 83 def self.inject_intent_param(input_schema) return input_schema unless object_shaped?(input_schema) schema = input_schema.dup props = (schema["properties"] || schema[:properties] || {}).dup # Do not clobber a real tool param that happens to share the name. unless props.key?(INTENT_PARAM_NAME) || props.key?(INTENT_PARAM_NAME.to_sym) props[INTENT_PARAM_NAME] = param_json_schema end schema["properties"] = props schema["type"] ||= "object" schema end |
.object_shaped?(schema) ⇒ Boolean
Whether a JSON-Schema fragment is object-shaped, i.e. somewhere we can add an ‘mcpeyeIntent` string property. The single source of truth for object detection, shared by `inject_intent_param` and the Tracker’s in-place auto-injection so the two paths can never drift (matches Python’s guard):
-
‘type == “object”`, OR a `properties` key (string or symbol), OR a literally-empty `{}` (a parameterless tool — synthesize an object).
-
An explicit non-object type (e.g. ‘{ “type” => “array” }`) is NOT object shaped; a typeless-but-non-empty schema (e.g. `{ “description” => “x” }`) is NOT either — there is no sensible place to add the param, and capture still works without it.
64 65 66 67 68 69 70 71 |
# File 'lib/mcpeye/intent.rb', line 64 def self.object_shaped?(schema) return false unless schema.is_a?(Hash) explicit_type = schema["type"] || schema[:type] explicit_type == "object" || schema.key?("properties") || schema.key?(:properties) || (explicit_type.nil? && schema.empty?) end |
.param_json_schema ⇒ Object
JSON-Schema fragment merged into each tool’s inputSchema by the SDKs. Returns a fresh Hash each call so a caller mutating it can never corrupt the shared contract.
46 47 48 49 50 51 |
# File 'lib/mcpeye/intent.rb', line 46 def self.param_json_schema { "type" => "string", "description" => INTENT_PARAM_DESCRIPTION } end |
.resolve_host_intent_param(properties, opts = {}) ⇒ Object
Resolve which host field (if any) to harvest as a FALLBACK intent for a tool, from its inputSchema ‘properties`. Precedence:
explicit_param (no gate, must be a string) ->
gated auto-detect over `names` (string + description passes the gate) ->
nil.
Pure; mirrors TS resolveHostIntentParam. Tolerates string/symbol keys.
opts: { names: [..], explicit_param: “..”, detect: true/false }
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/mcpeye/intent.rb', line 175 def self.resolve_host_intent_param(properties, opts = {}) return nil unless properties.is_a?(Hash) explicit_param = opts[:explicit_param] || opts["explicit_param"] detect = opts.key?(:detect) ? opts[:detect] : (opts.key?("detect") ? opts["detect"] : true) names = opts[:names] || opts["names"] || DEFAULT_HOST_INTENT_PARAM_NAMES unless explicit_param.nil? || (explicit_param.is_a?(String) && explicit_param.empty?) = string_prop_description(properties, explicit_param) return && [0] ? explicit_param : nil end return nil unless detect names.each do |name| = string_prop_description(properties, name) return name if && [0] && describes_analytics_intent?([1]) end nil end |
.string_prop_description(props, name) ⇒ Object
Read a property’s metadata from a schema ‘properties` Hash, tolerating BOTH string and symbol keys (Ruby schemas use either). Returns
- is_string, description_or_nil
-
or nil when the property is absent/non-Hash.
156 157 158 159 160 161 162 163 164 165 |
# File 'lib/mcpeye/intent.rb', line 156 def self.string_prop_description(props, name) p = props[name] p = props[name.to_sym] if p.nil? && name.is_a?(String) p = props[name.to_s] if p.nil? && name.is_a?(Symbol) return nil unless p.is_a?(Hash) type = p["type"] || p[:type] desc = p["description"] || p[:description] [type == "string", desc.is_a?(String) ? desc : nil] end |