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
- Inject an optional
mcpeyeIntentparameter 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. - Capture each tool call (name, arguments, result, error, duration, the self-reported intent).
- Add the reserved
mcpeye_request_capabilitytool (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 withtool_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 withcapture_missing_capabilities: false. - 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.
- Buffer + POST the events as a single
IngestPayloadJSON to"#{ingest_url}/ingest"with anx-mcpeye-secretheader.
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; passfalseto keep it out. Auto-add works for Hash / Array tool registries; for other server shapes the constant + descriptor live inMcpeye::RequestCapabilityso 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
identifyruns 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 inidentify(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:
#wraphas no schema to auto-detect from, so gated auto-detect can't run here. To harvest your server's own intent field, sethost_intent_param: "reason"on the tracker (Mcpeye.track/Mcpeye::Tracker.new);#wrapthen 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 injectedmcpeyeIntentwas filled. It always wins when present.intentSource: "native"—mcpeyeIntentwas 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 namedintentwhose description reads like an intent prompt. Functional fields namedintent— e.g. a StripePaymentIntentid — are rejected by the gate.false— off; capture onlymcpeyeIntent."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 liketoken/secretare still blocked.)
#wrapand the no-schema path: auto-detect needs the tool's schema (it reads the field's description), so it only works on the schema-awaretrack/#instrumentpath. For the manual#wrappath — which has no published schema to inspect — sethost_intent_param: "reason"on the tracker (Mcpeye.track/Mcpeye::Tracker.new) and#wrapharvests that field from the call args.#recorddoes not harvest — it captures whateverintent:/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) -> TrackerMcpeye::Tracker#instrument(server)— inject param + wrap discoverable handlersMcpeye::Tracker#inject_intent_param(server)Mcpeye::Tracker#wrap(tool_name) { |args| ... } -> ProcMcpeye::Tracker#record(tool_name, args, result:, is_error:, error_message:, intent:, intent_source:, duration_ms:) -> HashMcpeye::Tracker#flush -> Net::HTTPResponse | nil(never raises)Mcpeye::Tracker#start_flush_thread— start the background flush timer (call inon_worker_boot)Mcpeye::Tracker#stop -> nil— stop the timer + final flushMcpeye::Tracker#pending -> IntegerMcpeye::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