mcpeye (Ruby gem)

See why your agent is failing.

Product analytics for Ruby / Rails MCP servers. mcpeye captures what agents try to do through your MCP tools — and, crucially, the asks your tools could not fulfill — then ships them to your self-hosted mcpeye instance. There the worker clusters sessions into the Intent Gap Report: the top user asks your tools attempted but failed to deliver.

This gem instruments any Ruby / Rails MCP server. It is pure stdlib at runtime (net/http, json, securerandom) and never raises into, or alters, the host server. Capture is O(1); set flush_interval: to ship telemetry off the tool-call thread (see Options for the zero-thread default's one caveat).

How it works

  1. Inject 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. Capture is near-zero cost: no per-call LLM.
  2. Capture each tool call (name, arguments, result, error, duration, the self-reported intent).
  3. Add the reserved mcpeye_request_capability tool (active missing-capability capture). When the agent wants a capability none of your tools cover, it calls this tool to say so in the user's words; mcpeye answers it locally with a canned acknowledgement (never forwarding it to your server) and records it as a normal tool call with tool_name = "mcpeye_request_capability". The report folds these into "Top missing capabilities" as high-confidence, explicitly-requested entries — catching the silent miss, where the right move is to call no tool at all. Disable with capture_missing_capabilities: false.
  4. Redact secrets/PII client-side (regex-based) before anything leaves the process, and size-bound every field. Self-hosting is the real privacy control; redaction shrinks the blast radius of obvious secrets in free-text args.
  5. Buffer + POST the events as a single IngestPayload JSON to "#{ingest_url}/ingest" with an x-mcpeye-secret header.

The wire payload is byte-compatible across every mcpeye SDK (TS / Python / Ruby):

{
  "projectId": "my-project-id",
  "identity": { "userId": "u_123", "client": "claude-desktop/0.7.1", "serverVersion": "1.4.0" },
  "events": [
    {
      "callId": "f7c1...uuid",
      "toolName": "search_products",
      "arguments": { "q": "headphones" },
      "result": { "count": 3 },
      "isError": false,
      "intent": "The user is searching for wireless headphones to add to their cart.",
      "durationMs": 42,
      "timestamp": 1718323200000
    }
  ]
}

Install

Add to your Gemfile:

gem "mcpeye"

Then bundle install. No runtime dependencies.

Configuration

Reads the standard mcpeye env vars when arguments are omitted:

Env var Purpose
MCPEYE_INGEST_URL Base URL of your self-hosted mcpeye API (no /ingest).
MCPEYE_INGEST_SECRET Shared secret sent as the x-mcpeye-secret header.

Quick start

require "mcpeye"

tracker = Mcpeye.track(
  server,                 # your MCP server object
  "my-project-id",
  ingest_url: ENV["MCPEYE_INGEST_URL"],       # e.g. "http://localhost:3001"
  ingest_secret: ENV["MCPEYE_INGEST_SECRET"]
)

# That's it. A drain is auto-registered via at_exit, so you no longer have to
# remember it. (An explicit `at_exit { tracker.flush }` still works — it's
# idempotent.)

Mcpeye.track injects mcpeyeIntent into discoverable tool schemas and wraps discoverable handlers automatically. Ruby MCP server shapes vary, so when the internals can't be introspected track returns a working tracker unchanged (and reports once via on_error) — instrument manually with #wrap / #record.

Options

All optional except project_id:

Option Default What it does
ingest_url: ENV["MCPEYE_INGEST_URL"] Base URL; /ingest is appended.
ingest_secret: ENV["MCPEYE_INGEST_SECRET"] Sent as x-mcpeye-secret. Missing → ingest 401 (warned once).
redact: true Scrub secrets/PII from arguments/result/intent/error. false = verbatim.
identity: {} Static { userId:, client:, serverVersion: }.
identify: nil Callable evaluated once per flush for per-request identity. A raising one yields {}.
flush_interval: nil (no thread) Seconds between background flushes. Set it to drain low-traffic servers.
flush_threshold: 20 Eager-flush once this many events buffer.
denylist_fields: [] Extra field names whose values are always dropped (case-insensitive).
max_buffer: 10_000 Hard cap; oldest events drop past it while the API is down (warned once).
capture_missing_capabilities: true Add + locally answer the reserved mcpeye_request_capability tool. false keeps it out of your manifest.
on_error: warn "[mcpeye] ..." Diagnostics sink for every swallowed error. Wrapped so it can never throw.

Manifest cost. With capture_missing_capabilities: true, your server's tool list gains one extra tool — a few hundred tokens in any model context that lists tools, and one more entry in any tool picker / doc generator. That is the price of seeing silent misses; pass false to keep it out. Auto-add works for Hash / Array tool registries; for other server shapes the constant + descriptor live in Mcpeye::RequestCapability so you can register it yourself.

When flush_interval is set, a single background flush thread ships batches off the tool-call thread, so capture never blocks. Without it the gem is zero-thread: events flush eagerly at flush_threshold, on a manual #flush, and via the auto at_exit drain — but that threshold flush runs synchronously on the calling thread (one Net::HTTP POST, bounded by the 5s/10s open/read timeouts), so the Nth tool call can block briefly. For a busy or latency-sensitive server, set flush_interval: so capture is fully non-blocking.

Identity values (userId/client/serverVersion) are coerced to strings before they're sent (the ingest contract requires strings), so an integer id is fine — but pass an opaque, non-PII value.

Use in a Rails MCP server

config/initializers/mcpeye.rb:

require "mcpeye"

MCPEYE = Mcpeye.track(
  MyMcpServer.instance,                         # your server object
  ENV.fetch("MCPEYE_PROJECT_ID"),
  ingest_url: ENV.fetch("MCPEYE_INGEST_URL", "http://localhost:3001"),
  ingest_secret: ENV["MCPEYE_INGEST_SECRET"],
  # Per-request identity: evaluated once per flush, so a thread/request-local
  # value is attributed correctly. Pass an OPAQUE, non-PII id (coerced to a string).
  identify: -> { { userId: Current.user_id&.to_s, client: Current.client, serverVersion: MyApp::VERSION } },
  # Drop your own domain-sensitive fields on top of the built-in denylist:
  denylist_fields: %w[ssn account_number],
  # Forward diagnostics into your logger instead of stderr:
  on_error: ->(e) { Rails.logger.warn("[mcpeye] #{e}") }
)

Puma / Unicorn (forking servers)

A thread does not survive fork, so in a clustered server start the flush timer after each worker boots, and drain on shutdown:

# config/puma.rb
on_worker_boot do
  # Re-start the background flush thread inside each forked worker.
  MCPEYE.start_flush_thread
end

on_worker_shutdown do
  MCPEYE.stop   # stops the timer + does a final flush
end

Pass flush_interval: to Mcpeye.track (e.g. flush_interval: 5) so start_flush_thread has an interval to use. Even without the timer, the eager threshold flush + the auto at_exit drain still ship per worker; at worst a tiny tail is lost on SIGKILL (same as the other SDKs).

Manual capture (when auto-wrap can't attach)

mcpeye duck-types the common Ruby MCP shapes — a server exposing tools / registered_tools (a method or @tools / @registered_tools ivar) whose entries are Hashes carrying an input schema ("inputSchema", :input_schema, "schema") and, for auto-wrapping, a name plus a callable ("handler" / "call"). When your server doesn't match (a custom Rack handler, a frozen tool registry, a future framework), instrument is a safe no-op and you capture calls yourself:

class SearchContactsTool
  def self.handler
    MCPEYE.wrap("search_contacts") do |args|
      Contact.search(args["q"]).as_json   # your real logic
    end
  end
end

#wrap strips mcpeyeIntent out of the incoming arguments, records it as intent, runs your handler with the cleaned args, and returns the result unchanged. A handler that raises is recorded as isError and the identical exception is re-raised. Or record fully by hand:

MCPEYE.record(
  "place_order",
  { "items" => [{ "sku" => "p_7720", "qty" => 1 }] },
  result: { "id" => "ord_123" },
  intent: "User is placing an order but couldn't find a way to apply a discount code.",
  duration_ms: 88
)
MCPEYE.flush

Result-level errors

Besides a raised exception, mcpeye also captures a tool-level failure: when a handler returns a result Hash carrying a truthy "isError" (the MCP CallTool convention, e.g. { "isError" => true, "content" => [{ "type" => "text", "text" => "..." }] }), the event is recorded with isError: true, an errorMessage derived from the content text, and the result omitted. The handler's return value is passed back to the caller unchanged.

Redaction

Client-side, regex-based, deliberately conservative — it over-redacts rather than leak. Out of the box it scrubs emails, API keys (sk-, sk-ant-, GitHub gh*_, AWS AKIA…), Bearer tokens, JWTs, card-like and phone-like number runs, and drops the values of denylisted fields (password, secret, token, apiKey, authorization, …). Deeply-nested and self-referential structures are guarded ([REDACTED_TOO_DEEP] / [REDACTED_CYCLE]), and any single field over ~32 KB is replaced with a small marker so a multi-MB payload can never blow the ingest body limit. Extend the denylist with denylist_fields:; disable redaction with redact: false.

Redaction is not a substitute for self-hosting — it reduces the blast radius of obvious secrets that slip into free-text arguments and intent strings.

API

  • Mcpeye.track(server, project_id, ingest_url:, ingest_secret:, redact:, identity:, identify:, flush_interval:, on_error:, **opts) -> Tracker
  • Mcpeye::Tracker#instrument(server) — inject param + wrap discoverable handlers
  • Mcpeye::Tracker#inject_intent_param(server)
  • Mcpeye::Tracker#wrap(tool_name) { |args| ... } -> Proc
  • Mcpeye::Tracker#record(tool_name, args, result:, is_error:, error_message:, intent:, duration_ms:) -> Hash
  • Mcpeye::Tracker#flush -> Net::HTTPResponse | nil (never raises)
  • Mcpeye::Tracker#start_flush_thread — start the background flush timer (call in on_worker_boot)
  • Mcpeye::Tracker#stop -> nil — stop the timer + final flush
  • Mcpeye::Tracker#pending -> Integer
  • Mcpeye::Redaction.redact_string(s), Mcpeye::Redaction.redact_value(v, denylist_fields:)
  • Mcpeye::INTENT_PARAM_NAME ("mcpeyeIntent"), Mcpeye::INTENT_PARAM_DESCRIPTION

Development

cd packages/sdk-ruby
bundle install
bundle exec rake spec        # the seven-spec RSpec suite
# or: pnpm --filter @mcpeye/sdk-ruby test

License

MIT