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 works out of the box with the official mcp gem (MCP::Server + MCP::Tool subclasses):

require "mcp"
require "mcpeye"

server = MCP::Server.new(name: "my-server", tools: [SearchTool, OrderTool])
Mcpeye.track(server, "my-project-id", ingest_url: "http://localhost:3001")
# That's it — every tools/call is captured and mcpeyeIntent is advertised in tools/list.

For the official gem it hooks the server's tools/call and tools/list per server instance (never your global MCP::Tool classes), so it captures every call — including the ones the gem rejects at schema validation, the failed asks mcpeye exists to surface — and the injected mcpeyeIntent is stripped from the arguments before your tool runs (it never reaches your def self.call signature). It also auto-detects Hash / fast-mcp server shapes.

Call track after your tools are registered (for the official gem, any time after MCP::Server.new(tools: […])). If a server shape can't be introspected, track returns a working tracker and warns loudly via on_error (whose default prints to stderr) — it never silently captures nothing — and you can 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 resolving end-user identity. userId/userEmail are read per call (on the request thread → correct attribution on multi-user servers); batch-level client/serverVersion are read per flush. A raising one yields {}. See Use in a Rails MCP server.
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.
host_intent_param: true Coexist with a server's own analytics-style intent field. true = gated auto-detect + harvest as a fallback; false = off; "name" = harvest that exact field, bypassing the gate. See Works with servers that already capture intent.
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"],
  # End-user identity. `userId`/`userEmail` are resolved PER CALL on the request
  # thread (so a thread/request-local value like Rails Current / RequestStore is
  # attributed correctly even on a multi-user / stateless server); client/serverVersion
  # are read per flush. Pass an OPAQUE, stable userId. This is what powers the
  # dashboard's search-by-id/email — without it, sessions read "user not identified".
  identify: lambda {
    {
      userId: Current.user_id&.to_s,        # who the end user is (for search)
      userEmail: Current.user_email,        # human-readable, optional (PII you store)
      client: Current.client,               # process/connection-level
      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}") }
)

Multi-user / stateless servers (e.g. a streamable-HTTP MCP). Because identify runs per call on the request thread, set your per-request user context BEFORE the tool dispatches (e.g. in middleware: RequestStore.store[:current_user_id] = ...) and read it in identify (RequestStore.store[:current_user_id]). Each captured call is then attributed to the right user, even though one flushed batch may mix users.

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:

Host intent on this path: #wrap has no schema to auto-detect from, so gated auto-detect can't run here. To harvest your server's own intent field, set host_intent_param: "reason" on the tracker (Mcpeye.track / Mcpeye::Tracker.new); #wrap then harvests that field from the call args. The explicit form bypasses the gate, so point it only at a prose intent field (denylisted names still blocked).

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.

Works with servers that already capture intent

Some MCP servers already expose their own analytics-style intent field. mcpeye coexists with them: it keeps injecting mcpeyeIntent, and when the agent leaves that empty it falls back to harvesting the server's own field. Provenance is recorded on every captured event as intentSource:

  • intentSource: "mcpeye" — our injected mcpeyeIntent was filled. It always wins when present.
  • intentSource: "native"mcpeyeIntent was empty, so the value came from your server's own intent field (used only as a fallback).

host_intent_param: (default true) controls the fallback:

  • true — gated auto-detect: harvest a string field named intent whose description reads like an intent prompt. Functional fields named intent — e.g. a Stripe PaymentIntent id — are rejected by the gate.
  • false — off; capture only mcpeyeIntent.
  • "reason" (a string) — harvest that exact field, bypassing the semantic gate.

The host still receives a harvested field (it may be required); mcpeye only omits it from its own captured copy so the value isn't double-counted.

Mcpeye.track(server, "my-project-id", host_intent_param: true)      # default: gated auto-detect
Mcpeye.track(server, "my-project-id", host_intent_param: false)     # off
Mcpeye.track(server, "my-project-id", host_intent_param: "reason")  # explicit field name (bypasses the gate)

An explicit field name bypasses the safety gate. host_intent_param: "reason" harvests that exact field with no description check, so point it only at a prose intent field — not at an id/status/enum. (Denylisted field names like token/secret are still blocked.)

#wrap and the no-schema path: auto-detect needs the tool's schema (it reads the field's description), so it only works on the schema-aware track / #instrument path. For the manual #wrap path — which has no published schema to inspect — set host_intent_param: "reason" on the tracker (Mcpeye.track / Mcpeye::Tracker.new) and #wrap harvests that field from the call args. #record does not harvest — it captures whatever intent: / intent_source: you pass it, so resolve the intent yourself before calling it.

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:, host_intent_param:, 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:, intent_source:, 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