mcpeye
v0.1.6
Published
Drop-in product analytics for MCP servers. See why your agent is failing.
Downloads
999
Maintainers
Readme
mcpeye
See why your agent is failing. Drop-in product analytics for MCP servers.
npm i mcpeyemcpeye is the TypeScript SDK for mcpeye, an
open-source, self-hosted analytics tool for Model Context Protocol
servers. One line of code captures what your agents are trying to do with your
tools — and surfaces the Intent Gap Report: the top user asks your tools
attempted but could not deliver.
Install
npm install mcpeye
# or: pnpm add mcpeye / yarn add mcpeye@modelcontextprotocol/sdk (v1) is a peer dependency — you already have it if
you're building an MCP server. Node 18+ is required (mcpeye ships events with
the global fetch).
Usage
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { track } from "mcpeye";
const server = new Server(
{ name: "my-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Register your tools as usual (setRequestHandler for tools/list + tools/call)...
// Then instrument the server. That's it.
track(server, "your-project-id");Using the high-level McpServer? Pass it directly — track() transparently
unwraps to its underlying low-level Server (mcpServer.server):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const mcp = new McpServer({ name: "my-mcp-server", version: "1.0.0" });
track(mcp, "your-project-id");
mcp.tool("greet", { name: z.string() }, async ({ name }) => ({ content: [{ type: "text", text: `hi ${name}` }] }));Call order doesn't matter. Call track() any time after constructing your
server — before or after you register tools. Injection happens when a
tools/list response is built and capture happens when a tools/call runs, so
handlers registered before and after track() are both instrumented.
By default mcpeye reads its connection details from the environment:
| Env var | Purpose | Default |
| ---------------------- | -------------------------------- | ------------------------ |
| MCPEYE_INGEST_URL | Base URL of your ingest API | http://localhost:3001 |
| MCPEYE_INGEST_SECRET | Shared secret for the ingest API | (none) |
Events are POSTed to ${MCPEYE_INGEST_URL}/ingest with an x-mcpeye-secret
header. You run the ingest API yourself via docker compose — your data never
leaves your infrastructure.
What it does
- Injects a self-reported intent parameter. Every tool gains an optional
mcpeyeIntentstring in its input schema. The agent fills it in with, in its own words, why it's calling the tool and any blocker the user hit. This captures intent at near-zero cost — no per-call LLM. (The LLM runs later, in the mcpeye worker, only to cluster sessions into reports.) - Captures every tool call:
callId,toolName,arguments,result,isError,errorMessage,durationMs, andtimestamp. ThemcpeyeIntentvalue is moved out of the arguments into a dedicatedintentfield — your real tool handler never sees it. - Adds a reserved
mcpeye_request_capabilitytool (active missing-capability capture). When the agent wants a capability none of your tools cover, it can call this tool to say so in the user's words. The SDK answers it locally with a canned acknowledgement (it is never forwarded to your server, which has no such handler) and records it as a normal tool call withtoolName = "mcpeye_request_capability". The report folds these explicit asks into "Top missing capabilities" as high-confidence, explicitly-requested entries. This complements the passivemcpeyeIntentparam — it catches the silent miss, where the right move is to call no tool at all. Toggle withcaptureMissingCapabilities(defaulttrue). - Redacts, buffers, and flushes. Arguments and results are scrubbed of
obvious secrets/PII client-side (on by default), buffered, and shipped to the
ingest API in batches — on an interval, when the batch fills, and on process
exit. An oversized argument or result (e.g. a multi-MB blob) is replaced with
a small
{ "[truncated]": true, bytes }marker so one huge payload can never blow the ingest body limit or OOM the buffer; values that can't be serialized (circular refs,BigInt) become{ "[unserializable]": true }.
mcpeye is fail-open: every line of instrumentation is wrapped so that a
broken or unreachable analytics endpoint can never take down the server it is
observing. Your tools' own errors are always re-thrown unchanged.
How the instrumentation works
track() wraps your server's setRequestHandler and intercepts exactly two
MCP methods:
tools/list— after your handler runs, mcpeye augments each returned tool'sinputSchema.propertieswith the optionalmcpeyeIntentproperty. It never adds the field torequired, and is collision-safe: a tool that already declares its ownmcpeyeIntentproperty keeps it untouched. Injecting intopropertiesalso keeps anadditionalProperties: falseschema valid.tools/call— mcpeye times the handler, readsmcpeyeIntentoffparams.arguments(and strips it so your handler — and the SDK's own argument validation — sees clean arguments), then records the call. The collision is honored at runtime too: if a tool declared its ownmcpeyeIntentintools/list, mcpeye leaves that argument in place (it never deletes a field the tool owns) and does not capture it asintent.
Captured arguments and results are size-bounded (a value whose JSON
exceeds 32 KiB becomes a { "[truncated]": true, bytes } marker) and the
error message is redacted and byte-truncated the same way — so a giant or
secret-bearing error can never balloon the request body or leak onto the wire.
The low-level Server exposes setRequestHandler directly. The high-level
McpServer does not — it owns a low-level Server on its public .server
property and delegates to it — so track() detects an McpServer and unwraps
to .server automatically. mcpeye also wraps handlers that were registered
before track() ran, which is why call order is irrelevant. If a server shape
isn't recognized (a future SDK surface, or the wrong object passed), track()
never throws — it logs a loud console.warn (so it's never a silent no-op)
and returns a working, no-capture handle.
Attribute the end user (search by id / email)
The dashboard can search sessions by user id or email — but only if your server
tells mcpeye who the end user is. MCP has no built-in end-user identity, so you
supply it via identify. mcpeye resolves userId/userEmail per tool call, on
the request thread, so attribution is correct even on a multi-user / stateless
server where one flushed batch mixes users (a per-flush identity can't tell them
apart). Use your framework's per-request context — e.g. AsyncLocalStorage:
import { AsyncLocalStorage } from "node:async_hooks";
const userCtx = new AsyncLocalStorage<{ id: string; email?: string }>();
// In your request handler: userCtx.run({ id, email }, () => handleRequest(req))
track(server, "your-project-id", {
identify: () => {
const u = userCtx.getStore();
return { userId: u?.id, userEmail: u?.email };
},
});Pass an opaque, stable userId. userEmail is optional and is PII you store only
in your own deployment. Without identify, sessions read "user not identified" and
search-by-user returns nothing.
Options
track(server, "your-project-id", {
ingestUrl: "http://localhost:3001", // default: MCPEYE_INGEST_URL or localhost:3001
ingestSecret: "your-secret", // default: MCPEYE_INGEST_SECRET
identify: () => ({ // end-user identity — see "Attribute the end user"
userId: currentUser()?.id, // per CALL: who the end user is (powers search)
userEmail: currentUser()?.email, // per CALL: human-readable, optional
client: "claude-desktop/0.7.1", // per flush: process/connection-level
serverVersion: "1.0.0",
}),
redact: true, // default: true — scrub secrets/PII client-side
denylistFields: ["ssn", "cardNumber"], // extra field names whose values are dropped
flushIntervalMs: 5000, // default: 5000
batchSize: 50, // default: 50
captureMissingCapabilities: true, // default: true — inject mcpeye_request_capability
hostIntentParam: true, // default: true — fall back to your server's own intent field
onError: (err) => myLogger.debug(err), // default: console.debug
});| Option | Type | Default | Notes |
| ----------------- | --------------------- | -------------------------------------- | ---------------------------------------------------------------- |
| ingestUrl | string | MCPEYE_INGEST_URL / localhost:3001 | Base URL; mcpeye POSTs to ${ingestUrl}/ingest. |
| ingestSecret | string | MCPEYE_INGEST_SECRET | Sent as the x-mcpeye-secret header. |
| identify | () => Identity | () => ({}) | userId/userEmail resolved PER CALL (request thread → correct on multi-user servers); client/serverVersion per flush. See "Attribute the end user". |
| redact | boolean | true | Regex scrub of emails, keys, tokens, cards, phones + denylist. |
| denylistFields | string[] | [] | Field names (case-insensitive) whose values become [REDACTED_FIELD]. |
| flushIntervalMs | number | 5000 | Timer-based flush interval. |
| batchSize | number | 50 | Eager flush once this many events are buffered. |
| captureMissingCapabilities | boolean | true | Inject + locally answer the reserved mcpeye_request_capability tool. Set false to keep it out of your tools/list. |
| hostIntentParam | string \| boolean | true | Coexist with a server that already exposes its own analytics-style intent field. true = auto-detect a string intent field whose description reads like an intent prompt and harvest it as a fallback; false = off (never harvest); "name" = harvest that exact field, bypassing the semantic gate. See "Works with servers that already capture intent". |
| onError | (err) => void | console.debug | Sink for swallowed instrumentation/transport errors. |
Manifest cost. With
captureMissingCapabilitieson, your server'stools/listgains one extra tool — a few hundred tokens of definition 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; set the option tofalseif you'd rather not advertise it. How often the model calls it is driven by the tool's description, which lives in@mcpeye/coreso it can be tuned in one place across all SDKs.
Return value
track() returns a handle for tests and graceful shutdown:
const mcpeye = track(server, "your-project-id");
await mcpeye.flush(); // force-send buffered events now
await mcpeye.stop(); // flush + detach (also happens automatically on exit)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).
This is on by default (hostIntentParam: true): auto-detect targets a string
field named intent whose description reads like an intent prompt (it asks for
the user's goal/reason). Functional fields named intent — e.g. a Stripe
PaymentIntent id — are rejected by the gate and never harvested. 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.
track(server, "your-project-id", { hostIntentParam: true }); // default: gated auto-detect
track(server, "your-project-id", { hostIntentParam: false }); // off — capture only mcpeyeIntent
track(server, "your-project-id", { hostIntentParam: "reason" }); // explicit field nameAn explicit field name bypasses the safety gate.
hostIntentParam: "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.) Whether explicit or auto-detected, mcpeye resolves the field from your tool schema —track()reads it fromtools/list, so the field must be a declared tool parameter (the string form skips the description gate, not the schema lookup).
Privacy
Client-side redaction is conservative and over-redacts rather than leak, but the real privacy guarantee is that you self-host everything — the SDK only ever talks to the ingest URL you control.
License
MIT
