mcpeye Python SDK — open-source product analytics for MCP servers. See why your agent is failing.
Project description
mcpeye (Python SDK)
Open-source product analytics for MCP servers. See why your agent is failing.
mcpeye instruments your MCP server so you can answer the question the
hero Intent Gap Report is built around: what did users ask the tools to do
that the tools could not deliver?
It works by injecting an optional mcpeyeIntent parameter into every tool's
input schema. The agent self-reports, in its own words, why it is calling a tool
and any blocker the user hit — so intent is captured at near-zero cost, with no
per-call LLM. The clustering LLM runs later, server-side, only to build reports.
This is the Python SDK. The TypeScript SDK is the reference implementation; behaviour and the wire contract are identical across SDKs.
Install
pip install mcpeye
Requires Python 3.9+. Depends on httpx and pydantic. The mcp package is
not a hard dependency — it is your server's framework, imported lazily by
track(). If you use the automatic track() path, install it alongside (or use
the extra, which pins mcp>=1.0.0, itself Python 3.10+):
pip install "mcpeye[mcp]"
Quick start
Works out of the box with both official server styles — a low-level
mcp.server.Server or a high-level FastMCP. Call track(...) once, after
you have registered your tools (list_tools/call_tool handlers, or
@mcp.tool() functions):
from mcp.server.lowlevel import Server
import mcpeye
server = Server("my-server", version="1.2.3")
@server.list_tools()
async def list_tools():
return [ ... ]
@server.call_tool()
async def call_tool(name, arguments):
...
# Instrument it. mcpeyeIntent is injected into every tool schema, calls are
# captured, redacted, buffered, and shipped to the ingest API.
mcpeye.track(
server,
project_id="proj_123",
ingest_url="http://localhost:3001", # or set MCPEYE_INGEST_URL
ingest_secret="...", # or set MCPEYE_INGEST_SECRET
)
track returns the same server instance, instrumented in place.
For FastMCP, pass the FastMCP object directly — mcpeye resolves its inner
low-level server automatically (mcpeyeIntent is stripped before your function
runs, so a plain def search(query) never sees the extra argument):
from mcp.server.fastmcp import FastMCP
import mcpeye
mcp = FastMCP("my-server")
@mcp.tool()
def search(query: str) -> str:
...
mcpeye.track(mcp, project_id="proj_123", ingest_url="http://localhost:3001")
What track does
- Injects
mcpeyeIntentinto each tool'sinputSchema(and teaches the server's tool cache about it, so the built-in input validation accepts the parameter). The agent fills it in; your tool handler never sees it — it is stripped from the arguments before your code runs. - Captures every tool call: tool name, redacted arguments, redacted result, error state + message, duration, and the reported intent. Each captured field is size-bounded (oversized or unserializable values become a small marker) so a multi-MB tool result can never blow the ingest body limit or grow the buffer.
- 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. mcpeye answers it locally with a canned acknowledgement — it is never forwarded to your server — and records it as a normal tool call withtool_name = "mcpeye_request_capability", which the report folds into "Top missing capabilities" as a high-confidence, explicitly-requested entry. This catches the silent miss, where the right move is to call no tool at all. Disable withcapture_missing_capabilities=False. - Ships batches as the shared
IngestPayloadJSON to<ingest_url>/ingestwith thex-mcpeye-secretheader, usinghttpx. Shipping happens on a background daemon thread — never on the tool-call thread — so a slow or unreachable ingest endpoint adds no latency to your tools. It flushes on a timer (default every 5s), eagerly when the batch fills (flush_at), and a final time at process exit. Shipping is best-effort: ingest failures are swallowed (routed toon_error) and retried; analytics can never take down your MCP server.
Strict ingest URL. Unlike the TypeScript SDK (which defaults to
http://localhost:3001), the Python SDK raisesValueErrorat setup time if no ingest URL is configured — there is no implicit localhost default, so a misconfigured server fails loudly at your call site instead of silently dropping telemetry into a dead port.
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 pass
identify: a callable resolved per tool call, on the request thread, so
attribution is correct on a multi-user / stateless server where one flushed batch
mixes users. Use contextvars for the per-request value:
import contextvars, mcpeye
_user = contextvars.ContextVar("mcpeye_user", default=None)
# In your request handler: _user.set({"id": uid, "email": email})
mcpeye.track(
server,
"your-project-id",
ingest_url="http://localhost:3001",
identify=lambda: (_user.get() or {}), # returns {"userId": ..., "userEmail": ...}
)
Return {"userId": ..., "userEmail": ...}. Pass an opaque, stable userId;
userEmail is optional and is PII you store only in your own deployment. (For a
single-user process you can instead pass static user_id=/user_email=.) Without
either, sessions read "user not identified" and search-by-user returns nothing.
Configuration
| Argument | Env fallback | Default |
|---|---|---|
ingest_url |
MCPEYE_INGEST_URL |
— (required, raises if unset) |
ingest_secret |
MCPEYE_INGEST_SECRET |
none |
redact |
— | True |
identify |
— | none — callable resolved PER CALL for userId/userEmail (see below) |
user_id |
— | none (static fallback) |
user_email |
— | none (static fallback) |
client |
— | none |
server_version |
— | server.version when available |
flush_at |
— | 20 buffered events |
flush_interval_s |
— | 5.0 seconds (background timer) |
denylist_fields |
— | none (adds to the built-in denylist) |
capture_missing_capabilities |
— | True (inject mcpeye_request_capability) |
on_error |
— | debug log on the mcpeye logger |
Manifest cost. With
capture_missing_capabilities=True, your server'stools/listgains 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 of the manifest.
Diagnostics
Every swallowed error (transport failure, capture failure) is routed to on_error,
which defaults to a debug-level log on logging.getLogger("mcpeye"). Pass your
own sink to see why telemetry might be silent — it is wrapped so it can never throw
back into your server:
mcpeye.track(server, "proj_123", ingest_url="http://localhost:3001",
on_error=lambda err: print("mcpeye:", err))
Redaction
When redact=True (the default), arguments, results, and the reported intent are
scrubbed client-side before anything leaves your process. The v1 redaction is
a conservative regex pass — it over-redacts rather than leak — covering:
- emails
- API keys:
sk-…,sk-ant-…,ghp_…/gho_…/etc.,AKIA… Bearer …tokens and JWTs- credit-card-shaped digit runs
- loose international phone numbers
- a field-name denylist (
password,secret,token,apiKey,authorization, …)
Self-hosting is the real privacy mitigation; redaction reduces the blast radius of obvious secrets in free-text. You can use the primitives directly:
from mcpeye import redact_string, redact_value
redact_string("ping me at a@b.com") # -> "ping me at [REDACTED_EMAIL]"
redact_value({"password": "hunter2"}) # -> {"password": "[REDACTED_FIELD]"}
Manual instrumentation (wrap_tool)
The mcp package's Server internals vary across versions. track attaches to
server.request_handlers (resolving FastMCP's inner server automatically); if a
future layout isn't recognized, track does not raise — it logs a clear
WARNING and returns the server uninstrumented (a version mismatch must never break
your server's boot). A genuine config error (no ingest URL) still raises. Either
way, you can fall back to instrumenting individual tool handlers:
import mcpeye
@mcpeye.wrap_tool(project_id="proj_123", tool_name="search",
ingest_url="http://localhost:3001")
async def search(arguments):
...
wrap_tool pulls mcpeyeIntent out of the arguments, times the call, captures
the outcome, and ships it — the same capture path as track, scoped to one tool.
It accepts redact, ingest_url, ingest_secret, denylist_fields, flush_at,
flush_interval_s, and on_error (not user_id/client/server_version, which
are server-level identity). Note: it does not inject the parameter into a published
schema, so add mcpeyeIntent to that tool's inputSchema yourself (see
mcpeye.intent_param_json_schema) if you want agents to populate it.
Development
Unit tests stub the mcp package, so they run with only httpx, pydantic, and
pytest installed (no mcp):
python3 -m venv .venv && .venv/bin/pip install httpx pydantic pytest
.venv/bin/pip install -e packages/sdk-python --no-deps
.venv/bin/python -m pytest packages/sdk-python/tests -q
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mcpeye-0.1.4.tar.gz.
File metadata
- Download URL: mcpeye-0.1.4.tar.gz
- Upload date:
- Size: 39.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
886554e6dfec6477d131a8e58071e53e0159ded36ee6b55608904e3a908cef60
|
|
| MD5 |
94d379e13635d62b7f615b2846884467
|
|
| BLAKE2b-256 |
09b3be9d9a1a7a4c1bb83f13eda9f28e3eca46863cf80af47d64f5a62150ef41
|
File details
Details for the file mcpeye-0.1.4-py3-none-any.whl.
File metadata
- Download URL: mcpeye-0.1.4-py3-none-any.whl
- Upload date:
- Size: 27.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7e9181f1e69ed6c8f5ce3cfe9ed738818d7617ec387e9a2df19f94db94c3a70d
|
|
| MD5 |
38a56703ff35c6d1bdb96329f134d338
|
|
| BLAKE2b-256 |
24330ab5796588058518d1d9b447c03ec67fcfdaa1728db2ba10d492a37fd4c8
|