opentel-mcp
v0.17.0
Published
One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers
Maintainers
Readme
opentel-mcp
Turn every MCP tool call into an OpenTelemetry trace — including the failures your logs won't show you, and what it cost in LLM tokens.
opentel-mcp watches every tool call your MCP (Model Context Protocol) server handles: which tool ran, how long it took, whether it worked, and — when the tool result carries usage data — how many tokens it burned and what that cost. It reports all of that as OpenTelemetry (OTel) traces — the standard most dashboards already read. One function call; no changes to your tools' code.
opentel-mcp is the only Node.js MCP instrumentation library that ties tool calls to LLM cost.
See it locally
npx opentel-mcp-ui --demoOpens a dashboard at http://127.0.0.1:4319 — no MCP server, no
Prometheus, no Collector to stand up first. It's seeded with realistic
fixture data showing exactly what this README is about: tool calls that
returned isError: true inside a successful response, rendered as the
clean spans a standard OTel setup would show, side by side with what
opentel-mcp actually caught.
Drop --demo and point your own instrumented server's exporterUrl at
it to see real traffic instead — see
opentel-mcp-ui's README
for the two-minute walkthrough.
The problem
Your AI agent calls 15 MCP tools across 3 servers this turn. One tool
returns { isError: true } inside an otherwise-successful response — how
a tool reports "I couldn't do that" without crashing. Your logs show
success. Your metrics show success. The agent gives a wrong answer, and
nothing you're monitoring says why.
opentel-mcp makes that failure visible: one span per tool call, marked as an error when it actually is one, using the same standard your dashboards already speak.
Install
npm install opentel-mcp @opentelemetry/apiopentel-mcp is an ES module — add "type": "module" to package.json.
If you're starting from a blank project to try the quickstart below
(rather than adding opentel-mcp to an MCP server you already have), you
also need the MCP SDK and zod — the quickstart's own server and tool
definitions, not opentel-mcp's dependencies:
npm install @modelcontextprotocol/sdk zod30-second quickstart
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { instrumentMcpServer } from 'opentel-mcp';
import { z } from 'zod';
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
// Wraps every tool registered below. Must run BEFORE server.registerTool() —
// see "Ordering constraint" below for why.
instrumentMcpServer(server, {
serviceName: 'my-mcp-server', // shows up on your traces
setupNodeSdk: true, // dev mode: prints traces to your terminal
});
// A normal tool, registered exactly as usual.
server.registerTool('echo', { inputSchema: { text: z.string() } }, async ({ text }) => ({
content: [{ type: 'text', text: `you said: ${text}` }],
}));
const transport = new StdioServerTransport();
await server.connect(transport);That's it. Every tool call now emits a trace. Wire an exporter to see them (next section). When the server stops — your MCP client closing stdin, or sending SIGTERM/SIGINT — anything still buffered is flushed first, automatically (see "Flushing on exit" below).
See it working
Save the snippet as server.js. On its own it just waits: an MCP server
reads requests from stdin and does nothing until a client sends one. To
try it without a client, pipe in a single tool call:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' | node server.jsThe JSON-RPC response goes to stdout, and this prints to stderr — a real,
captured run (full dump: examples/hello-mcpserver/README.md):
name: 'tools/call echo'
kind: 1 // SpanKind.SERVER
status: { code: 1 } // OK
attributes: {
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': 'echo',
'mcp.tool.argument_count': 1,
'jsonrpc.request.id': '1'
}No dashboard needed — setupNodeSdk: true's dev exporter printed this
directly. Point it at a real backend later; see "Two modes" below.
Metrics print too (v0.15.0+) — a compact one-line-per-metric summary
on stderr, reusing the exact mcp.tool.* instruments documented in
"Metrics" below. A long-running server prints it every 5 seconds (not the
default 60-second export interval). The one-shot piped run above prints it
once as it exits, because the server flushes when stdin closes (v0.16.0,
see "Flushing on exit"):
[opentel-mcp metrics] mcp.tool.calls{gen_ai.tool.name=echo,mcp.method.name=tools/call} = 1
[opentel-mcp metrics] mcp.tool.duration{gen_ai.tool.name=echo,mcp.tool.outcome=success} count=1 avg=0.4msNo extra configuration, no @opentelemetry/sdk-metrics setup of your
own — setupNodeSdk: true now does for metrics what it's always done for
traces. See "Dev-mode metrics" below for exactly what this does and
doesn't change.
The rest of this README goes deeper: both server APIs, every attribute and metric emitted, how failure grouping works, configuration, and the non-obvious design decisions behind each.
Both server APIs
MCP servers are built on one of two classes from @modelcontextprotocol/sdk;
opentel-mcp detects and wraps either one the same way (see ADR 001 in
docs/adr/ for how).
McpServer — the high-level API most servers are actually built on.
Use it unless you have a specific reason not to; this is what the
quickstart above uses.
Server — the low-level API, for when you're handling raw JSON-RPC
yourself or building a library on top of MCP:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { instrumentMcpServer } from 'opentel-mcp';
const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
// Must run before setRequestHandler(CallToolRequestSchema, ...) below.
instrumentMcpServer(server, { serviceName: 'my-mcp-server', setupNodeSdk: true });
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { text } = request.params.arguments ?? {};
return { content: [{ type: 'text', text: `you said: ${text}` }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);Runnable versions of both live in examples/hello-server/ and
examples/hello-mcpserver/.
MCP v2 (@modelcontextprotocol/server) support (v0.10.0+)
Both the original SDK and the new one work with instrumentMcpServer() —
they're two separate, OPTIONAL peer dependencies (install whichever one(s)
you actually use):
@modelcontextprotocol/sdk("v1" throughout this README) — protocol revisions through 2025-11-25. TheServer/McpServerAPIs above.@modelcontextprotocol/server("v2") — protocol revision 2026-07-28, whose headline change is removing theinitializehandshake and theMcp-Session-IdStreamable HTTP header in favor of a stateless, self-contained-request model. SameServer/McpServershapes, sameinstrumentMcpServer()call — detection and wrapping happen automatically, resolved once perinstrumentMcpServer()call by which SDK the object you passed in actually came from (ADR 015,docs/adr/015-mcp-v2-support.md).
import { McpServer } from '@modelcontextprotocol/server';
import { instrumentMcpServer } from 'opentel-mcp';
import { z } from 'zod';
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
instrumentMcpServer(server, { serviceName: 'my-mcp-server' });
server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }],
}));The important difference isn't the API — it's the deployment shape.
v2's own createMcpHandler/serveStdio entry points construct a fresh
Server/McpServer instance per request (via a factory function you
provide), not once at process start — including for createMcpHandler's
default stateless HTTP posture, not just an edge case. That means
instrumentMcpServer() has to run inside the factory, on every
invocation, not once at module load the way the v1 examples above do:
import { createMcpHandler } from '@modelcontextprotocol/server';
import { McpServer } from '@modelcontextprotocol/server';
import { instrumentMcpServer } from 'opentel-mcp';
const handler = createMcpHandler((ctx) => {
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
// Runs on every request this factory serves. instanceKey is what makes
// that not mean "trackers reset every time" — see below.
instrumentMcpServer(server, { serviceName: 'my-mcp-server', instanceKey: 'my-mcp-server' });
server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }],
}));
return server;
});instanceKey (see the dedicated section below) is the mechanism for
this — it's not a new, v2-specific option; it's the same one ADR 012
built for a v1 "stateless Streamable HTTP" deployment shape that turned
out to be exactly what v2 makes the default, SDK-recommended pattern
instead of something a host happened to build. Without it, the same four
in-memory trackers ("In-memory tracker state is scoped to one
instrumentMcpServer() call" below) reset to empty on every request under
this pattern, same as they always have for any fresh-instance-per-request
deployment — v2 doesn't change that mechanism, it just makes hitting it
the default instead of an edge case.
What works today: spans, standard attributes (mcp.method.name,
gen_ai.tool.name, jsonrpc.request.id — read from v2's ctx.mcpReq.id),
deep failure fingerprinting, mcp.failure.channel/mcp.failure.validation_paths
classification, and — as of this release — Agent Thrash Detection's
fallback-session path and transport auto-detection all work the same as
v1 (ADR 015 Phases 1–3, plus a follow-up round documented in ADR 015's
Update section):
isSingleConnectionTransport()requires positive confirmation (transport.constructor.name === 'StdioServerTransport') for v2 specifically, instead of inferring single-connection from an absentsessionIdproperty — closing a confirmed false positive against the transportcreateMcpHandlerbuilds internally (PerRequestHTTPServerTransport), which also has nosessionId, for the opposite reason stdio doesn't. v1 is completely unaffected by this change. Seedocs/known-gaps.mdentry 8 for the full history.- The generated fallback session id is now shared across repeated
instrumentMcpServer()calls wheninstanceKeyis set (the same registry the four ADR-012 trackers already use), fixing the gap "instanceKey alone does not fix thrash detection" below originally described for v1's stateless deployment shape, for the v2 factory pattern specifically. Seedocs/known-gaps.mdentry 6.
One narrower thing still open, tracked in docs/known-gaps.md entry
6's own update: thrashSessionState (the internal flag tracking "has
this server ever proven itself session-aware") is not registry-backed the
way the fallback id now is, so that specific memory still resets on every
v2 per-request call. This only matters for a deployment that mixes
real-session-id calls with occasional no-session-id ones under a shared
instanceKey — thrash detection using a real session id on every
call (ctx.sessionId) is unaffected either way.
Runnable end-to-end coverage lives in test/instrument.v2.test.js and
test/integration/thrash-v2-transport-detection.test.js, not a dedicated
examples/ directory yet.
What gets emitted
Tool-level failures, specifically
An MCP tool can fail two ways: it can throw, or it can return
isError: true on an otherwise-successful response (the case from "The
problem" above) — exactly like the quickstart's echo tool, but with
isError: true added and no throw involved:
server.registerTool('fetch_weather', { inputSchema: { city: z.string() } }, async ({ city }) => ({
isError: true,
content: [{ type: 'text', text: `couldn't reach the weather service for ${city}` }],
}));opentel-mcp treats both failure shapes the same way — span marked
ERROR, nothing thrown, the result returned to the caller unchanged:
tools/call fetch_weather ................. 605ms ERROR
error.type = tool_errorVerified in test/instrument.test.js's "tool-level failure" tests.
Errors your agent can't act on (v0.16.0)
An isError: true result with no content, or just "Error", tells the
model nothing about what went wrong or what to do next — a common cause
of retries and thrash. Every isError: true span also carries:
| Attribute | Values |
| --- | --- |
| mcp.failure.unactionable | true when there's no content, or fewer than 10 characters of text (whitespace-trimmed) and no image/audio/resource item |
| mcp.failure.content_length_bucket | empty (0) · tiny (1–9) · short (< 80) · medium (< 500) · long |
Both are computed only from lengths and item counts — never from the
text itself (proved by a canary-string test in
test/unactionable.test.js), on spans only, never as metric labels. They
don't change fingerprints or categories. Configure with
unactionableErrors: { enabled, minTextLength } (defaults true, 10).
Design: ADR 025.
Resources and prompts (opt-in, v0.16.0)
Tools are traced by default. resources/* and prompts/* requests can be
traced too, if you turn them on:
instrumentMcpServer(server, {
coverage: { resources: true, prompts: true }, // either or both
});| Method | Span name | Attributes |
| --- | --- | --- |
| resources/read, resources/list, resources/templates/list | the method, e.g. resources/read | mcp.method.name |
| prompts/get | prompts/get <prompt name> | mcp.method.name, gen_ai.prompt.name |
| prompts/list | prompts/list | mcp.method.name |
- Never captured: resource URIs, prompt arguments, result content. A
failing request gets status
ERROR,error.type, and the usual fingerprint/category/channel (mcp.failure.*), but not the exception message, because SDK resource errors embed the URI in it. gen_ai.tool.nameis absent (not empty) on these spans, so they never show up in per-tool views or tool metrics.- Durations go to a separate histogram,
mcp.server.operation.duration, labeled only bymcp.method.name(one of the five methods above) anderror.type. No thrash detection or cost tracking applies. - Ordering: instrument before registering resources/prompts, same as
tools. If a handler for one of these methods already exists, that method
is skipped with a single
diag.warn. It never throws, so turning this on can't break an existing server. On an SDK v2McpServer, declaringcapabilities: { resources: {}, prompts: {} }in the constructor registers those handlers immediately (and so skips them); leave them out, since registering a resource or prompt declares the capability for you.
Design: ADR 026.
Span attributes
Every span follows the OpenTelemetry MCP semantic conventions (see
"Semantic conventions" below), name {mcp.method.name} {tool name}
(e.g. tools/call echo), kind SERVER, status ERROR whenever
error.type is set.
| Attribute | Requirement Level | Description | Example |
|---|---|---|---|
| mcp.method.name | Required | JSON-RPC method name | "tools/call" |
| gen_ai.tool.name | Conditionally Required | Tool name from request | "echo" |
| gen_ai.operation.name | Recommended | GenAI operation type | "execute_tool" |
| jsonrpc.request.id | Conditionally Required | JSON-RPC request id (string) | "abc-123" |
| error.type | Conditionally Required (on failure) | Error class name, or "tool_error" when the tool call itself returned isError: true | "TypeError" |
| mcp.tool.argument_count | Custom — not spec | Number of arguments (values not captured) | 2 |
Span status description carries the error message on failure (thrown
errors); there's no separate error-message attribute — the spec expresses
success/failure through span status, not an attribute. Source of truth:
src/attributes.js.
Metrics
The four base mcp.tool.* call/duration metrics below, via
@opentelemetry/api's Metrics API — same API-only pattern as tracing
(see "Two modes" below): nothing is recorded until a MeterProvider is
registered — except under setupNodeSdk: true, which registers one for
you (v0.15.0+); see "Dev-mode metrics" below. Set enableMetrics: false
to opt out even when one is (dev-mode included); tracing is unaffected
either way. Source of truth: src/metrics.js. Thirteen mcp.tool.*
metrics exist across the library in total — this section's four, plus two
cost/token metrics ("Cost & Token Attribution" below), five Agent Thrash
Detection metrics ("Agent Thrash Detection" below), one schema-drift metric
("Tool schema drift detection" below), and mcp.tool.stale_responses
("Staleness detection" below).
| Metric | Type | Unit | Attributes | Emitted when |
|---|---|---|---|---|
| mcp.tool.calls | Counter | — | gen_ai.tool.name, mcp.method.name | Every tool call |
| mcp.tool.errors | Counter | — | gen_ai.tool.name, error.type[^1] | Handler threw or rejected |
| mcp.tool.silent_failures | Counter | — | gen_ai.tool.name[^1] | Result had isError: true |
| mcp.tool.duration | Histogram | ms | gen_ai.tool.name, mcp.tool.outcome[^1] | Every call, completion |
[^1]: Also carries mcp.failure.category when fingerprinting finds one — see "Failure Fingerprinting" below.
mcp.tool.silent_failures increments from the exact same check that marks
the span ERROR (isToolResultError() in src/instrument.js) — the
detection logic isn't duplicated between traces and metrics.
Wiring a real MeterProvider/TracerProvider — a worked example against
SigNoz's local OTLP endpoint:
import { metrics, trace } from '@opentelemetry/api';
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { instrumentMcpServer } from 'opentel-mcp';
const resource = resourceFromAttributes({ 'service.name': 'my-mcp-server' });
const meterProvider = new MeterProvider({
resource,
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: 'http://localhost:4318/v1/metrics' }),
}),
],
});
metrics.setGlobalMeterProvider(meterProvider);
const tracerProvider = new NodeTracerProvider({
resource,
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }))],
});
tracerProvider.register();
const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
// setupNodeSdk: false (default) — both providers above are already
// registered globally, so instrumentMcpServer() picks them up as-is.
instrumentMcpServer(server, {});http://localhost:4318 is SigNoz's default local OTLP/HTTP (OpenTelemetry
Protocol — the wire format traces/metrics travel over) endpoint; point it
at your own collector in production. @opentelemetry/exporter-metrics-otlp-http
is a host-app dependency — opentel-mcp doesn't bundle it. @opentelemetry/sdk-metrics,
@opentelemetry/sdk-trace-node, and @opentelemetry/exporter-trace-otlp-http
are already runtime dependencies of opentel-mcp itself (@opentelemetry/sdk-metrics
as of v0.15.0, for the dev-mode metrics path below; the other two for
setupNodeSdk: true's dev tracing path, same as before), so no extra
install is needed for those three — only the OTLP metrics exporter above
needs installing yourself.
Dev-mode metrics (v0.15.0+)
setupNodeSdk: true now also stands up an owned MeterProvider — the
same dev-friendly, zero-config posture tracing has always had under this
flag, extended to metrics (ADR 023, docs/adr/023-dev-mode-metrics.md).
This is a dev-mode-only exception to the API-only framing above, the same
way StderrSpanExporter (ADR 003) is a dev-mode-only exception for
tracing — setupNodeSdk: false (the default, and the production posture)
is completely unaffected; metrics stay exactly as API-only as they are
today.
What it does, under setupNodeSdk: true and enableMetrics (default
true, same flag that already opts out of metrics generally):
- Builds a
MeterProviderwith aPeriodicExportingMetricReaderexporting to stderr every 5 seconds (not the SDK's own 60-second default — a quickstart user making a few tool calls should see output in a few seconds, not up to a minute later), via a small custom exporter (src/exporters/stderr-metrics.js) — same "stderr, never stdout" reasoning asStderrSpanExporter(ADR 003): aStdioServerTransportserver's JSON-RPC stream lives on stdout, so diagnostic output of any kind must go to stderr instead. - Prints one line per instrument × attribute-set, not a JSON dump:
counters as
name{attrs} = <value>, histograms asname{attrs} count=<n> avg=<sum/count><unit>— see the quickstart's "See it working" section above for a real example. - Shares the exact same
Resource(service.name, and the pricing-staleness attribute when applicable) the devTracerProvideralready builds, so the two signals can never carry differentservice.namevalues in dev mode. - If a
MeterProvideris already registered globally — your own, or anything else's — this is a complete no-op: nothing is overridden, no exception is thrown, and a singlediag.warn(once per process) says so.setupMeter()immediately afterward continues to use whatever was already registered, exactly as it does today. - Extends
server.shutdown()(already attached undersetupNodeSdk: true) to also flush and shut down theMeterProviderit registered, alongside the existingTracerProvidershutdown. As of v0.16.0 this runs automatically when the process stops — see "Flushing on exit" below.
Flushing on exit (flushOnExit, v0.16.0)
Under setupNodeSdk: true, buffered spans and dev-mode metrics are
flushed automatically when the process stops. It's on by default; pass
flushOnExit: false to turn it off. MCP clients built on the SDK's stdio
transport stop a server by closing stdin, then sending SIGTERM 2 seconds
later, then SIGKILL; this covers the first two:
| Event | What happens |
| --- | --- |
| stdin closes and nothing else keeps the process alive | flushed on beforeExit; the process exits with its own exit code |
| SIGTERM / SIGINT | flushed, then the same signal is re-raised so the process still dies by it, unless your code has its own listener for that signal, in which case yours decides |
| process.exit() | not covered: call await server.shutdown() before exiting |
| SIGKILL | can't be intercepted |
The flush waits at most 1000 ms (flushOnExit: { timeoutMs } can only
lower that), so a stuck exporter can't keep a stopping server alive.
server.shutdown() is safe to call yourself as well: it runs once, however
many times it's called. With setupNodeSdk: false this option does
nothing — your application owns its providers and its shutdown. Design:
ADR 024.
@opentelemetry/sdk-metrics is a real (non-dev) dependency of opentel-mcp
as of this feature — not an optional peer, not a dynamic import with a
fallback path. See ADR 023's "Dependency strategy" section for the full
reasoning: its own transitive dependencies were already in this package's
tree via @opentelemetry/resources/@opentelemetry/sdk-trace-node, so
this adds no new package beyond itself.
Using the Prometheus exporter instead of OTLP — service.name needs an extra option
If you scrape metrics with @opentelemetry/exporter-prometheus (pull-based)
rather than exporting over OTLP (push-based, the example above), be aware
that the Prometheus exporter does not attach resource attributes —
including service.name — to every metric point by default. It only
exposes them on a separate target_info series, which most PromQL you'd
actually write (rate(mcp_tool_calls_total[5m]), grouped sum by
(gen_ai_tool_name), etc.) never joins against. In a single-service setup
this is invisible; the moment you're scraping more than one instrumented
server into the same Prometheus and need to tell their metrics apart —
which is exactly what a service.name filter/template variable is for —
every series looks identical without it.
Fix: pass withResourceConstantLabels, a regex matching the resource
attribute(s) you want flattened onto every point:
import { metrics } from '@opentelemetry/api';
import { MeterProvider } from '@opentelemetry/sdk-metrics';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { resourceFromAttributes } from '@opentelemetry/resources';
const meterProvider = new MeterProvider({
resource: resourceFromAttributes({ 'service.name': 'my-mcp-server' }),
readers: [
new PrometheusExporter({
port: 9464,
withResourceConstantLabels: /^service\.name$/,
}),
],
});
metrics.setGlobalMeterProvider(meterProvider);With this set, mcp_tool_calls_total{...} (and every other mcp.tool.*
series) carries a service_name label directly, so
mcp_tool_calls_total{service_name="my-mcp-server"} and a Grafana
service.name template variable both work without a target_info join.
See dashboards/grafana-mcp-health.json and dashboards/dev/ in the repo
for a full worked example (dashboard JSON + a script that exercises this
exact setup end to end).
Staleness detection (v0.17.0)
A read tool can return data older than it should be: a quote from four minutes ago, yesterday's bars after today's session opened. The call succeeds, the span is green, and the agent acts on stale data with full confidence. opentel-mcp can flag that, if the tool says how fresh its data is. The tool is the source of truth, because only it knows its own data cadence. Config is only an override. (Suggested by the r/mcp community.)
It's opt-in, per tool. A tool adds one key to its result's _meta:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { instrumentMcpServer, FRESHNESS_META_KEY } from 'opentel-mcp';
import { z } from 'zod';
const server = new McpServer({ name: 'market-data', version: '1.0.0' });
// Before any registerTool() call.
instrumentMcpServer(server, {
serviceName: 'market-data',
setupNodeSdk: true, // dev mode: prints spans to stderr
});
// Stand-in for your real feed. This quote is 4 minutes old.
async function fetchQuote(symbol) {
return { symbol, price: 101.5, timestamp: Date.now() - 4 * 60_000 };
}
server.registerTool(
'get_quote',
{ description: 'Latest quote for a symbol', inputSchema: { symbol: z.string() } },
async ({ symbol }) => {
const quote = await fetchQuote(symbol);
return {
content: [{ type: 'text', text: JSON.stringify(quote) }],
_meta: {
[FRESHNESS_META_KEY]: {
asOf: quote.timestamp, // when the data is from: epoch ms, or ISO 8601 with a timezone
maxAgeMs: 5_000, // a quote is fresh for 5 seconds
},
},
};
},
);
await server.connect(new StdioServerTransport());Save it as server.js and send it one call:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_quote","arguments":{"symbol":"ACME"}}}' | node server.jsThe span on stderr (from a real run) is still OK, and now says why the
answer shouldn't be trusted:
name: 'tools/call get_quote'
attributes: {
...
'mcp.tool.freshness.verdict': 'stale',
'mcp.tool.freshness.data_age_ms': 240001,
'mcp.tool.freshness.max_age_ms': 5000,
'mcp.tool.freshness.limit_source': 'max_age_ms'
},
status: { code: 1 } // OK: stale data isn't an error
[opentel-mcp metrics] mcp.tool.stale_responses{gen_ai.tool.name=get_quote} = 1The _meta contract
FRESHNESS_META_KEY is 'io.github.thirumalaiboobathi.opentel-mcp/freshness'
(a reverse-DNS key, as the MCP spec recommends for custom _meta keys).
Its value:
| Field | Type | Meaning |
| --- | --- | --- |
| asOf | epoch milliseconds, or ISO 8601 string with Z/±hh:mm | When the data is from (the quote's timestamp, the bar's close), not when the tool ran. Required. |
| maxAgeMs | number ≥ 0 | How long the data stays fresh. |
| freshUntil | same as asOf | An absolute deadline, for windows that aren't a fixed duration. |
Give asOf plus maxAgeMs, freshUntil, or both. With both, the
earlier deadline wins. For data that's good until the trading session
closes:
_meta: {
[FRESHNESS_META_KEY]: {
asOf: bars.closeTime, // '2026-10-12T20:00:00Z'
freshUntil: nextSessionClose, // '2026-10-13T20:00:00Z'
},
},TypeScript users get autocomplete from the exported FreshnessMeta type:
{ asOf, maxAgeMs } satisfies FreshnessMeta.
Overriding a tool's window from config
instrumentMcpServer(server, {
staleness: {
tools: {
get_quote: { maxAgeMs: 2_000 }, // replaces get_quote's own maxAgeMs/freshUntil
},
},
});The override replaces the tool's window entirely (limit_source becomes
config). It can't supply asOf: a response without the freshness key
gets no verdict, whatever the config says. An invalid value (negative,
non-finite) is ignored with one diag.warn. staleness: { enabled: false }
turns the whole feature off.
What gets emitted
| Attribute | Type | When |
| --- | --- | --- |
| mcp.tool.freshness.verdict | fresh | stale | unknown | The tool sent the freshness key |
| mcp.tool.freshness.data_age_ms | integer ms | asOf was readable |
| mcp.tool.freshness.max_age_ms | integer ms | A verdict exists: the effective window (deadline minus asOf) |
| mcp.tool.freshness.limit_source | max_age_ms | fresh_until | config | A verdict exists |
| mcp.tool.freshness.unknown_reason | invalid_as_of | future_as_of | missing_window | invalid_window | Verdict is unknown |
Plus one counter, mcp.tool.stale_responses (label: gen_ai.tool.name),
+1 per stale response.
- Tools that don't report freshness get nothing. No attribute, no
verdict; never
fresh, neverstale. Their spans are unchanged. - Stale isn't an error. Span status stays
OK, there's noerror.type, and it's never counted as a silent failure. Alert on the verdict or the counter, not on error rate. isError: trueresponses are skipped. They're already failures.- Malformed freshness never breaks a call. It gets
unknownwith a reason, at most onediag.warnper tool, and the result goes back to the client untouched. Common mistakes it catches: epoch seconds instead of milliseconds, an ISO string with no timezone,asOfmore than 5 s in the future (up to 5 s is treated as clock skew), andfreshUntilbeforeasOf. - Privacy: only
asOf,maxAgeMsandfreshUntilunder that one key are read. Result content and other_metafields are never read for this, and raw values are never emitted, only the derived numbers (checked by a canary test intest/staleness.test.js). - Data age is span-only, never a metric label.
Seeing it in the dashboard
opentel-mcp-ui 0.3.0+ shows the data age in its feed, a Stale data
filter, and stale rate as a fifth health-grade signal. Start it, then point
the server above at it with one more option:
npx opentel-mcp-ui # dashboard at http://127.0.0.1:4319instrumentMcpServer(server, {
serviceName: 'market-data',
setupNodeSdk: true,
exporterUrl: 'http://127.0.0.1:4319/v1/traces', // send spans to the dashboard too
});Each run of the echo … | node server.js command above now shows up in the
dashboard's feed and under Stale data. The tool gets a grade once it has
10 calls, and the stale-data signal joins it once 10 of those carried a
fresh or stale verdict, so send the command ten times. With every call
stale, get_quote grades F, "set by stale data 100% (F)".
Design and every edge case: ADR 027.
Failure Fingerprinting (v0.4.0+)
Groups logically identical failures under one stable identifier, even when the error message contains UUIDs, timestamps, or user IDs. Ten calls that fail the same way but each mention a different user ID show up as one issue, not ten.
Runs locally and synchronously over the error object already in hand —
no network call, no third-party service. Every thrown error and every
isError: true result gets one, automatically; disable with
{ fingerprinting: false } (default: enabled). Full algorithm: ADR 006
in docs/adr/.
| Attribute | Description | Example |
|---|---|---|
| mcp.failure.fingerprint | Stable 16-hex-char identity for the failure | "a3f4c8e2b1d09f77" |
| mcp.failure.signature | Human-readable errorClass@fn:line, ≤60 chars | "TypeError@doThing:42" |
| mcp.failure.category | One of 8 categories (below) | "timeout" |
| mcp.failure.origin | tool_error | thrown | transport | "thrown" |
| mcp.failure.error_class | Error class / constructor name | "TypeError" |
| mcp.failure.channel | execution | protocol.not_found | protocol.input | protocol.output | protocol.other | unknown — see "Agent Thrash Detection" below | "protocol.input" |
| mcp.failure.validation_paths | Which schema field(s) a Zod validation failure named, one dot-joined path per failing issue — omitted entirely when nothing parseable was found (ADR 009) | ["email", "user.profile.age"] |
Source of truth: src/fingerprint/attributes.js. Every category:
validation— bad input (Zod/Joi/Yup errors, "invalid"/"required" wording)timeout— an operation timed out (TimeoutError,ETIMEDOUT, ...)network— a connection failed (ECONNREFUSED,FetchError, ...)auth— 401/403, "unauthorized"/"forbidden" wordingdependency— a downstream service or package failed (Mongo, Postgres, ...)serialization— malformed JSON, "unexpected token" wordinginternal— nothing more specific matched (the catch-all)unknown— fingerprinting itself hit an internal error (should not normally happen)
Full classifier source: src/fingerprint/classify/.
Cardinality: the fingerprint itself is unbounded — a new bug means a
new fingerprint, forever. That's fine on span attributes (each span is
its own record), but it must never go on a metric label, or every
distinct failure becomes its own permanent time series. opentel-mcp
enforces this structurally, not by convention: src/metrics.js can only
reach a fingerprint-derived value through
METRIC_SAFE_ATTRIBUTES — a frozen list containing only category and
origin (24 combinations max). There is no code path today that could
accidentally attach fingerprint, signature, or error_class to a
counter or histogram label. See src/fingerprint/attributes.js and ADR
006's "Consequences" section. mcp.failure.validation_paths above is
held to the exact same rule and for the exact same reason — field/path
names are bounded per tool but unbounded across every tool anyone
registers, so it is permanently excluded from METRIC_SAFE_ATTRIBUTES,
span-only, no exceptions (ADR 009).
Field-level discrimination is a property of the fingerprint, not a
separate detector. Two validation failures on the same schema field
normalize to the same message and hash to the same fingerprint, even
across different invalid values tried; a failure on a different field
normalizes differently and hashes to a different fingerprint. This
already falls out of hashing the full Zod issues JSON (which embeds each
failing field's path) — it is not a dedicated field-convergence detector,
and mcp.failure.validation_paths doesn't change this behavior, it just
makes it queryable instead of implicit in an opaque hash. See ADR 009
(docs/adr/009-field-level-convergence.md) for the full investigation,
including the one gap this doesn't cover: fixing one of several failing
fields changes the issues array's shape, which changes the fingerprint
even though another field is still failing underneath — tracked in
docs/known-gaps.md, not solved here.
Extending it: computeFingerprint(err, ctx, opts)
(src/fingerprint/compose.js) accepts opts.classifiers to prepend your
own detection rules ahead of the built-in eight, and opts.stackFrames to
change how many stack frames feed the signature — see
test/fingerprint/compose.test.js's "uses a custom classifiers list" and
"respects a custom opts.stackFrames count" tests, and
examples/fingerprint-demo.js for a runnable, standalone demo (node
examples/fingerprint-demo.js). Not yet wired through
instrumentMcpServer()'s own options — today this means importing
computeFingerprint directly rather than configuring the automatic
per-call-site wrapping; tracked in the roadmap below.
Error recording (v0.13.0+)
What this controls, and who needs it. Whenever a tools/call/
tools/list handler throws, this library records the thrown error's
message and stack trace onto the span it creates for that call — by
default, as the tool wrote them, except that recognizable credentials
(Bearer/Basic tokens, AWS key ids, sk-/ghp_/xox… API keys, JWTs,
passwords in URLs, token=/api_key=/password= values) are replaced
with <SECRET> and the message and stack are capped at 4096 and 16384
characters (v0.17.0). If your tools call
third-party services, wrap unaudited dependencies, or you otherwise don't
control every string a tool might throw, that's a real channel for
whatever those errors happen to contain (an email address, an internal
hostname, a connection string a driver embedded in its own error message)
to reach wherever your traces get exported. errorRecording.mode lets
you scrub or drop that content instead of recording it verbatim — read
both caveats below before treating it as a privacy control, though: it
has two distinct limits, and neither is obvious from the option name
alone.
Mechanically: every thrown error goes through span.recordException(err)
(an OpenTelemetry SDK method, not one of this library's own attributes)
plus span.setStatus({ code: ERROR, message: err.message }) —
unconditionally, whether or not fingerprinting is enabled.
errorRecording.mode controls what those two calls actually put on the
span:
| Mode | exception.message / status message | exception.stacktrace | When to use |
|---|---|---|---|
| 'full' (default) | Raw err.message, credentials → <SECRET>, capped at 4096 chars (v0.17.0) | Raw err.stack, same scrubbing, capped at 16384 chars | Everything else kept: ids, paths, URLs, line numbers. Before v0.17.0 both were unmodified |
| 'normalized' | normalizeMessage(err.message) — the exact scrubbing pipeline (src/fingerprint/normalize/message.js) fingerprinting already runs before hashing: UUIDs, emails, URLs, IPs, timestamps, filesystem paths, hex runs, quoted ids | Reconstructed from parseAndNormalizeStack() (src/fingerprint/normalize/stack.js) — keeps every function name/file/line, strips only the local cwd prefix (and collapses node_modules package versions) | Tool results come from third-party or unaudited MCP servers and you want the same scrubbing fingerprinting already trusts, applied to the raw exception content too |
| 'none' | Not set — span.setStatus({ code: ERROR }) with no message, the same pattern already used for tool-level isError: true failures | Not set | You rely entirely on mcp.failure.* (category/fingerprint/signature — already hashed/normalized) and don't want any free-text exception content on the span at all |
Caveat 1 — 'normalized' is targeted scrubbing, not general-purpose
redaction. Do not treat it as a PII filter. normalizeMessage()
matches specific, structured shapes only: UUIDs, email addresses, URLs,
IPv4/IPv6 addresses, ISO-8601/Unix timestamps, filesystem paths, long hex
runs, and quoted alphanumeric ids (8–64 chars, mixed letters/digits). It
does not recognize sensitive content in general — an API key in a format
none of those patterns match (a bare, unquoted token with no digit in it,
or a custom prefix scheme), or a customer's name embedded in ordinary
prose ("could not process request for Jane Smith"), passes through
'normalized' mode completely unchanged, identical to what 'full'
would put on the span. Treat 'normalized' as "the same scrubbing
fingerprinting already trusts for hashing," not as a guarantee that
whatever a tool's error messages contain is safe to record.
Example — the same thrown error, 'full' vs. 'normalized':
throw new Error('upstream lookup failed for user [email protected]');| Mode | exception.message on the span |
|---|---|
| 'full' | upstream lookup failed for user [email protected] |
| 'normalized' | upstream lookup failed for user <EMAIL> |
| 'none' | (not set — only the ERROR status code is) |
The stack trace scrubs the same way: 'full' keeps every frame's
absolute path; 'normalized' keeps every frame (function name, file,
line) but strips the local cwd prefix.
The redactor hook (v0.14.0+)
Caveat 1 above is the reason this exists: normalizeMessage() matches
eight specific, structured shapes (UUIDs, emails, URLs, IPv4/IPv6,
timestamps, paths, hex runs, quoted ids), and that is where its
competence ends. An API key in your own proprietary format
(ACME_KEY_[a-f0-9]{8}), an internal account id shape
(ACCT-\d{9}), or a customer's name embedded in ordinary prose are not
structurally distinguishable from ordinary text by any pattern this
library could ship — no ninth built-in pattern fixes that. Only the host
running the tool knows what their own data looks like.
errorRecording.redactor is a synchronous function you supply that runs
as part of mode: 'normalized', so you can close that gap without
forking this library or falling back to 'none''s blunt "drop
everything." See ADR 020 (docs/adr/020-redactor-hook.md) for the full
design rationale; the operator-facing behavior is summarized below.
Worked example:
instrumentMcpServer(server, {
errorRecording: {
mode: 'normalized',
redactor: ({ message, stack }) => ({
message: message
.replace(/ACME_KEY_[a-f0-9]{8}/g, '[API_KEY]')
.replace(/ACCT-\d{9}/g, '[ACCOUNT_ID]'),
stack, // unchanged — this redactor only needs to touch the message
}),
},
});throw new Error(
'auth failed for ACME_KEY_7f3a9c2e, ' +
'account ACCT-123456789, contact [email protected]',
);| Step | exception.message |
|---|---|
| Raw, as thrown | auth failed for ACME_KEY_7f3a9c2e, account ACCT-123456789, contact [email protected] |
| After your redactor (runs first, on the raw text) | auth failed for [API_KEY], account [ACCOUNT_ID], contact [email protected] |
| After this library's own patterns (run second, on YOUR output) | auth failed for [API_KEY], account [ACCOUNT_ID], contact <EMAIL> |
Ordering matters, and it's fixed, not configurable: your redactor
always runs first, on the raw, uncoerced message/stack — before
normalizeMessage()/parseAndNormalizeStack() ever see them, never the
reverse. Two consequences follow directly: (1) this library's own
scrubbing is still the last thing that touches the content before it
reaches the span — a defense-in-depth backstop if your redactor misses a
shape its own patterns don't cover (see the API key example above:
notice <EMAIL> still gets scrubbed even though your redactor never
mentioned email addresses), and (2) your redactor sees the full,
untruncated original message — normalizeMessage()'s 2048-character
truncation applies afterward, to your output, not before you ever see the
content.
Only consulted under mode: 'normalized'. Configure a redactor
alongside 'full' or 'none' and it's accepted but never called — those
two modes' entire meaning is "byte-identical to their own fixed
behavior, regardless of what else is configured." Since forgetting to
also flip mode away from its 'full' default is an easy way to end up
with a redactor that's silently never invoked, instrumentMcpServer()
logs a one-time diag.warn() at setup naming exactly that.
Failure never falls back to raw content. If your redactor throws,
returns a non-string message, or returns a stack that's neither a
string nor undefined, that one event falls back to 'none'-equivalent
output — span.setStatus({ code: ERROR }) only, no exception event at
all — never to 'full'-equivalent (raw, unredacted) content. A redactor
you configured specifically to keep something off the span must never
silently fail open; the safe direction here is more redacted, not
less. This fires a one-time, content-free diag.warn() naming the
failure shape (threw / returned a non-string message / returned an
invalid stack) — never the message/stack content that triggered it,
so the warning itself can't become a second, undocumented leak channel.
Your redactor's returned message/stack are also length-capped
defensively before use (reusing normalizeMessage()'s own 2048-character
limit), regardless of what a well-behaved redactor is expected to
return.
Timing — read this before you write a regex. The redactor runs
synchronously, inline, on the error path — inside the still-open
span's own callback, before span.end() is called. There is no timeout,
and there cannot be one: JavaScript is single-threaded, and nothing in
this library (or in Node itself, short of a worker_threads boundary
this feature deliberately doesn't pay for) can interrupt a synchronous
function that's already running. This means a slow redactor doesn't
just delay telemetry — it delays the tool call's own response to
whoever's waiting on it. Catastrophic backtracking in a regex (the
classic (a+)+$-shaped footgun), an accidental synchronous file read, or
any blocking call inside your redactor adds directly to that tool call's
latency, on every single call that throws under 'normalized' mode,
for as long as the redactor stays slow. Test your redactor's regexes
against adversarial input the same way you would for any other
user-facing regex, not just against the examples you had in mind when
you wrote it.
The fingerprint is completely unaffected — on purpose, always.
mcp.failure.fingerprint/mcp.failure.signature and the values hashed
into them are computed from the real, unmodified thrown error, identically
whether or not a redactor is configured. A redactor has no way to reach
that computation at all — it only ever touches what lands on the span.
This is deliberate: the fingerprint is a SHA-256 hash, never a plaintext
channel, so redacting it would buy no privacy while tying fingerprint
stability to your own, unversioned redactor code. Practically, this means
any saved dashboard filter or alert keyed on mcp.failure.fingerprint
keeps working exactly as before the moment you add a redactor — nothing
about adding or changing one ever reshuffles a fingerprint value.
The redactor is still scoped to this library's own span, the same as
every other mode value — see Caveat 2 below for what that does and
doesn't cover; nothing about the redactor changes that boundary.
Caveat 2 — every mode is scoped to the span this library creates, not
to your trace as a whole. No mode mutates the original err — both
call sites rethrow it afterward, so 'normalized'/'none' build the
exception event independently rather than editing err.message/
err.stack in place. The consequence: the unmodified, fully raw err is
exactly what propagates to whatever called this handler. If any other
instrumentation further out in the call stack — an APM agent, HTTP or
framework auto-instrumentation, anything else wrapping this handler —
independently calls recordException(err)/setStatus({ message:
err.message }) on its OWN span when it observes the rethrow, the raw,
unscrubbed message and stack land in the SAME TRACE as this library's
(scrubbed) span, one span up, not on it. 'none' buys no more protection
here than 'normalized': neither mutates err, so an outer recorder
sees the identical raw content either way. Someone choosing 'none'
specifically to keep sensitive content out of their tracing backend needs
to know that protection stops at this library's own span, not at the
trace boundary.
Confirmed directly, not just reasoned about: an instrumented server run
under a real ambient context manager (AsyncLocalStorageContextManager —
the propagation mechanism production deployments actually use, unlike
this project's own test suite, which registers most test files with
contextManager: null and therefore can't exercise this path — see
docs/known-gaps.md entry 10's latest update for why that's fine for
those specific tests), wrapped in an outer span that calls
recordException(err) on catch, with errorRecording.mode: 'normalized'
set and an InMemorySpanExporter attached: the tool span's exception
event carries the scrubbed message, and the ancestor span's exception
event and status message both carry the original, raw one — same trace
ID, parent/child span relationship confirmed, no mutation anywhere in
between.
This is not fixable from inside this library. The only way to close
it here would be mutating err.message/err.stack in place before
rethrowing — which would corrupt whatever error-handling the host
application does with that same object afterward, a correctness hazard
this library isn't willing to trade for a telemetry one. It has authority
over one span; it has no visibility into, and no control over, what other
instrumentation attached to the same process does with the error once
rethrown. See docs/known-gaps.md entry 10 for the full writeup.
error.type/exception.type (err.name) is capped at 128 characters
unconditionally in every mode — the same cap mcp.failure.error_class
uses — since a length cap on a class-identifier field costs a
well-behaved tool nothing, unlike message/stack content.
Default stays 'full' through all of 0.x. Changing it would alter
what every trace backend renders for the single highest-traffic failure
path in this library, silently, for every existing deployment that
doesn't opt in — see ADR 019 Part 1 (docs/adr/019-raw-content-on-spans.md)
for the full argument, including why the default is expected to flip to
'normalized' at 1.0, not before.
Configuration
| Option | Env var | Type | Default | Description |
|---|---|---|---|---|
| mode | OTEL_MCP_ERROR_RECORDING_MODE | 'full' | 'normalized' | 'none' | 'full' | See table above. An unrecognized value falls back to 'full' silently, same as every other OTEL_MCP_* env var |
| redactor | (none — a function can't be expressed as an env var string) | (input: { message: string, stack: string \| undefined }) => { message: string, stack: string \| undefined } | undefined | See "The redactor hook" above. Only consulted when mode is 'normalized'; a non-function value is treated as absent, silently |
Cost & Token Attribution (v0.5.0)
MCP tools increasingly wrap LLM calls themselves — a tool that summarizes a document, drafts a reply, or classifies a ticket usually does it by calling out to a model, and that call has a real dollar cost. Standard MCP/OTel instrumentation has no opinion on any of this: a trace shows a tool ran in 800ms and succeeded, with nothing about which model it used, how many tokens it burned, or what that cost. That's the AI FinOps gap in MCP observability today — cost and usage data exists inside the tool call, but nothing carries it out to your traces. opentel-mcp closes it: when a tool result carries recognizable usage data, the same span your other instrumentation already reads also gets token counts, the detected model, and an estimated USD cost.
Zero-config quick-start
import { instrumentMcpServer } from 'opentel-mcp';
instrumentMcpServer(server, {
serviceName: 'my-mcp-server',
setupNodeSdk: true, // dev mode: prints traces to your terminal
});That's it — costTracking defaults to enabled. Any tool result whose
usage data matches one of defaultExtractor's recognized conventions
(Anthropic's usage.input_tokens/usage.output_tokens, OpenAI's
usage.prompt_tokens/usage.completion_tokens, Bedrock's
usage.inputTokens/usage.outputTokens, the MCP _meta.usage extension
point, or JSON-in-text inside content[0].text) automatically gets
mcp.tool.tokens.* / mcp.tool.model / mcp.tool.cost.* span
attributes, priced against DEFAULT_PRICING.
Advanced: overriding pricing, a custom extractor, and a budget guardrail
import { instrumentMcpServer } from 'opentel-mcp';
instrumentMcpServer(server, {
serviceName: 'my-mcp-server',
costTracking: {
// Merged per-model OVER DEFAULT_PRICING — correct a stale price or add
// a model DEFAULT_PRICING doesn't know about, without having to spread
// the whole default table yourself. Everything you don't name here is
// untouched. See "Overriding pricing" below.
pricing: {
'my-internal-model': { pricingKind: 'chat', inputPer1M: 1.0, outputPer1M: 2.0, currency: 'USD' },
},
// Recognize your own tool result shape. Return null for anything you
// don't recognize — never throw (see src/cost/extractor.js).
extractor: (toolResult) => {
if (!toolResult?.tokenStats) return null;
const { in: inputTokens, out: outputTokens, modelId } = toolResult.tokenStats;
return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, model: modelId };
},
// Observability guardrail, NOT enforcement — opentel-mcp never blocks
// or throws on a budget overrun, it just flags the span.
budget: {
perSessionUsd: 5, // flag once one MCP session's calls total > $5
perToolUsd: 1, // ...or once any single tool's calls total > $1
},
},
});Overriding pricing (v0.11.0+)
Two ways to change what costTracking prices against, composing as
{ ...(pricingTable ?? DEFAULT_PRICING), ...pricing } — see ADR 016
(docs/adr/016-pricing-override-and-staleness.md) for the full reasoning:
costTracking.pricing— a partial table, merged per-model overDEFAULT_PRICING(or overpricingTable, if you set both). Each key you supply replaces that one model's entire pricing entry; every model you don't name keeps its default price. This is the recommended way to correct a stale number or add a model — enterprises on committed-use discounts, AWS EDP, Bedrock provisioned throughput, or Azure OpenAI negotiated rates should use this to reflect what they actually pay, not list price.costTracking.pricingTable— fully replacesDEFAULT_PRICING. Use this when you want an effective table containing only your own models, none ofDEFAULT_PRICING's.
A model priced via either option reports pricing_status: 'user_override'
(see the span attributes table below) instead of 'known'.
Embedding models (v0.11.0+)
Embeddings are input-token-only — there's no "output" to price. Rather
than modeling that as outputPer1M: 0 (indistinguishable from a
data-entry bug), ModelPricing carries an explicit
pricingKind: 'chat' | 'embedding' discriminator; an 'embedding' entry
has no outputPer1M field at all, and calculateCost() never reads
outputTokens for one. DEFAULT_PRICING includes OpenAI
text-embedding-3-small/text-embedding-3-large/text-embedding-ada-002,
Cohere cohere-embed-v3, and Bedrock amazon-titan-embed-v2 out of the
box. A pre-v0.11.0 custom pricing entry with no pricingKind field is
still treated as 'chat' at runtime — only the TypeScript type is
stricter, not the runtime.
Span attributes
| Attribute | Standard OTel? | Description | Example |
|---|---|---|---|
| mcp.tool.tokens.input | Custom | Input tokens consumed | 1000 |
| mcp.tool.tokens.output | Custom | Output tokens produced | 500 |
| mcp.tool.tokens.total | Custom | input + output | 1500 |
| mcp.tool.model | Custom | Detected model name, gated by a length/shape check (v0.13.0) — see below | "claude-sonnet-5" |
| gen_ai.response.model | Standard (GenAI semconv)[^5] | Same value as mcp.tool.model, co-emitted for dashboard compatibility | "claude-sonnet-5" |
| mcp.tool.pricing_status | Custom[^7] | "known" | "unknown" | "user_override" — set whenever token usage was extracted, even with no model detected | "known" |
| mcp.tool.cost.usd | Custom | Estimated cost, from calculateCost() | 0.0105 |
| mcp.tool.cost.currency | Custom | Always "USD" today | "USD" |
| mcp.tool.cost.budget_exceeded | Custom | true once a configured costTracking.budget limit is crossed | true |
| mcp.tool.cost.budget_scope | Custom | Which budget scope tripped: "session" | "tool" (session wins if both did) | "session" |
[^5]: gen_ai.response.model is a real OTel GenAI semantic convention attribute ("the name of the model that generated the response") — but this span is an MCP tool-call span (gen_ai.operation.name: execute_tool), not a dedicated LLM request/response span, so co-emitting it here is a pragmatic dashboard-compatibility choice, not a spec-pure emission. It's set purely so off-the-shelf GenAI dashboards (Grafana, SigNoz, Honeycomb) that filter/group by gen_ai.response.model pick these spans up without any opentel-mcp-specific configuration. Full reasoning in src/attributes.js's ATTR_GEN_AI_RESPONSE_MODEL docblock.
[^7]: mcp.tool.pricing_status is provenance-based, not value-based: "user_override" means the model's key was present in your costTracking.pricing/pricingTable, whether or not the numbers you supplied happen to match DEFAULT_PRICING. "unknown" covers both "no model detected" and "model detected but not priceable" (unrecognized, or a malformed override entry) — see ADR 016 point 4.
The four token/model attributes are set together or not at all;
mcp.tool.pricing_status is set whenever usage was extracted at all
(unlike the model/cost attributes, it's present even with no model
detected); the two cost attributes only appear when a model was detected
and it resolves in the effective pricing table; the two budget
attributes only appear when a cost was calculated and a configured
limit was crossed. Source of truth: src/attributes.js and
src/instrument.js's applyCostAttribution().
Model identifier validation (v0.13.0)
A tool result's declared model field (result.model, result.usage.model,
result._meta.model, or the same read out of JSON in
result.content[0].text) is tool-result content, not library-computed
metadata like everything else applyCostAttribution() puts on a span — so
before it can reach mcp.tool.model, gen_ai.response.model,
calculateCost(), or a metric label
(mcp.tool.tokens.total/mcp.tool.cost.total), it has to pass a length
cap (256 characters) and character allowlist
(/^[A-Za-z0-9._:/@-]{1,256}$/). The allowlist is deliberately generous —
verified against every DEFAULT_PRICING key and normalizeModelName()'s
documented provider/model input contract (e.g.
"Anthropic/Claude-Opus-4-7"), plus headroom for conventions like a full
Bedrock ARN or a Vertex AI @version suffix — because silently rejecting a
legitimate model name is a worse failure than admitting a few characters
no known provider convention actually uses.
A rejected value is never a silent drop. mcp.tool.pricing_status is
set to "unknown" — the same status a legitimately unrecognized model
already produces (see the footnote above) — and a diag.warn() fires
once per instrumentMcpServer() call, reporting shape only (length, and
whether length or character class failed), never the rejected value
itself: echoing a malformed model id into log output would just move
this exact problem from spans into logs instead of closing it. Token
counts (mcp.tool.tokens.*) are unaffected either way — they're set
before this gate runs. costTracking.pricing/pricingTable override keys
(operator-authored config) are never subject to this check — only
tool-result content is. Full design: ADR 019 Part 2
(docs/adr/019-raw-content-on-spans.md).
Metrics
Two more mcp.tool.* metrics, via the same API-only pattern as the four
in "Metrics" above — nothing recorded until a MeterProvider is
registered, enableMetrics: false opts out of these too.
| Metric | Type | Unit | Attributes | Emitted when |
|---|---|---|---|---|
| mcp.tool.tokens.total | Counter | tokens | gen_ai.tool.name, mcp.tool.model[^6], mcp.tool.pricing_status | Usage detected in the tool result |
| mcp.tool.cost.total | Counter | USD | gen_ai.tool.name, mcp.tool.model[^6], mcp.tool.pricing_status | Cost calculated (model resolved in the effective pricing table) |
[^6]: mcp.tool.model is only added when a model was detected — the same optional-attribute cardinality pattern mcp.failure.category already uses on the other four metrics. mcp.tool.pricing_status is always added — a fixed, closed 3-value enum, well within this package's metric-label cardinality discipline (see COST_METRIC_SAFE_ATTRIBUTES in src/attributes.js).
Grouping mcp.tool.tokens.total by mcp.tool.pricing_status answers "what
fraction of tokens/spend is running through models we can't price" as a
direct query, instead of inferring it from missing mcp.tool.cost.* data.
Pricing accuracy and staleness
DEFAULT_PRICINGis a best-effort snapshot, not a maintained price list. Provider pricing changes frequently and varies by region/contract —opentel-mcpdoes not guarantee it stays current, and says so at runtime, not just here: onceDEFAULT_PRICING(checked viaDEFAULT_PRICING_LAST_VERIFIED, also exported) is more than 90 days past its last-verified date,instrumentMcpServer()fires a one-timediag.warn()naming that date — and, whensetupNodeSdk: true, also attaches anmcp.pricing.default_table_last_verifiedresource attribute, so long-running deployments can alert on it directly. Both only fire whenDEFAULT_PRICINGis actually contributing to your effective table (i.e. you haven't fully replaced it viapricingTable) — see ADR 016 point 3.
DEFAULT_PRICING (src/cost/pricing.js) covers 20+ models — chat and
embedding — across six providers: Anthropic, OpenAI, Google, Cohere, AWS
Bedrock, and DeepSeek. Bedrock entries (Nova and Titan embeddings)
assume us-east-1 list pricing — Bedrock pricing varies by region and
this table is not region-keyed (no reliable region signal exists in any
tool-result usage shape this package recognizes to key a lookup on — see
ADR 016 point 5); override via costTracking.pricing for a different
region.
Extending it
defaultExtractor(also exported) recognizes the five conventions listed under "Zero-config quick-start" above; pass your owncostTracking.extractor(aUsageExtractor:(toolResult) => TokenUsage | null, never throwing) to recognize anything else.calculateCost(inputTokens, outputTokens, model, pricingTable)is also exported directly, for recomputing cost outside the instrumentation hot path (e.g. over historical spans). Malformed pricing entries (missing/negative/non-numericinputPer1M/outputPer1M) degrade tonull, same as an unknown model — never throws.isDefaultPricingStale(now?, thresholdDays?)(also exported) is the pure function backing the staleness warning above, if you want to check it yourself (e.g. in a startup health check).- Disable everything in this section with
costTracking: { enabled: false }; tracing, metrics, and fingerprinting are all unaffected. - B
