Amplitude MCP Analytics SDK — Model Context Protocol server usage tracking for Amplitude Analytics.
Status: Preview. Server and tool instrumentation, the default event set, identity resolution, and custom events are available now. Transport and correlation handling (stdio and Streamable HTTP, across protocol revisions) is handled for you under the hood.
There are two ways to set up: let a coding agent do it, or follow this README manually.
Option 1 — agent-assisted Install the
instrument-mcp-server skill
(part of the builder-skills
engineering-skills plugin) and ask your agent to instrument your MCP server —
it walks through this README for you, plus the recommended rationale and UTM
steps.
Option 2 — manual
pnpm add @amplitude/mcp-analytics @amplitude/analytics-node @modelcontextprotocol/sdk zod@amplitude/analytics-node, @modelcontextprotocol/sdk, and zod are peer
dependencies — your MCP server already depends on the latter two. zod is
the same range the MCP SDK accepts (^3.25.0 or ^4.0.0); the feedback
tool builds its input schema with it. Any
@modelcontextprotocol/sdk from 1.14.0 up is supported, including versions
1.21.0+, which changed how McpServer reports a failed tools/call; the
default events mean the same thing across that whole range.
import { createMcpAnalytics } from '@amplitude/mcp-analytics';
const analytics = createMcpAnalytics({
apiKey: process.env.AMPLITUDE_API_KEY!,
serverName: 'my-mcp-server',
serverVersion: '1.0.0',
});
// Bind the server (enables analytics + emits connection events), then wrap your
// tool handlers. Order matters: instrumentServer() must run before connect().
analytics.instrumentServer(server, { authType: 'oauth' });
server.tool(
'search_docs',
schema,
analytics.instrumentTool(
async (args, extra) => doSearch(args), // your handler, unchanged
{ name: 'search_docs' },
),
);
await server.connect(transport);To reuse an Amplitude client you already own, pass it instead of apiKey:
createMcpAnalytics({ amplitude, serverName: '...', serverVersion: '...' });Two steps, both wrap things you already have — no handler signatures change.
instrumentServer(server, options?) binds the SDK to your MCP server. It
auto-detects the transport, captures the client/server handshake, and emits the
default connection events. Call it before server.connect() — that's when
the transport becomes available. It's idempotent and returns the same server.
instrumentTool(handler, meta) wraps a tool handler. The returned function
has the exact same shape as the one you pass in ((args, extra) with a schema,
(extra) without), so it drops straight into server.tool(...). On each call it
emits [MCP] Tool Call Response with timing, error, and size details.
analytics.instrumentServer(server);
server.tool('search', schema, analytics.instrumentTool(
async (args, extra) => doSearch(args),
{ name: 'search', owner: 'docs-team', extra: { 'feature flag': 'new-ranker' } },
));
instrumentToolrequiresinstrumentServer. If the server was never bound, the wrapper is a no-op passthrough: your handler runs untouched, nothing is emitted, and a one-time warning is logged. Instrumenting a tool can never change its behavior.
Once a server is bound and its tools wrapped, the SDK emits these automatically:
| Event | When | Notable properties |
|---|---|---|
[MCP] Session Initialized |
The initialize handshake (every transport) |
client/server identity, [MCP] Transport, [MCP] Auth Type |
[MCP] Session Ended |
Close of a connection that outlived one request (stdio + legacy Streamable HTTP) | [MCP] Session Duration |
[MCP] Tools Listed |
A tools/list request |
[MCP] Tool Count, [MCP] Tool Names (capped), [MCP] Response Duration, [MCP] Response Size |
[MCP] Tool Call Response |
Every instrumented tool call | [MCP] Is Error, [MCP] Error Message/[MCP] Error Code/[MCP] Error Type/[MCP] Error HTTP Status, [MCP] Response Duration, [MCP] Request Size, [MCP] Response Size, client-supplied conversation/run/turn correlation, content-free [MCP] Param * shape metadata, [MCP] Rationale (opt-in, see below) |
[MCP] Tool Call Rejected |
A tools/call request that fails before any tool callback runs (unknown/disabled tool, input-schema validation) |
[MCP] Attempted Tool Name (unvalidated input — kept off [MCP] Tool Name), [MCP] Rejection Reason (unknown_tool/disabled_tool/schema_validation/unrecognized), [MCP] Error Message, [MCP] Response Duration, [MCP] Response Size, [MCP] Response HTTP Status |
[MCP] Feedback Submitted |
A call to the opt-in submit_feedback tool (see below) |
[MCP] Feedback Helpful, [MCP] Feedback Reason, [MCP] Feedback Solicited, [MCP] Feedback Tool Names, [MCP] Feedback Has Comment. Not gated by autocapture |
All event names and properties are prefixed [MCP] so they never collide with
same-named events/properties from other Amplitude SDKs on the same project.
This table is a summary. The full reference — every property and when it's
present, identity resolution, transport nuances, and the error taxonomy —
lives in docs/events.md, which is also the normative
event contract if you are implementing it in another language (see
docs/porting.md).
A protocol session only exists on stdio and on Streamable HTTP where the transport mints a session id. Two distinct cases look "stateless" and behave differently:
- Sessionless transport mode (
sessionIdGenerator: undefined, protocol2025-11-25and earlier) still performs theinitializehandshake, so[MCP] Session Initializedfires with[MCP] Session ID: no-session.[MCP] Session Endeddoes not, since the connection lives and dies inside one request and would report no meaningful duration. - Protocol revision
2026-07-28removes the handshake entirely, so neither session event fires. Client identity moves to per-request_meta, which this SDK already reads. The revision is implemented by the v2 SDK package set (@modelcontextprotocol/{core,server,client}2.0.0), not by@modelcontextprotocol/sdk1.x, which tops out at2025-11-25.
Nothing is fabricated in either case. Every event also carries the shared context properties (identity, client/server, transport, trace correlation).
Clients can identify a conversation, run, and turn on each request through
_meta. The preferred keys are conversation_id, run_id (or job_id), and
turn_id (or turn_number):
{
_meta: {
conversation_id: 'conversation-123',
run_id: 'run-456',
turn_id: 'turn-7',
},
}The SDK emits [MCP] Conversation ID, [MCP] Run ID, and [MCP] Turn ID.
job_id is normalized to the run property. Numeric turn values are normalized
to strings. Camel-case (conversationId, threadId, runId, jobId,
turnId, turnNumber) is also accepted.
Codex sends the turn id at _meta["x-codex-turn-metadata"].turn_id. A
top-level turn key wins over that nested value. Only turn_id is read from
the nested object.
ChatGPT already sends two of these on tool calls. _meta["openai/session"] is
read as [MCP] Conversation ID when no unnamespaced conversation or thread id
is present. _meta["openai/subject"] is emitted as [MCP] Subject ID. That
subject is an anonymized ChatGPT user id, so it stays off user_id and does
not group separate conversations into one episode.
Each tool-scope event also reports [MCP] Episode Anchor Type and
[MCP] Episode Anchor Confidence. The strongest available source wins:
conversation, run/job, transport session, W3C trace, then inferred. These
properties do not replace [MCP] Session ID; legacy session semantics remain
unchanged.
Through protocol 2025-11-25 the client's clientInfo rides only on the
initialize request, so on a sessionless or serverless host — where each
request gets a fresh McpServer — nothing on a tools/call identifies the
client. (Revision 2026-07-28 fixes this at the protocol level by putting
client identity in every request's _meta, which this SDK reads — but that
revision is implemented by the v2 SDK packages, not by
@modelcontextprotocol/sdk 1.x, and even there it is only a SHOULD.) Two
things help today:
[MCP] OAuth Client IDis emitted fromauthInfo.clientIdon every authenticated request with no host-side state. It names a client registration rather than a product, so it is kept out of[MCP] Client Name.resolveClientInfosupplies the name per request, typically from a token claim (an authorization server already knowsclient_namefrom dynamic client registration):
analytics.instrumentServer(server, {
resolveClientInfo: ({ authInfo }) => ({ name: authInfo?.client_name as string }),
});See docs/events.md for the full
precedence order.
user_id must match whatever you already send to Amplitude for the same user.
The SDK never guesses it from auth — you provide it, via whichever path fits:
// 1. Bound with the server. Scoped to that binding — hosts that build one
// McpServer per request can pass per-request values safely; concurrent
// bindings never overwrite each other.
analytics.instrumentServer(server, {
userId: 'user-123',
tenant: { groupType: 'org id', groupValue: '456' },
});
// 2. Per request, inside a handler (wins over everything else).
analytics.instrumentTool(async (args, extra) => {
analytics.setIdentity({ userId: myAuth.getLoginId(extra) });
return doWork(args);
}, { name: 'search' });
// 3. Opt-in, derived from the request's authInfo (you map the claims).
analytics.instrumentTool(handler, { name: 'search' }, {
resolveIdentity: (authInfo) => ({ userId: authInfo?.sub as string }),
});Resolution order (first match wins): setIdentity() → resolveIdentity() →
instrumentServer options → correlation anchor → an anonymous floor. When no
explicit identity is supplied but a correlation anchor exists (a stdio process,
a legacy session id, or a propagated W3C trace context), the SDK emits accurate
aggregate-only data under a synthetic device_id derived from that anchor —
never a polluting placeholder, never a fabricated user.
If there is no anchor either — the fully stateless case with no identity and no
tenant — each request would mint a brand-new random device_id with no
cross-call stitching, so those events are dropped by default rather than
inflating your user counts (see docs/events.md). Opt in to
emit them as anonymous, aggregate-only data with emitAnonymousEvent: true:
import { MCPAnalyticsConfig } from '@amplitude/mcp-analytics';
new MCPAnalyticsConfig({ emitAnonymousEvent: true });Agent clients often supply a free-text rationale for a tool call ("why I'm
calling this tool"). If your server receives one — as a tool argument, in
_meta, a header, or however your convention works — pass it to the SDK and
it is emitted as the reserved [MCP] Rationale property on the tool-call
event and on every tool-scope custom event of the same invocation:
analytics.instrumentTool(async (args, extra) => {
if (typeof args.rationale === 'string') {
analytics.setRationale(args.rationale);
}
return doWork(args);
}, { name: 'search' });The SDK never reads rationale out of tool inputs itself: it is content-bearing
free text, so emitting it is an explicit opt-in, and where it lives is your
convention. Callable at any depth inside an instrumented handler (like
setIdentity); truncated to 1000 characters; last write wins. Omitted
entirely when never set.
registerFeedbackTool(server) adds a submit_feedback tool to a high-level
McpServer. The agent calls it when the user reacts to a result from this
server ("thanks, that's it", "that's wrong") or asks to give feedback, and the
SDK emits [MCP] Feedback Submitted. instrumentServer does not add the tool.
A server that never opts in is unchanged.
analytics.instrumentServer(server);
analytics.registerFeedbackTool(server);
await server.connect(transport);The tool description is what tells the agent when to call it. The result text
tells it not to ask again in the same conversation unless the user brings
feedback up. The call does not also emit [MCP] Tool Call Response.
helpful is a boolean. reason, when the user gave one, is one of
wrong_result, incomplete, too_slow, wrong_tool, missing_capability,
or other. An optional tools list is kept only for names registered on that
server. solicited records whether the agent asked or the user volunteered.
Free-text comment is off by default, and the field is then absent from the
schema. Opt in to emit [MCP] Feedback Comment, capped at 500 characters and
passed through sanitizeErrorMessage:
analytics.registerFeedbackTool(server, { captureComment: true });A name that is already registered is left alone. Pass name to register
alongside an existing tool of the same name. On a low-level Server (no
registerTool) this warns and does nothing.
The event follows the same anonymous-floor rule as every other event: a call
with no identity, no tenant, and no correlation anchor is dropped unless
emitAnonymousEvent is on. It inherits the request's episode ids, which is
how feedback joins to a conversation without the SDK sending the conversation.
To name the tool in the server's own instructions (those are fixed when the
McpServer is constructed), append FEEDBACK_TOOL_INSTRUCTIONS:
import { FEEDBACK_TOOL_INSTRUCTIONS } from '@amplitude/mcp-analytics';
const server = new McpServer(
{ name: 'my-mcp-server', version: '1.0.0' },
{ instructions: `Your instructions.\n\n${FEEDBACK_TOOL_INSTRUCTIONS}` },
);feedbackToolInstructions(name) is the same paragraph for a renamed tool.
createFeedbackToolHandler(analytics, options) is the callback on its own,
for a framework that registers tools itself. Pass server in those options
so tools can be checked against that server's registry.
For handlers with an input schema, [MCP] Tool Call Response automatically
includes content-free metadata describing which parameters were supplied and
their shallow types:
[MCP] Param Keys— sorted top-level keys, capped at 32[MCP] Param Count— the full top-level key count[MCP] Param Shape— deterministic types, array/object counts, and bucketed string lengths, capped at 1,024 characters[MCP] Param Fingerprint— a stable 12-character hash of the shape
Values are not included. Nested arrays and objects are counted, not traversed,
and exact string lengths are bucketed. Only identifier-like keys
([A-Za-z0-9_.:\\-/]{1,64}) appear on [MCP] Param Keys and
[MCP] Param Shape; caller-controlled names from record/passthrough schemas
(for example an email used as a key) are omitted there but still counted in
[MCP] Param Count. By default, rationale and context are also excluded
because servers commonly use those names for content-bearing injected metadata.
Multiplexed tools can include a safe route value in the shape (routeKey is
for small schema enums — string, finite number, or boolean — not userId-
style fields). Tools can also opt into bounded scalar facts derived from
their inputs:
server.tool('manage_items', schema, analytics.instrumentTool(
async (args, extra) => manageItems(args),
{
name: 'manage_items',
paramCapture: {
routeKey: 'action',
never: ['privateMetadata'],
derive: (params) => ({
itemCount: Array.isArray(params.items) ? params.items.length : 0,
destructive: params.action === 'delete',
}),
},
},
));Derived facts are emitted as [MCP] Param: <key>. The SDK accepts at most eight
per tool call and drops non-scalars, non-finite numbers, long strings, unsafe
property names, and strings containing email/free-text indicators such as @,
quotes, or newlines. never applies to both input keys and those derived fact
names. A throwing derive callback is ignored and never affects the tool
response. A mistyped opt-in field (routeKey, derive, never) logs a
warning and is ignored; default shape capture still runs. Only a non-object
paramCapture disables capture for that tool.
Disable automatic shape capture, or replace the global exclusion list, through
the SDK config. neverKeys replaces the default
['rationale', 'context'] — include those names if you still want them
omitted. Opted-in derive facts still run when shape is disabled:
import { DEFAULT_PARAM_NEVER_KEYS, MCPAnalyticsConfig } from '@amplitude/mcp-analytics';
new MCPAnalyticsConfig({
paramCapture: {
shape: false,
neverKeys: [...DEFAULT_PARAM_NEVER_KEYS, 'reason', 'customerContext'],
},
});The SDK may inspect and serialize inputs to derive this metadata and
[MCP] Request Size, but it never emits input content unless the server author
explicitly opts into a derived fact or calls a content-bearing API such as
setRationale.
When a tool call fails on a thrown error that carries an HTTP status
(err.status or err.statusCode — the common Node conventions), the
tool-call event includes [MCP] Error HTTP Status. This is the status of the
failure the tool hit (an upstream API response, an HTTP-shaped error), NOT the
MCP transport status — per the MCP spec, tool failures are returned in-band,
so the transport typically answers 200 even when this property is a 4xx/5xx.
For error shapes the SDK can't sniff, set it explicitly when building the
error: analytics.toolError(ctx, { code, message, httpStatus: 502 }).
Related but distinct: [MCP] Response HTTP Status is the transport-level
status of the HTTP response itself. The instrumented-tool wrapper never emits
it (dispatched tool calls answer 200; the wrapper emits before the response is
written). The default [MCP] Tool Call Rejected event carries it on
Streamable HTTP (protocol-level rejections answer 200 with the error in the
JSON-RPC body). For events you emit yourself, set responseHttpStatus on the
context's request info before calling trackToolEvent.
All default events are on by default. Toggle them with autocapture — a boolean
for everything, or an object to control families independently:
import { createMcpAnalytics, MCPAnalyticsConfig } from '@amplitude/mcp-analytics';
createMcpAnalytics({
apiKey: process.env.AMPLITUDE_API_KEY!,
serverName: 'my-mcp-server',
serverVersion: '1.0.0',
config: new MCPAnalyticsConfig({
autocapture: { serverEvents: false }, // keep tool-call events, drop connection events
}),
});autocapture: false disables all default events; { serverEvents, toolCalls }
toggles each family. toolCalls covers both [MCP] Tool Call Response and
[MCP] Tool Call Rejected. serverEvents can be split further with
sessionLifecycle ([MCP] Session Initialized/Ended) and toolsListed
([MCP] Tools Listed) — e.g. servers built per HTTP request typically want
{ sessionLifecycle: false, toolsListed: true }, since their transports close
at the end of every request rather than at session end. Custom events (below)
are unaffected.
[MCP] Error Message carries free text the SDK didn't compose — a failing tool's
own message, or the MCP SDK's input-validation text, which quotes the rejected
argument value. Either may contain end-user data. sanitizeErrorMessage rewrites
or drops it before emission, on every event that carries it:
createMcpAnalytics({
apiKey: process.env.AMPLITUDE_API_KEY!,
serverName: 'my-mcp-server',
serverVersion: '1.0.0',
config: new MCPAnalyticsConfig({
sanitizeErrorMessage: (message) =>
message.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, '<email>'),
}),
});Return null to omit the property entirely. [MCP] Error Code and
[MCP] Error Type are unaffected, so failures stay segmentable. The text sent to
the client never changes. Built-in PII redaction still runs on whatever string
the sanitizer returns. See
Redacting [MCP] Error Message.
By default the SDK redacts personally identifiable information from the free-form content of every event it emits:
- host enrichment on
extra(server scope and tool metadata) - properties passed to
trackServerEvent/trackToolEvent [MCP] Rationale[MCP] Error Message(aftersanitizeErrorMessage, when that hook is set)- opt-in derived
[MCP] Param:values
The built-in patterns cover emails, phone numbers (including international
+ numbers), credit cards, SSNs, and IPv4/IPv6 addresses. A value that is
entirely a base64-encoded image is replaced with [base64 image redacted].
Identity and dimension fields are never redacted — user id, device id,
group, session id, server and tool names, error type, and the rest of the
reserved [MCP] properties other than rationale and error message. Redacting
those would corrupt attribution. Raw tool arguments are not sent at all; see
Tool parameter capture.
import { MCPAnalyticsConfig, createMcpAnalytics } from '@amplitude/mcp-analytics';
const analytics = createMcpAnalytics({
apiKey: process.env.AMPLITUDE_API_KEY!,
serverName: 'my-mcp-server',
serverVersion: '1.0.0',
config: new MCPAnalyticsConfig({
// redactPii: true, // built-in PII patterns (default)
customRedactionPatterns: [
'secret-\\d+', // bare string → "[REDACTED]"
{ pattern: '\\bACME-\\d+\\b', replacement: '[ticket]' },
],
customRedactionFn: (text) => text.replace(/internal-\w+/g, '[hidden]'),
}),
});customRedactionPatterns adds rules. It does not replace the built-in set,
and there is no switch for one built-in pattern. Set redactPii: false to
turn the built-in patterns off, then list the ones you still want as custom
patterns. @amplitude/ai works the same way.
Custom patterns, customRedactionFn, and base64-image replacement still run
when redactPii is false.
Custom redaction fails closed. If customRedactionFn throws or returns a
non-string, or a custom pattern fails while running, that value is sent as
[content redacted]. The first failure is logged with the error type only,
never the message, which might echo the input. An invalid custom pattern, or a
customRedactionFn that is not a function, throws a ConfigurationError when
MCPAnalyticsConfig is constructed.
The standalone trackServerEvent and trackToolEvent functions take the same
three fields on their options argument. AmplitudeMCPAnalytics methods always
use the config passed to the client.
Every tracked event carries a per-invocation context object. You can construct
one and pass it explicitly to the tracking APIs, or expose it via
runWithContext so deeper call stacks can read it through getCurrentContext().
import {
createServerContext,
createToolContext,
runWithContext,
} from '@amplitude/mcp-analytics/context';
const serverCtx = createServerContext({
server: { name: 'my-mcp-server', version: '1.0.0' },
transport: 'stdio',
});
const toolCtx = createToolContext(serverCtx, { name: 'search_docs' });
runWithContext(toolCtx, () => {
// getCurrentContext() is available here if needed
});Types and helpers are also re-exported from the main entry
(@amplitude/mcp-analytics).
You usually don't build ctx by hand — instrumentServer / instrumentTool
construct and inject it for you. Reach for these factories when emitting events
outside an instrumented handler.
Every event carries a set of reserved properties the SDK derives from the context — identity, session/trace correlation, client/server identity, and (for tool events) the tool metadata. You can attach your own properties on top of these from two places:
extra— an enrichment bag carried on the context. Put domain values at the server scope (extraininstrumentServeroptions) or on a tool (extrain the tool metadata) and they ride along on every event derived from that scope — including the default events.properties— the per-call argument totrackServerEvent/trackToolEvent, for values specific to that one event.
When the same key appears in more than one place, the merge order is fixed — later sources overwrite earlier ones:
reserved (SDK-derived) < extra (context bag) < properties (per call)
- A
propertiesvalue wins over anything with the same key — including a reserved property (the explicit, per-call value is the most intentional one). - An
extravalue overrides a reserved property but loses toproperties. - On the default events, the SDK's outcome values (
[MCP] Is Error,[MCP] Response Duration, …) ride as per-callproperties, so a collidingextrakey can't overwrite them.
Reserved names all carry the [MCP] prefix — avoid it in your own keys and
collisions never arise.
extra properties are included by default. To omit them for a single event,
pass { dropExtraProps: true }:
analytics.trackToolEvent(ctx, 'my event', { foo: 'bar' }, { dropExtraProps: true });Free-form strings in extra and properties are run through
PII redaction before they are sent. Reserved
dimension fields are not. The SDK does not otherwise escape values — apply
output encoding where the data is rendered.
MCP server analytics is a distinct product from agent analytics. Different audience (MCP server operators vs. agent developers), different domain model (server / session / tool invocation vs. agent / turn / message), and a different release cadence. Keeping the repos separate lets each evolve on its own timeline without coupling unrelated breaking changes.
This package is Node/TypeScript only; the suffix leaves room for sibling SDKs
without forcing a rename. A
Python SDK
implements the same event contract. For any other language, that contract is
documented and portable — see docs/porting.md.
Build tooling (tsdown, vitest, biome), repo layout, constructor shape, mock test client, subpath exports, and release pipeline all mirror Amplitude-AI-Node so contributors moving between the two repos see familiar patterns. The domain model — events, properties, identity, context — is MCP-native and intentionally does not reuse agent vocabulary.
A small set of shared, low-level utilities (the delivery proxy + hooks,
serverless flush accounting, privacy/PII redaction) is vendored from
@amplitude/ai rather than taken as a dependency. This keeps the two packages independent at runtime —
no shared package, no version coupling — while reusing battle-tested code.
Contributor notes on the vendoring policy live in VENDORED.md.
pnpm install
pnpm build
pnpm test
pnpm lint