npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mcpeye

v0.1.6

Published

Drop-in product analytics for MCP servers. See why your agent is failing.

Downloads

999

Readme

mcpeye

See why your agent is failing. Drop-in product analytics for MCP servers.

npm i mcpeye

mcpeye 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

  1. Injects a self-reported intent parameter. Every tool gains an optional mcpeyeIntent string 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.)
  2. Captures every tool call: callId, toolName, arguments, result, isError, errorMessage, durationMs, and timestamp. The mcpeyeIntent value is moved out of the arguments into a dedicated intent field — your real tool handler never sees it.
  3. Adds a reserved mcpeye_request_capability tool (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 with toolName = "mcpeye_request_capability". The report folds these explicit asks into "Top missing capabilities" as high-confidence, explicitly-requested entries. This complements the passive mcpeyeIntent param — it catches the silent miss, where the right move is to call no tool at all. Toggle with captureMissingCapabilities (default true).
  4. 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's inputSchema.properties with the optional mcpeyeIntent property. It never adds the field to required, and is collision-safe: a tool that already declares its own mcpeyeIntent property keeps it untouched. Injecting into properties also keeps an additionalProperties: false schema valid.
  • tools/call — mcpeye times the handler, reads mcpeyeIntent off params.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 own mcpeyeIntent in tools/list, mcpeye leaves that argument in place (it never deletes a field the tool owns) and does not capture it as intent.

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 captureMissingCapabilities on, your server's tools/list gains 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 to false if you'd rather not advertise it. How often the model calls it is driven by the tool's description, which lives in @mcpeye/core so 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 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).

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 name

An 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 like token/secret are still blocked.) Whether explicit or auto-detected, mcpeye resolves the field from your tool schema — track() reads it from tools/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