A recording proxy for MCP servers. Point your MCP client at mcp-blackbox instead of the real server, and it forwards every call while capturing the full request and response. Errors are pinned and kept forever; everything else ages out after the newest 200 calls per target.
It's the companion to fleetwatch: fleetwatch answers "is it up right now", mcp-blackbox answers "what actually happened when it broke."
- Register a target: the real URL of the MCP server you want recorded.
- mcp-blackbox gives you back a proxy URL (
/t/:targetId) — point your MCP client there instead. - Every call is forwarded to the real server, and the full request/response pair is stored. Payloads up to
MAX_BODY_SIZE(25mb by default) are relayed, so a tool returning a document is not rejected by the recorder. - A call counts as an error if the connection fails, times out, the HTTP status isn't 2xx, or the response carries a JSON-RPC
error. Error traces are pinned immediately and never auto-deleted. - A scheduled sweep (every 5 minutes by default) prunes non-pinned traces beyond the newest 200 per target.
- The dashboard shows live traces per target over WebSocket, and lets you drill into any call's full JSON.
- Removing a target removes what was recorded against it, in one transaction.
Every exchange is decoded into its JSON-RPC facts at record time — method, id, tool name, error code, session, batch size — and stored as indexed columns, so traces are queryable instead of opaque:
GET /traces?targetId=…&method=tools/call&tool=search_*&status=error
GET /traces?targetId=…&sessionId=…&toolFailed=true
Failures are also fingerprinted — the volatile parts of an error message (ids, numbers, urls, timestamps) are collapsed and the rest hashed with the method, tool and error code, so a target failing the same way ten thousand times is one incident with a count rather than ten thousand rows:
GET /traces/fingerprints?targetId=… # grouped, most frequent first
GET /traces?targetId=…&fingerprint=… # drill into one group
toolFailed is worth knowing about: a tools/call can come back HTTP 200 with a clean JSON-RPC result that carries isError: true. Neither the status code nor the error envelope shows it, so it is recorded separately.
Verified end to end: registered a target, proxied a real success and a real failure through it, confirmed both were captured correctly and only the failure was pinned.
Same Postgres and Redis you already run for fleetwatch — just a different database.
brew services start postgresql
brew services start redis
psql postgres -c "CREATE USER mcpblackbox WITH PASSWORD 'mcpblackbox';"
psql postgres -c "CREATE DATABASE mcpblackbox OWNER mcpblackbox;"
cd api
cp .env.example .env
npm install
npm run start:dev # http://localhost:3011Then in a new terminal tab:
cd ../web
cp .env.local.example .env.local
npm install
npm run dev # http://localhost:3000Prefer containers? docker-compose up -d from the repo root starts Postgres on port 5433 and Redis on port 6380 (different from fleetwatch's compose file, so both can run at once) — update api/.env to match those ports if you go this route.
Set BLACKBOX_API_KEY and the management API and the live trace feed both require it — Authorization: Bearer <key>, X-API-Key: <key>, or ?apiKey=<key> for the WebSocket handshake, compared in constant time. Leave it unset and everything stays open, which is fine for local use; the API logs a warning at startup so it is not a silent default.
The /t/:targetId proxy is never guarded. It is addressed by an unguessable target id and carries the caller's own credentials for the real MCP server, so putting a second key in front of it would mean every MCP client needs blackbox's key to make a call it is already authenticated for.
synchronize: trueon TypeORM creates tables automatically for local dev. Swap for real migrations before production.- Request/response bodies are redacted before they are stored — anything whose key looks like a credential (
authorization,password,token,apiKey,secret, …) becomes[redacted]. Add your own withREDACT_KEYS=customerRef,internalIdandREDACT_PATHS=params.arguments.query,**.ssn(*matches one segment,**any depth). Redaction is key-name based, so a secret in a field named something innocuous still lands in Postgres.
NestJS, TypeORM, BullMQ, Postgres, Redis, Socket.IO — Next.js, Tailwind.