Module: Mcpeye::OfficialServerCapture

Defined in:
lib/mcpeye/tracker.rb

Overview

Prepended to an official ‘MCP::Server`’s SINGLETON class so its ‘call_tool` is captured at the dispatch layer — the Ruby analog of the TS/Python protocol-level hook. The official gem invokes `call_tool` directly (mcp 0.20 server.rb), running required-arg + schema validation there, so wrapping it (not the tool classes) also captures the validation failures the gem returns BEFORE a tool’s own ‘.call` —exactly the failed asks mcpeye exists to surface.

Prepended PER SERVER (this instance’s singleton), never to the global ‘MCP::Tool` subclasses, so a tool class shared across servers is never bound to one tracker. Fail-open: the host’s return value is always passed through unchanged, and the injected ‘mcpeyeIntent` is stripped from the arguments BEFORE `super`, so the gem never splats it into the host tool’s keyword signature (which would raise ‘unknown keyword: :mcpeyeIntent` and break the call).

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.classify(response) ⇒ Object

The official gem’s call_tool returns EITHER a Hash (‘isError:`, the common 0.2x case) OR a Tool::Response object. Classify both into [is_error, error_text, result_payload]. Fail-open: unknown shapes -> success.



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/mcpeye/tracker.rb', line 161

def self.classify(response)
  if response.respond_to?(:error?)
    payload =
      if response.respond_to?(:structured_content) && response.structured_content
        response.structured_content
      elsif response.respond_to?(:content)
        { "content" => response.content }
      else
        response
      end
    [!!response.error?, text_of_content(response.respond_to?(:content) ? response.content : nil), payload]
  elsif response.is_a?(Hash)
    flag = response["isError"]
    flag = response[:isError] if flag.nil?
    [!!flag, text_of_content(response["content"] || response[:content]), response]
  else
    [false, nil, response]
  end
rescue StandardError
  [false, nil, response]
end

.text_of_content(content) ⇒ Object

Join the text parts of an MCP content array (Hash or struct items).



184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/mcpeye/tracker.rb', line 184

def self.text_of_content(content)
  return nil unless content.is_a?(Array)

  parts = content.filter_map do |item|
    if item.is_a?(Hash)
      item[:text] || item["text"]
    elsif item.respond_to?(:text)
      item.text
    end
  end
  parts.empty? ? nil : parts.join("\n")
rescue StandardError
  nil
end

Instance Method Details

#call_tool(request, **kwargs) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/mcpeye/tracker.rb', line 67

def call_tool(request, **kwargs)
  tracker = @__mcpeye_tracker
  return super if tracker.nil?

  name = (request[:name] || request["name"]).to_s
  raw_args = request[:arguments] || request["arguments"]
  args = raw_args.is_a?(Hash) ? raw_args : {}

  # Reserved capability tool: answer locally ONLY when capture is on AND the host
  # has NOT claimed the name for a real tool of its own (collision -> forward to
  # the host, mirroring the duck path). reserved_host_owned? is learned from
  # tools/list, which a client always calls before it can call a tool. (LATENT BUG
  # fixed: previously this intercepted unconditionally, hijacking a host tool that
  # legitimately owns the reserved name.)
  if name == Mcpeye::RequestCapability::TOOL_NAME &&
     tracker.capture_missing_capabilities? && !tracker.reserved_host_owned?
    return tracker.answer_request_capability_official(args)
  end

  # Resolve intent + provenance, consulting the per-tool maps (no longer a blind
  # delete). `captured_args` is what we RECORD; for a native harvest it omits the
  # promoted field, while the host STILL receives it via `args` (request[:arguments]
  # is the same object) — it may be a required field.
  intent = nil
  intent_source = nil
  captured_args = args
  begin
    if tracker.own_intent_tool?(name)
      # Collision: the tool owns mcpeyeIntent. Do NOT strip it (that would break a
      # tool that marks it required), do not claim it as agent intent, never harvest.
      intent = nil
    else
      # Strip OUR injected param in place so the gem validates/dispatches WITHOUT it.
      v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME)
      v = args.delete(Mcpeye::Intent::INTENT_PARAM_NAME.to_sym) if v.nil?
      if v.is_a?(String) && !v.strip.empty?
        # mcpeye's own param wins whenever the agent filled it.
        intent = v
        intent_source = Mcpeye::Intent::INTENT_SOURCE_MCPEYE
      else
        # FALLBACK: harvest the host's own analytics-intent field (after the strip),
        # if this tool has an eligible one. Resolved at CALL time against the map.
        harvested = tracker.harvest_host_intent(name, args)
        if harvested
          intent = harvested[1]
          intent_source = Mcpeye::Intent::INTENT_SOURCE_NATIVE
          # Omit the promoted field from the CAPTURED copy ONLY; the host (super)
          # still sees it via `args`.
          captured_args = args.dup
          captured_args.delete(harvested[0])
          captured_args.delete(harvested[0].to_sym) if harvested[0].is_a?(String)
        end
      end
    end
  rescue StandardError
    intent = nil
    intent_source = nil
    captured_args = args
  end

  started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
  begin
    response = super
  rescue StandardError => e
    dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
    begin
      tracker.record(name, captured_args, is_error: true, error_message: e.message,
                                          intent: intent, intent_source: intent_source, duration_ms: dur)
    rescue StandardError
      nil
    end
    raise
  end

  begin
    dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - started
    is_error, error_text, result_payload = Mcpeye::OfficialServerCapture.classify(response)
    if is_error
      tracker.record(name, captured_args, is_error: true, error_message: error_text,
                                          intent: intent, intent_source: intent_source, duration_ms: dur)
    else
      tracker.record(name, captured_args, result: result_payload,
                                          intent: intent, intent_source: intent_source, duration_ms: dur)
    end
  rescue StandardError
    nil
  end

  response
end