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

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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_schemaObject

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?)
    meta = string_prop_description(properties, explicit_param)
    return meta && meta[0] ? explicit_param : nil
  end

  return nil unless detect

  names.each do |name|
    meta = string_prop_description(properties, name)
    return name if meta && meta[0] && describes_analytics_intent?(meta[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