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 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; 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"],
# 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) -> 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:, 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