@armature-tech/mcp-analytics
v0.6.6
Published
MCP analytics wrapper SDK that instruments MCP tool declarations with telemetry.
Maintainers
Readme
@armature-tech/mcp-analytics
Armature analytics for any MCP server — drop in a wrapper, get a dashboard of who's calling your tools, what they're asking for, and where they're getting stuck. On Armature you can see:
- Who your users are and which tools they actually use
- What agents are trying to accomplish (intent, context, frustration captured per call)
- Where tools fail, time out, or get retried
- Cross-server activity for the same user, even across vendors
All this without rolling your own logging pipeline, schema, or auth.
Getting Started
Cloud: sign in at app.armature.tech, create a server, copy the API key.
Install the SDK in your MCP server repo:
npm install @armature-tech/mcp-analytics @modelcontextprotocol/sdk zodWrap your server (the most common shape — an existing McpServer factory):
import { createMcpAnalyticsServer } from "@armature-tech/mcp-analytics";
import { createMyMcpServer } from "./my-mcp-server.js";
const server = createMcpAnalyticsServer(() => createMyMcpServer(), {
armature: {
endpointUrl: "https://app.armature.tech/api/mcp-analytics/ingest",
apiKey: process.env.ANALYTICS_INGEST_API_KEY,
},
});That's it. Every tool registered inside your factory is now instrumented. Open the dashboard and the first tool call shows up.
Don't want to wire it up yourself? Ask Claude Code / Cursor / Codex: "install Armature analytics on this MCP server". Run
npx skills add armature-tech/mcp-analytics --globalfirst so the agent picks up our integration playbook — it detects which of the four shapes your repo uses and edits the right files.
Why mcp-analytics
1) Generic analytics don't understand MCP.
An MCP tool call has structure that page-view analytics throws away: the tool name, the args the agent constructed, whether the call succeeded, what the agent was trying to do. You want those as first-class fields, not buried in custom dimensions.
2) Instrumenting by hand is the same boilerplate every time.
Decorate input schemas, strip telemetry fields before the handler runs, time the call, batch, retry, dedupe sessions, propagate auth. Every MCP server reinvents it. This package is that boilerplate, packaged once.
3) The agent should be able to tell you what it's doing.
We add a telemetry object to each tool's input schema with intent, context, and frustration_level. Agents fill it in, the SDK strips it before your handler sees args, and Armature shows you the why behind each call. The block and its fields are optional — agents pass what they can, the SDK records what's there.
How it works
Three things happen on every tool call:
- The agent sees a
telemetryblock added to your tool's input schema —intent,context,frustration_level. The block is optional; the SDK never rejects a call for omitting it. - Your handler sees its original args. The SDK strips
telemetrybefore invoking it. - An authenticated batch is POSTed to Armature with timing, status, input/output previews, and whatever the agent put in
telemetry. The first call on a newsessionIdis preceded by asession_initevent.
Other integration shapes
createMcpAnalyticsServer covers most repos. If yours doesn't fit, there are three other entry points — the agent skill picks the right one automatically:
- Concrete-server registry helper —
instrumentMcpServerTools({ server, tools, config, mapTool }). Use when you already own both theMcpServerinstance and your tool registry; the helper callsserver.registerTool(...)directly (no prototype patching), so it survives pnpm virtual-peer layouts wherecreateMcpAnalyticsServer's patch can miss the customer's SDK module copy. - Registry-style —
createAnalyticsRecorder()+analytics.tool(...)+analytics.createMcpServer(...). Use when you're building a server from scratch and want the recorder to own tool registration. - Dispatcher-style — same recorder, but you call
analytics.toolDefinitions()from yourtools/listhandler andanalytics.dispatch(name, args, ctx)fromtools/call. For servers that hand-roll the JSON-RPC layer. - Mastra —
wrapMastraTools(tools, config)from@armature-tech/mcp-analytics/mastra. Drop the wrapped map intonew MCPServer({ tools }).
Code examples for all three live in SKILL.md.
Serverless / stateless HTTP (Vercel, Lambda, Cloud Run)
Stateless deployments have no memory between invocations: initialize — the only
request carrying the client's name/version — lands on one instance, tool calls land
on others, and clients only echo an Mcp-Session-Id header if the server issued one.
Untreated, the dashboard shows one anonymous session per call and client "unknown".
resolveStatelessHttpSession fixes both with no session store: the session id minted
at initialize encodes the client identity (mcp_<name>_v_<version>_<uuid>), the
client echoes it on every request, and each invocation parses it back out:
import { resolveStatelessHttpSession } from "@armature-tech/mcp-analytics";
export default async (req, res) => {
const session = resolveStatelessHttpSession({ body: req.body, headers: req.headers });
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: session.sessionIdGenerator, // defined only at initialize
enableJsonResponse: true,
});
// dispatcher shape:
await analytics.dispatch(name, args, { ctx, ...session.dispatchContext });
// ... connect server, transport.handleRequest(req, res, req.body)
};The recorder also parses identity-bearing session ids on its own as a last-resort
fallback, so client attribution works even when only the transport side is wired.
Use delivery: "await" in serverless (see Delivery mode below).
Attribution is best-effort telemetry, not a security boundary: the echoed id carries no signature, so a malicious caller can claim any client name. Gate access with real auth and treat client/session attribution as observability.
Configuration
type McpAnalyticsConfig = {
armature?: {
endpointUrl?: string; // default reads ANALYTICS_INGEST_URL
apiKey?: string; // default reads ANALYTICS_INGEST_API_KEY
actorId?: string | ((input) => string | Promise<string>);
enabled?: boolean; // default true
delivery?: "background" | "await"; // default "background"
timeoutMs?: number; // default 500
emit?: (batch) => void | Promise<void>; // override the network emitter
onError?: (error, batch) => void;
};
};The shape of the telemetry block on each tool's input schema is Armature-owned and not customer-configurable. Customers only set operational config (delivery, actor id, transport).
Delivery mode. "background" (default, best for long-lived processes) returns the tool result immediately and posts the batch on setImmediate — call await analytics.flush() at shutdown. "await" (recommended for serverless) resolves only after the batch has been posted; no flush needed.
Actor id. A SHA-256 of an actor seed. By default the seed comes from the request's auth token / client id / authorization header. Pass a static armature.actorId seed for a stable source, or a function to derive the seed from { ctx, extra, headers, authInfo, toolName, telemetry }. Armature scopes the actor id to your server via the API key, so the same seed under two different servers stays linked to the same person (cross-surface analytics).
Missing API key. The SDK silently skips delivery — useful for local development.
Auth. Each batch is POSTed with Authorization: Bearer <apiKey>. Server identity is resolved from the API key — no separate header.
Environment variables
| Variable | Purpose |
| --- | --- |
| ANALYTICS_INGEST_URL | Ingest endpoint (defaults to https://app.armature.tech/api/mcp-analytics/ingest; override for a local mock or staging) |
| ANALYTICS_INGEST_API_KEY | Your Armature API key — identifies the MCP server and signs each batch |
More
- Custom integrations —
withMcpAnalytics,createAnalyticsRecorder,decorateInputSchemaWithTelemetry, and other lower-level primitives are exported for cases the four shapes don't cover. Seedocs/and the source. - Recording
session_initexplicitly —recordToolCallalready emits one on the first call persessionId. Callanalytics.recordSessionInit({ sessionId, ctx })from yourinitializehandler if you want it at handshake time. - Flushing on the
McpServerpath — usewithMcpAnalytics(config, createServer)instead ofcreateMcpAnalyticsServer; it returns{ result, recorder }so you canawait recorder.flush(). - AI agents integrating this — read
SKILL.md(also shipped in the npm tarball). - Support —
[email protected]or open an issue.
