npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

opentel-mcp

v0.17.0

Published

One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers

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.

CI npm version node license

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 --demo

Opens 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/api

opentel-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 zod

30-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.js

The 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.4ms

No 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. The Server/McpServer APIs above.
  • @modelcontextprotocol/server ("v2") — protocol revision 2026-07-28, whose headline change is removing the initialize handshake and the Mcp-Session-Id Streamable HTTP header in favor of a stateless, self-contained-request model. Same Server/McpServer shapes, same instrumentMcpServer() call — detection and wrapping happen automatically, resolved once per instrumentMcpServer() 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 absent sessionId property — closing a confirmed false positive against the transport createMcpHandler builds internally (PerRequestHTTPServerTransport), which also has no sessionId, for the opposite reason stdio doesn't. v1 is completely unaffected by this change. See docs/known-gaps.md entry 8 for the full history.
  • The generated fallback session id is now shared across repeated instrumentMcpServer() calls when instanceKey is 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. See docs/known-gaps.md entry 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_error

Verified 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.name is 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 by mcp.method.name (one of the five methods above) and error.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 v2 McpServer, declaring capabilities: { 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 MeterProvider with a PeriodicExportingMetricReader exporting 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 as StderrSpanExporter (ADR 003): a StdioServerTransport server'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 as name{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 dev TracerProvider already builds, so the two signals can never carry different service.name values in dev mode.
  • If a MeterProvider is already registered globally — your own, or anything else's — this is a complete no-op: nothing is overridden, no exception is thrown, and a single diag.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 under setupNodeSdk: true) to also flush and shut down the MeterProvider it registered, alongside the existing TracerProvider shutdown. 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.js

The 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} = 1

The _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, never stale. Their spans are unchanged.
  • Stale isn't an error. Span status stays OK, there's no error.type, and it's never counted as a silent failure. Alert on the verdict or the counter, not on error rate.
  • isError: true responses are skipped. They're already failures.
  • Malformed freshness never breaks a call. It gets unknown with a reason, at most one diag.warn per 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, asOf more than 5 s in the future (up to 5 s is treated as clock skew), and freshUntil before asOf.
  • Privacy: only asOf, maxAgeMs and freshUntil under that one key are read. Result content and other _meta fields are never read for this, and raw values are never emitted, only the derived numbers (checked by a canary test in test/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:4319
instrumentMcpServer(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" wording
  • dependency — a downstream service or package failed (Mongo, Postgres, ...)
  • serialization — malformed JSON, "unexpected token" wording
  • internal — 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 over DEFAULT_PRICING (or over pricingTable, 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 replaces DEFAULT_PRICING. Use this when you want an effective table containing only your own models, none of DEFAULT_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_PRICING is a best-effort snapshot, not a maintained price list. Provider pricing changes frequently and varies by region/contract — opentel-mcp does not guarantee it stays current, and says so at runtime, not just here: once DEFAULT_PRICING (checked via DEFAULT_PRICING_LAST_VERIFIED, also exported) is more than 90 days past its last-verified date, instrumentMcpServer() fires a one-time diag.warn() naming that date — and, when setupNodeSdk: true, also attaches an mcp.pricing.default_table_last_verified resource attribute, so long-running deployments can alert on it directly. Both only fire when DEFAULT_PRICING is actually contributing to your effective table (i.e. you haven't fully replaced it via pricingTable) — 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 own costTracking.extractor (a UsageExtractor: (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-numeric inputPer1M/outputPer1M) degrade to null, 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