Skip to content
opentel-mcp

API Reference

Exported functions, types, and options in opentel-mcp v0.10.0: instrumentMcpServer, computeFingerprint, calculateCost, and their real signatures.

By Thirumalaiboobathi BLast updated: August 12, 2026Edit this page on GitHub

opentel-mcp's package root exports one function for instrumentation (instrumentMcpServer), a small set of functions and types for Deep Failure Fingerprinting (computeFingerprint, toSpanAttributes, ATTRIBUTE_KEYS, METRIC_SAFE_ATTRIBUTES, DEFAULT_CLASSIFIERS), and — new in v0.5.0 — a set of functions and types for Cost & Token Attribution (DEFAULT_PRICING, defaultExtractor, calculateCost). Every signature below is copied from the package's own src/index.d.ts and the modules it re-exports from, not reconstructed from behavior.

TL;DR

instrumentMcpServer(server, options?) is the only function most integrations need — it wraps a Server or McpServer instance and returns it unchanged (plus an optional shutdown()). The fingerprinting exports (computeFingerprint, toSpanAttributes, and friends) exist for advanced cases: calling the fingerprinting pipeline directly, outside of instrumentMcpServer's automatic per-call wrapping.

What does instrumentMcpServer() accept?

ts
function instrumentMcpServer<T extends Server | McpServer | DuckTypedMcpServer>(
  server: T,
  options?: InstrumentOptions,
): T & { shutdown?: () => Promise<void> };

server is either a low-level Server or a high-level McpServer, from either of two supported SDKs: @modelcontextprotocol/sdk (v1 — Server from .../server/index.js, McpServer from .../server/mcp.js) or @modelcontextprotocol/server (v2, protocol revision 2026-07-28, v0.10.0+ — same class names, same import-path shape). Both are OPTIONAL peer dependencies of opentel-mcp — install whichever one(s) you actually use; see "Does opentel-mcp support MCP v2?" below for what differs between them. It must be called before any tools/call handler is registered — before server.setRequestHandler(CallToolRequestSchema, ...) (v1 low-level) or server.setRequestHandler('tools/call', ...) (v2 low-level — v2 dispatches by a plain method-name string rather than schema-object identity), or before any .tool()/.registerTool() call on an McpServer from either SDK. Calling it more than once on the same server (or on an McpServer and its inner Server interchangeably) is a no-op after the first call.

As of v0.10.0, an McpServer-shaped object isn't just duck-typed — its inner server is verified too. instrumentMcpServer() recognizes a high-level McpServer by two checks together: an outer shape match (.server.setRequestHandler is a function, and either .tool or .registerTool is a function) and, new in v0.10.0, that .server actually is a recognized Server instance from a supported SDK — not merely an object that happens to expose a setRequestHandler method, which is all earlier versions checked.

Unrecognized McpServer-shaped objects now throw, not silently no-op

Scope: this only covers an object that passes the outer McpServer shape check above but whose .server doesn't resolve to a recognized Server class. An input that doesn't look like a Server or McpServer at all fails a different, earlier check and throws a separate error — this callout is about the narrower, shape-matched case specifically.

Before v0.10.0, an object satisfying just the outer shape check passed detection and appeared to instrument successfully — instrumentMcpServer() returned without error, getThrashSummary/getObservationState were both attached — while producing zero spans, zero metrics, and zero fingerprinting for every tool call, silently. As of v0.10.0, that object throws instead — a plain Error, not a named or exported error class, so it can't be caught by type or a .code property. Its .message begins:

"opentel-mcp: instrumentMcpServer() detected an object shaped like a high-level McpServer (it has .tool()/.registerTool() and a .server.setRequestHandler() method), but its `.server` property is not a recognized Server instance from either supported MCP SDK"

No escape hatch was added — an override letting a caller bypass the check would defeat the reason this exists.

The message names three possible causes — check them in this order:

  1. A duplicate or mismatched install of whichever SDK (@modelcontextprotocol/sdk or @modelcontextprotocol/server) your server actually came from — try npm dedupe, or check for multiple installed versions.
  2. That SDK is installed, but isn't resolvable from opentel-mcp's own location — a monorepo or hoisting issue. Fix is confirming normal node_modules resolution from wherever opentel-mcp itself is installed, not npm dedupe — a different problem than (1).
  3. An MCP SDK this package doesn't support at all.

A real Server/McpServer from a single, consistently-resolved copy of either supported SDK is unaffected by this change.

Scope: as of v0.8.0, the tools/call handler (registered via CallToolRequestSchema) is always wrapped, and the tools/list handler (registered via ListToolsRequestSchema) is also wrapped whenever schemaDrift.enabled is true (the default). Requests for resources/* and prompts/* pass through unwrapped and don't emit spans or metrics.

tools/list now falls under the instrument-first requirement too

Because schemaDrift.enabled defaults to true, instrumentMcpServer() must now be called before tools/list is registered, not just before tools/call — for low-level Server users specifically who register setRequestHandler(ListToolsRequestSchema, ...) themselves. Registering it first now throws INSTRUMENT_FIRST_ERROR, where it didn't before v0.8.0. McpServer users are unaffected — it registers tools/list and tools/call together, atomically. Migration: reorder the tools/list registration to after instrumentMcpServer(), or pass schemaDrift: { enabled: false }.

It returns the same object it was given, so it can be used inline:

ts
const server = instrumentMcpServer(new McpServer({ name: 'my-server', version: '1.0.0' }), {
  serviceName: 'my-mcp-server',
});

InstrumentOptions

ts
interface InstrumentOptions {
  serviceName?: string;
  exporterUrl?: string;
  enabled?: boolean;                        // default: true
  enableMetrics?: boolean;                  // default: true
  setupNodeSdk?: boolean;                   // default: false
  costTracking?: CostTrackingOptions;       // see below
  fingerprinting?: boolean;                 // default: true — deep-failure fingerprinting
  thrashDetection?: Partial<ThrashConfig>;  // default: enabled — see README's "Agent Thrash Detection (v0.6.0+)" section for ThrashConfig's full fields
  schemaDrift?: Partial<SchemaDriftConfig>; // default: enabled — see README's "Tool schema drift detection (v0.8.0+)" section for SchemaDriftConfig's full fields
  instanceKey?: string;                     // default: unset — shares Agent Thrash Detection, budget tracking, schema drift, and the toolOutcome counter's state across instrumentMcpServer() calls that share this key (v0.9.0+). See "Tracker State Under Stateless HTTP" (/docs/instance-state)
}
OptionDefaultWhat it does
serviceNameNames the resource of the tracer provider opentel-mcp creates. Required (non-empty string) when setupNodeSdk: true — throws otherwise. Has no effect when setupNodeSdk is false; passing it anyway logs a one-time diag.warn rather than erroring.
exporterUrlOTLP/HTTP traces endpoint (e.g. http://localhost:4318/v1/traces). Only takes effect when setupNodeSdk: true.
enabledtruefalse makes instrumentMcpServer() a complete no-op.
enableMetricstruefalse disables the mcp.tool.* metrics; tracing is unaffected. Metrics are already a zero-overhead no-op when no MeterProvider is registered — this is for opting out even when one is.
setupNodeSdkfalsetrue makes opentel-mcp create and register its own NodeTracerProvider (always to stderr; also to exporterUrl via OTLP/HTTP if set). false (the default) emits spans through whatever TracerProvider the host application already registered globally, or drops them silently if none has been — so opentel-mcp never overrides a host app's own OpenTelemetry setup.
costTracking{ enabled: true, pricingTable: DEFAULT_PRICING, extractor: defaultExtractor }Controls Cost & Token Attribution — see CostTrackingOptions below. Any fields omitted from a partial object fall back to their defaults individually.

fingerprinting's type-declaration gap — since resolved

src/config.js's resolveOptions() reads a fingerprinting option (boolean, default true — see Deep Failure Fingerprinting) and src/instrument.js uses it at runtime. Through v0.5.0, it wasn't listed in the InstrumentOptions TypeScript interface in src/index.d.ts — only in config.js's JSDoc typedef — so TypeScript consumers could see a type error passing { fingerprinting: false } even though it was honored at runtime. Resolved in v0.6.0: fingerprinting?: boolean landed on InstrumentOptions. A narrower, related gap is still not resolved as of v0.10.0: computeFingerprint()'s classifiers/stackFrames options remain unwired through instrumentMcpServer()'s own options — no target version has been set for that part.

What do I get back from instrumentMcpServer()?

The same object passed in — T, unchanged — plus an optional shutdown(): Promise<void>. shutdown is only attached when setupNodeSdk: true; check for its presence before calling it. It flushes and shuts down the NodeTracerProvider opentel-mcp created, and should run during your process's own shutdown sequence so buffered spans aren't lost.

DuckTypedServer

ts
type DuckTypedServer = {
  setRequestHandler: (...args: any[]) => any;
};

The low-level counterpart to DuckTypedMcpServer below — a purely structural type describing a low-level Server's shape without importing either SDK's actual Server class. Added in v0.10.0 alongside @modelcontextprotocol/server becoming a second, optional peer dependency: once both SDKs are optional, a nominal import type { Server } from '@modelcontextprotocol/sdk/...' in index.d.ts breaks type-checking for any consumer who has neither SDK installed — confirmed against a real packed tarball with neither SDK present, not predicted from package.json syntax alone. Same reasoning as DuckTypedMcpServer's own history below, which hit the identical problem first.

DuckTypedMcpServer

ts
type DuckTypedMcpServer = {
  server: { setRequestHandler: (...args: any[]) => any };
  tool?: (...args: any[]) => any;
  registerTool?: (...args: any[]) => any;
};

A structural type for McpServer-shaped objects that aren't literally instanceof McpServer — the package's own doc comment explains why: McpServer has private fields, so TypeScript treats assignability to it as effectively nominal, and neither SDK's real McpServer class is imported here at all — nominally importing either would break type-checking for a consumer who has only the other SDK, or neither, installed.

Satisfying this type is necessary, but not sufficient, for runtime acceptance

Before v0.10.0, an object satisfying this shape reliably worked at runtime too — detection there was equally loose. That stopped being true once detectServerKind() was hardened to additionally require .server instanceof <Server> for a real, resolved SDK class (see "Unrecognized McpServer-shaped objects now throw, not silently no-op" above). As a direct, deliberate consequence, an object that merely has the right shape — a dual-package-hazard .server from a different resolved copy of the SDK than this process itself resolves, or a hand-rolled mock not actually built on either SDK — is now rejected at runtime (UNWRAPPABLE_MCPSERVER_ERROR), not silently accepted. This type mirrors the duck-typing the runtime's detection step performs; it can't also encode the nominal instanceof check runtime acceptance ultimately requires, since that would mean importing a class this file cannot safely import for either SDK.

Does opentel-mcp support MCP v2?

Yes, as of v0.10.0. Both @modelcontextprotocol/sdk (v1, protocol revisions through 2025-11-25) and @modelcontextprotocol/server (v2, protocol revision 2026-07-28) are supported — two separate, OPTIONAL peer dependencies. Install whichever one(s) you actually use; neither is required just to depend on opentel-mcp. Detection and wrapping happen automatically, resolved once per instrumentMcpServer() call by which SDK the object actually came from — there's no separate instrumentMcpServerV2() export.

What works identically to v1: spans, standard attributes, Deep Failure Fingerprinting, and mcp.failure.channel/mcp.failure.validation_paths classification — v2 gained its own code path for each, since v2's thrown errors carry a clean message with no "MCP error N: " wrapper to unwrap, and its rendered validation-issue text uses a third, distinct format from either of v1's two. One attribute source differs: jsonrpc.request.id reads from v1's extra.requestId, but from v2's ctx.mcpReq.id — a field that's always present on v2 rather than conditionally set, unlike v1's.

v2's own entry points construct a fresh server per request — instrumentMcpServer() must run inside that factory

v2's createMcpHandler/serveStdio build a new Server/McpServer instance per request, via a factory function you provide — including createMcpHandler's default stateless posture, not an edge case. instrumentMcpServer() has to run inside that factory, on every invocation, not once at module load the way a long-lived v1 server does. This isn't a new mechanism: it's the identical deployment shape Tracker State Under Stateless HTTP already covers for v1's "stateless Streamable HTTP" case — instanceKey (v0.9.0) is the existing fix, shared unchanged. Nothing v2-specific was added for it.

Status of the two tracked gaps under v2 (docs/known-gaps.md entries 6 and 8) as of v0.10.0: entry 8 is fixed; entry 6 is partially fixed, not closed — its fallback-id half is fixed, but a structural limitation survives it (below). isSingleConnectionTransport() no longer auto-detects the transport createMcpHandler builds internally as single-connection (entry 8, fixed) — so Agent Thrash Detection's session-id fallback path is no longer reached automatically by a typical v2 HTTP deployment; it now requires an explicit, informed assumeSingleSession: true opt-in, or a genuinely single-connection v2 serveStdio deployment. For whichever deployments do legitimately reach that fallback path, the generated fallback session id is now shared across repeated instrumentMcpServer() calls when instanceKey is set (entry 6's fallback-id half, fixed) — the same registry Tracker State Under Stateless HTTP already describes, one more namespaced entry, no new bound/eviction policy. One narrower thing stays open: the internal flag tracking "has this server ever proven itself session-aware" isn't registry-backed yet, which only matters for a deployment mixing real-session-id calls with occasional no-session-id ones under one shared instanceKey. Separately, and not fixable by this or any library-side change: MCP spec 2026-07-28 removes the wire-level session handshake entirely, so createMcpHandler's stateless default simply doesn't populate a real session id most of the time — instanceKey was never designed to manufacture one, only to share tracker state given one.

What's exported for Deep Failure Fingerprinting?

These are re-exported at the package root from src/fingerprint/, so they're available as import { computeFingerprint, ... } from 'opentel-mcp' rather than reaching into a subpath.

ts
function computeFingerprint(
  err: unknown,
  ctx: FingerprintContext,
  opts?: ComputeFingerprintOptions,
): FingerprintResult;
ts
interface FingerprintContext {
  readonly toolName?: string;
  readonly origin: FailureOrigin;
  readonly cwd?: string; // default: process.cwd(), used for path normalization
}
 
interface ComputeFingerprintOptions {
  readonly classifiers?: readonly Classifier[]; // prepended to DEFAULT_CLASSIFIERS
  readonly stackFrames?: number;                // default: 5
}

computeFingerprint() never throws — see Deep Failure Fingerprinting for the fallback behavior and why that matters for something running inline in the instrumentation path.

ts
function toSpanAttributes(result: FingerprintResult): Attributes;

Maps a FingerprintResult onto the five mcp.failure.* span attributes. Returns {} if result is malformed in any way — like computeFingerprint(), this must never throw, since it also runs inline on every failure.

ts
const ATTRIBUTE_KEYS: {
  FINGERPRINT: 'mcp.failure.fingerprint';
  SIGNATURE: 'mcp.failure.signature';
  CATEGORY: 'mcp.failure.category';
  ORIGIN: 'mcp.failure.origin';
  ERROR_CLASS: 'mcp.failure.error_class';
};
 
const METRIC_SAFE_ATTRIBUTES: readonly ['mcp.failure.category', 'mcp.failure.origin'];
 
const DEFAULT_CLASSIFIERS: readonly Classifier[]; // 7 built-in classifiers, in match order

What types does opentel-mcp export?

ts
type FailureCategory =
  | 'validation' | 'timeout' | 'network' | 'auth'
  | 'dependency' | 'serialization' | 'internal' | 'unknown';
 
type FailureOrigin = 'tool_error' | 'thrown' | 'transport';
 
interface FingerprintResult {
  readonly fingerprint: string; // 16 lowercase hex chars
  readonly signature: string;   // "<errorClass>@<fn>:<line>", ≤60 chars
  readonly category: FailureCategory;
  readonly origin: FailureOrigin;
  readonly inputs: FingerprintInputs;
}
 
interface FingerprintInputs {
  readonly errorClass: string;
  readonly category: FailureCategory;
  readonly origin: FailureOrigin;
  readonly toolName: string | null; // set only for origin: 'tool_error'
  readonly normalizedMessage: string;
  readonly stackSignature: string;
}

What's exported for Cost & Token Attribution?

New in v0.5.0, re-exported at the package root from src/cost/ the same way the fingerprinting exports are — import { DEFAULT_PRICING, defaultExtractor, calculateCost } from 'opentel-mcp'. See Cost & Token Attribution for how these fit together at runtime.

ts
const DEFAULT_PRICING: PricingTable; // 15+ models across 5 providers, see src/cost/pricing.js
 
const defaultExtractor: UsageExtractor;
 
function calculateCost(
  inputTokens: number,
  outputTokens: number,
  model: string,
  pricingTable: PricingTable,
): number | null; // null if model isn't in pricingTable or a token count is invalid
ts
interface CostTrackingOptions {
  enabled?: boolean;              // default: true
  pricingTable?: PricingTable;    // default: DEFAULT_PRICING
  extractor?: UsageExtractor;     // default: defaultExtractor
  budget?: BudgetConfig;          // default: undefined (budget tracking off)
}
 
interface ModelPricing {
  readonly inputPer1M: number;  // USD per 1,000,000 input tokens
  readonly outputPer1M: number; // USD per 1,000,000 output tokens
  readonly currency: 'USD';
}
 
type PricingTable = Record<string, ModelPricing>;
 
interface TokenUsage {
  readonly inputTokens: number;
  readonly outputTokens: number;
  readonly totalTokens: number;
  readonly model?: string; // present only when the extractor found a model name
}
 
type UsageExtractor = (toolResult: unknown) => TokenUsage | null;
 
interface BudgetConfig {
  readonly perSessionUsd?: number; // cumulative-cost limit per MCP session id
  readonly perToolUsd?: number;    // cumulative-cost limit per tool name
}

calculateCost() normalizes model (lowercases it, strips a leading provider/ prefix) before looking it up in pricingTable, and never throws — an unrecognized model or an invalid token count resolves to null, not an exception. defaultExtractor also never throws; see Cost & Token Attribution for the shapes it recognizes.

Where do I go from here?