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
-
.classify(response) ⇒ Object
The official gem’s call_tool returns EITHER a Hash (‘isError:`, the common 0.2x case) OR a Tool::Response object.
-
.text_of_content(content) ⇒ Object
Join the text parts of an MCP content array (Hash or struct items).
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., 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 |