opentel-mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ ### Added
6
+
7
+ - OTel metrics, via `@opentelemetry/api`'s Metrics API only (no bundled
8
+ SDK/exporter — same host-app-provides-the-SDK pattern tracing already
9
+ uses):
10
+ - `mcp.tool.calls` (counter) — every tool call; `gen_ai.tool.name`,
11
+ `mcp.method.name`
12
+ - `mcp.tool.errors` (counter) — thrown/rejected handler; `gen_ai.tool.name`,
13
+ `error.type`
14
+ - `mcp.tool.silent_failures` (counter) — JSON-RPC succeeded but
15
+ `CallToolResult.isError === true`; `gen_ai.tool.name`
16
+ - `mcp.tool.duration` (histogram, unit `ms`) — call latency;
17
+ `gen_ai.tool.name`, `mcp.tool.outcome` (`success` | `error` |
18
+ `silent_failure`)
19
+ - `mcp.tool.silent_failures` fires from the same `isError` check that
20
+ marks the span ERROR — extracted into one shared `isToolResultError()`
21
+ helper in `src/instrument.js` so the detection logic isn't duplicated
22
+ between traces and metrics.
23
+ - Metrics are a zero-overhead no-op until the host application registers
24
+ a `MeterProvider` (default `@opentelemetry/api` behavior — not
25
+ special-cased here).
26
+ - `enableMetrics` option (default `true`) to opt out of metric emission
27
+ without affecting tracing.
28
+
29
+ ### Naming note (no attribute rename)
30
+
31
+ The tool-name attribute on all four new metrics is `gen_ai.tool.name`, not
32
+ `mcp.tool.name` — the same spec-aligned name spans have used since v0.2's
33
+ semantic-conventions pass (ADR 004). Traces and metrics were already
34
+ consistent going into this release, so nothing was renamed here; this is
35
+ called out because a naive read of the MCP semantic conventions might
36
+ suggest a `mcp.tool.name` attribute, but the spec's actual server-span/
37
+ metric attribute for this is `gen_ai.tool.name` (MCP tool calls are
38
+ GenAI `execute_tool` calls under the hood — see `docs/adr/004-semantic-conventions-alignment.md`
39
+ and `.spec-reference/mcp-semconv.md`). `mcp.method.name`, `error.type`,
40
+ and `mcp.tool.argument_count` are unchanged. `mcp.tool.outcome` is a new
41
+ custom (non-spec) attribute, documented in `src/attributes.js` alongside
42
+ the other custom attribute.
43
+
44
+ ### Docs
45
+
46
+ - README: new "Metrics" section (instrument table, `enableMetrics`, and a
47
+ `PeriodicExportingMetricReader` + OTLP/HTTP wiring example targeting
48
+ SigNoz's default local endpoint).
49
+ - Roadmap updated to reflect metrics shipping in this release.
50
+
51
+ ## 0.2.0
52
+
53
+ See git history — TypeScript declarations (`.d.ts`), workspace stripping
54
+ for publish, and README documentation improvements.
55
+
56
+ ## 0.1.0
57
+
58
+ Initial release: OTel tracing for MCP tool calls, including detection of
59
+ `CallToolResult.isError: true` "silent failures" as `error.type: tool_error`
60
+ span status.
package/README.md CHANGED
@@ -1,18 +1,30 @@
1
1
  # opentel-mcp
2
2
 
3
- > OpenTelemetry instrumentation for Model Context Protocol (MCP) servers.
4
- > One-line visibility into which tools your AI agent is calling, how
5
- > long they take, and which ones fail — via standard OTel traces.
3
+ > Turn every MCP tool call into an OpenTelemetry trace — including the
4
+ > failures your logs won't show you.
6
5
 
7
6
  [![CI](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
8
7
  [![npm version](https://img.shields.io/npm/v/opentel-mcp.svg)](https://www.npmjs.com/package/opentel-mcp)
8
+ [![node](https://img.shields.io/node/v/opentel-mcp.svg)](https://www.npmjs.com/package/opentel-mcp)
9
9
  [![license](https://img.shields.io/npm/l/opentel-mcp.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/LICENSE)
10
10
 
11
- ## Status
11
+ opentel-mcp watches every tool call your MCP (Model Context Protocol)
12
+ server handles: which tool ran, how long it took, and whether it worked.
13
+ It reports that as OpenTelemetry (OTel) traces — the standard most
14
+ dashboards already read. One function call; no changes to your tools'
15
+ code.
12
16
 
13
- `0.x` the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
14
- this library implements are Development-stage and may change. Breaking
15
- attribute changes will land in minor versions until `1.0`.
17
+ ## The problem
18
+
19
+ Your AI agent calls 15 MCP tools across 3 servers this turn. One tool
20
+ returns `{ isError: true }` inside an otherwise-successful response — how
21
+ a tool reports "I couldn't do that" without crashing. Your logs show
22
+ success. Your metrics show success. The agent gives a wrong answer, and
23
+ nothing you're monitoring says why.
24
+
25
+ opentel-mcp makes that failure visible: one span per tool call, marked as
26
+ an error when it actually is one, using the same standard your dashboards
27
+ already speak.
16
28
 
17
29
  ## Install
18
30
 
@@ -20,82 +32,121 @@ attribute changes will land in minor versions until `1.0`.
20
32
  npm install opentel-mcp @opentelemetry/api
21
33
  ```
22
34
 
23
- `opentel-mcp` is an ES module. Your project needs `"type": "module"` in
24
- its package.json, or you must use `.mjs` file extensions.
25
-
26
- ## Why
35
+ opentel-mcp is an ES module add `"type": "module"` to package.json.
27
36
 
28
- When Claude Code calls 15 MCP tools across 3 servers, you have zero
29
- visibility into which was slow, which errored silently, which sequence
30
- ran. opentel-mcp wraps any MCP server and emits one OTel span per tool
31
- invocation with rich attributes, using standard OpenTelemetry APIs so
32
- it plugs into your existing observability stack (Jaeger, Grafana Tempo,
33
- Honeycomb, Datadog, whatever).
34
-
35
- ## Quickstart (5-line usage)
36
-
37
- Works with either the low-level `Server` API:
37
+ ## 30-second quickstart
38
38
 
39
39
  ```js
40
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
40
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
41
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
41
42
  import { instrumentMcpServer } from 'opentel-mcp';
43
+ import { z } from 'zod';
42
44
 
43
- const server = new Server({ name: 'my-server', version: '1.0.0' }, {
44
- capabilities: { tools: {} }
45
- });
45
+ const server = new McpServer({ name: 'my-server', version: '1.0.0' });
46
46
 
47
+ // Wraps every tool registered below. Must run BEFORE server.tool() —
48
+ // see "Ordering constraint" below for why.
47
49
  instrumentMcpServer(server, {
48
- serviceName: 'my-mcp-server', // required because setupNodeSdk is true below
49
- setupNodeSdk: true, // dev-friendly stderr output; omit in prod
50
- // if you already have OTel configured
50
+ serviceName: 'my-mcp-server', // shows up on your traces
51
+ setupNodeSdk: true, // dev mode: prints traces to your terminal
51
52
  });
52
53
 
53
- // ...register tools as usual...
54
- server.setRequestHandler(CallToolRequestSchema, async (request) => { /*...*/ });
54
+ // A normal tool, registered exactly as usual.
55
+ server.tool('echo', { text: z.string() }, async ({ text }) => ({
56
+ content: [{ type: 'text', text: `you said: ${text}` }],
57
+ }));
58
+
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
61
+ ```
62
+
63
+ That's it. Every tool call now emits a trace. Wire an exporter to see them
64
+ (next section).
65
+
66
+ ## See it working
67
+
68
+ Run the snippet above and this prints to your terminal — a real, captured
69
+ run (full dump: `examples/hello-mcpserver/README.md`):
70
+
55
71
  ```
72
+ name: 'tools/call echo'
73
+ kind: 1 // SpanKind.SERVER
74
+ status: { code: 1 } // OK
75
+ attributes: {
76
+ 'mcp.method.name': 'tools/call',
77
+ 'gen_ai.tool.name': 'echo',
78
+ 'mcp.tool.argument_count': 1,
79
+ 'jsonrpc.request.id': '1'
80
+ }
81
+ ```
82
+
83
+ No dashboard needed — `setupNodeSdk: true`'s dev exporter printed this
84
+ directly. Point it at a real backend later; see "Two modes" below.
85
+
86
+ ---
87
+
88
+ The rest of this README goes deeper: both server APIs, every attribute
89
+ and metric emitted, how failure grouping works, configuration, and the
90
+ non-obvious design decisions behind each.
56
91
 
57
- ...or the high-level `McpServer` API most servers actually use:
92
+ ## Both server APIs
93
+
94
+ MCP servers are built on one of two classes from `@modelcontextprotocol/sdk`;
95
+ opentel-mcp detects and wraps either one the same way (see ADR 001 in
96
+ `docs/adr/` for how).
97
+
98
+ **`McpServer`** — the high-level API most servers are actually built on.
99
+ Use it unless you have a specific reason not to; this is what the
100
+ quickstart above uses.
101
+
102
+ **`Server`** — the low-level API, for when you're handling raw JSON-RPC
103
+ yourself or building a library on top of MCP:
58
104
 
59
105
  ```js
60
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
106
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
107
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
108
+ import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
61
109
  import { instrumentMcpServer } from 'opentel-mcp';
62
110
 
63
- const server = new McpServer({ name: 'my-server', version: '1.0.0' });
111
+ const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
64
112
 
65
- instrumentMcpServer(server, {
66
- serviceName: 'my-mcp-server', // required because setupNodeSdk is true below
67
- setupNodeSdk: true,
113
+ // Must run before setRequestHandler(CallToolRequestSchema, ...) below.
114
+ instrumentMcpServer(server, { serviceName: 'my-mcp-server', setupNodeSdk: true });
115
+
116
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
117
+ const { text } = request.params.arguments ?? {};
118
+ return { content: [{ type: 'text', text: `you said: ${text}` }] };
68
119
  });
69
120
 
70
- // ...register tools as usual...
71
- server.tool('my-tool', async (args) => { /*...*/ });
121
+ const transport = new StdioServerTransport();
122
+ await server.connect(transport);
72
123
  ```
73
124
 
74
- Either way, `instrumentMcpServer()` must run before any tool is
75
- registered — see "Ordering constraint" below.
125
+ Runnable versions of both live in `examples/hello-server/` and
126
+ `examples/hello-mcpserver/`.
76
127
 
77
- ## Semantic conventions
128
+ ## What gets emitted
129
+
130
+ ### Tool-level failures, specifically
78
131
 
79
- opentel-mcp follows the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
80
- published by the OTel GenAI SIG (they moved there from the main
81
- `semantic-conventions` repo, where the MCP conventions are now marked
82
- deprecated). **That spec's status is Development, not Stable** attribute
83
- names and requirement levels may still change upstream, and this package
84
- will follow suit when they do. See ADR 004 in `docs/adr/` for the full
85
- reasoning.
132
+ An MCP tool can fail two ways: it can throw, or it can return
133
+ `isError: true` on an otherwise-successful response (the case from "The
134
+ problem" above). opentel-mcp treats both the same way span marked
135
+ `ERROR`, nothing thrown, the result returned to the caller unchanged:
86
136
 
87
- One attribute, `mcp.tool.argument_count`, is **not** part of the spec — it's
88
- our own addition, documented as such in `src/attributes.js`. It's a
89
- privacy-preserving alternative to the spec's opt-in
90
- `gen_ai.tool.call.arguments`: it gives shape/anomaly signal (argument count
91
- changed) without capturing any argument values.
137
+ ```
138
+ tools/call fetch_weather ................. 605ms ERROR
139
+ error.type = tool_error
140
+ ```
92
141
 
93
- ## Span attributes emitted
142
+ Verified in `test/instrument.test.js`'s "tool-level failure" tests.
94
143
 
95
- Span (server-side `tools/call` handling) follows the spec's MCP server
96
- span: name `{mcp.method.name} {tool name}` (e.g. `tools/call echo`, falling
97
- back to just `mcp.method.name` when no tool name is available), kind
98
- `SERVER`, status `ERROR` whenever `error.type` is set.
144
+ ### Span attributes
145
+
146
+ Every span follows the OpenTelemetry MCP semantic conventions (see
147
+ "Semantic conventions" below), name `{mcp.method.name} {tool name}`
148
+ (e.g. `tools/call echo`), kind `SERVER`, status `ERROR` whenever
149
+ `error.type` is set.
99
150
 
100
151
  | Attribute | Requirement Level | Description | Example |
101
152
  |---|---|---|---|
@@ -107,31 +158,203 @@ back to just `mcp.method.name` when no tool name is available), kind
107
158
  | mcp.tool.argument_count | **Custom — not spec** | Number of arguments (values not captured) | 2 |
108
159
 
109
160
  Span status description carries the error message on failure (thrown
110
- errors); no separate error-message attribute is emitted — the spec
111
- expresses success/failure through span status, not a status attribute.
161
+ errors); there's no separate error-message attribute — the spec expresses
162
+ success/failure through span status, not an attribute. Source of truth:
163
+ `src/attributes.js`.
112
164
 
113
- ## Two modes
165
+ ### Metrics
166
+
167
+ Four `mcp.tool.*` metrics via `@opentelemetry/api`'s Metrics API — same
168
+ API-only pattern as tracing (see "Two modes" below): nothing is recorded
169
+ until a `MeterProvider` is registered. Set `enableMetrics: false` to opt
170
+ out even when one is; tracing is unaffected either way. Source of truth:
171
+ `src/metrics.js`.
172
+
173
+ | Metric | Type | Unit | Attributes | Emitted when |
174
+ |---|---|---|---|---|
175
+ | `mcp.tool.calls` | Counter | — | `gen_ai.tool.name`, `mcp.method.name` | Every tool call |
176
+ | `mcp.tool.errors` | Counter | — | `gen_ai.tool.name`, `error.type`[^1] | Handler threw or rejected |
177
+ | `mcp.tool.silent_failures` | Counter | — | `gen_ai.tool.name`[^1] | Result had `isError: true` |
178
+ | `mcp.tool.duration` | Histogram | ms | `gen_ai.tool.name`, `mcp.tool.outcome`[^1] | Every call, completion |
179
+
180
+ [^1]: Also carries `mcp.failure.category` when fingerprinting finds one — see "Failure Fingerprinting" below.
181
+
182
+ `mcp.tool.silent_failures` increments from the exact same check that marks
183
+ the span `ERROR` (`isToolResultError()` in `src/instrument.js`) — the
184
+ detection logic isn't duplicated between traces and metrics.
185
+
186
+ Wiring a real `MeterProvider`/`TracerProvider` — a worked example against
187
+ SigNoz's local OTLP endpoint:
188
+
189
+ ```js
190
+ import { metrics, trace } from '@opentelemetry/api';
191
+ import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
192
+ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
193
+ import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
194
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
195
+ import { resourceFromAttributes } from '@opentelemetry/resources';
196
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
197
+ import { instrumentMcpServer } from 'opentel-mcp';
198
+
199
+ const resource = resourceFromAttributes({ 'service.name': 'my-mcp-server' });
114
200
 
115
- **One-line (dev):** `setupNodeSdk: true` sets up a NodeTracerProvider
116
- that prints spans to stderr (safe alongside stdio-transport MCP servers —
117
- see ADR 003), optionally + an OTLP exporter if `exporterUrl` is provided.
118
- No separate OTel SDK setup needed — `serviceName` is required in this
119
- mode, since it names the resource of the provider opentel-mcp creates.
201
+ const meterProvider = new MeterProvider({
202
+ resource,
203
+ readers: [
204
+ new PeriodicExportingMetricReader({
205
+ exporter: new OTLPMetricExporter({ url: 'http://localhost:4318/v1/metrics' }),
206
+ }),
207
+ ],
208
+ });
209
+ metrics.setGlobalMeterProvider(meterProvider);
210
+
211
+ const tracerProvider = new NodeTracerProvider({
212
+ resource,
213
+ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }))],
214
+ });
215
+ tracerProvider.register();
120
216
 
121
- **Bring-your-own-SDK (prod):** Omit `setupNodeSdk` (default false).
122
- opentel-mcp uses whatever tracer provider you've already registered
123
- via `trace.setGlobalTracerProvider()`. This means it plugs into any
124
- existing OTel setup without conflict. In this mode the host's
125
- TracerProvider owns the resource, so `serviceName` is not needed and has
126
- no effect — set `service.name` on the host's Resource instead. Passing
127
- `serviceName` anyway is harmless but logs a one-time `diag.warn`.
217
+ const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
218
+
219
+ // setupNodeSdk: false (default) both providers above are already
220
+ // registered globally, so instrumentMcpServer() picks them up as-is.
221
+ instrumentMcpServer(server, {});
222
+ ```
223
+
224
+ `http://localhost:4318` is SigNoz's default local OTLP/HTTP (OpenTelemetry
225
+ Protocol — the wire format traces/metrics travel over) endpoint; point it
226
+ at your own collector in production. `@opentelemetry/sdk-metrics` and
227
+ `@opentelemetry/exporter-metrics-otlp-http` are host-app dependencies —
228
+ opentel-mcp doesn't bundle them (see `package.json`'s `peerDependencies`).
229
+ `@opentelemetry/sdk-trace-node` and `@opentelemetry/exporter-trace-otlp-http`
230
+ are already runtime dependencies of opentel-mcp itself (its `setupNodeSdk:
231
+ true` dev path uses them), so no extra install is needed for those two.
232
+
233
+ ## Failure Fingerprinting (v0.4.0+)
234
+
235
+ Groups logically identical failures under one stable identifier, even
236
+ when the error message contains UUIDs, timestamps, or user IDs. Ten
237
+ calls that fail the same way but each mention a different user ID show
238
+ up as **one** issue, not ten.
239
+
240
+ Runs locally and synchronously over the error object already in hand —
241
+ no network call, no third-party service. Every thrown error and every
242
+ `isError: true` result gets one, automatically; disable with
243
+ `{ fingerprinting: false }` (default: enabled). Full algorithm: ADR 006
244
+ in `docs/adr/`.
245
+
246
+ | Attribute | Description | Example |
247
+ |---|---|---|
248
+ | mcp.failure.fingerprint | Stable 16-hex-char identity for the failure | "a3f4c8e2b1d09f77" |
249
+ | mcp.failure.signature | Human-readable `errorClass@fn:line`, ≤60 chars | "TypeError@doThing:42" |
250
+ | mcp.failure.category | One of 8 categories (below) | "timeout" |
251
+ | mcp.failure.origin | `tool_error` \| `thrown` \| `transport` | "thrown" |
252
+ | mcp.failure.error_class | Error class / constructor name | "TypeError" |
253
+
254
+ Source of truth: `src/fingerprint/attributes.js`. Every category:
255
+
256
+ - `validation` — bad input (Zod/Joi/Yup errors, "invalid"/"required" wording)
257
+ - `timeout` — an operation timed out (`TimeoutError`, `ETIMEDOUT`, ...)
258
+ - `network` — a connection failed (`ECONNREFUSED`, `FetchError`, ...)
259
+ - `auth` — 401/403, "unauthorized"/"forbidden" wording
260
+ - `dependency` — a downstream service or package failed (Mongo, Postgres, ...)
261
+ - `serialization` — malformed JSON, "unexpected token" wording
262
+ - `internal` — nothing more specific matched (the catch-all)
263
+ - `unknown` — fingerprinting itself hit an internal error (should not normally happen)
264
+
265
+ Full classifier source: `src/fingerprint/classify/`.
266
+
267
+ **Cardinality:** the fingerprint itself is unbounded — a new bug means a
268
+ new fingerprint, forever. That's fine on span attributes (each span is
269
+ its own record), but it must **never** go on a metric label, or every
270
+ distinct failure becomes its own permanent time series. opentel-mcp
271
+ enforces this structurally, not by convention: `src/metrics.js` can only
272
+ reach a fingerprint-derived value through
273
+ `METRIC_SAFE_ATTRIBUTES` — a frozen list containing only `category` and
274
+ `origin` (24 combinations max). There is no code path today that could
275
+ accidentally attach `fingerprint`, `signature`, or `error_class` to a
276
+ counter or histogram label. See `src/fingerprint/attributes.js` and ADR
277
+ 006's "Consequences" section.
278
+
279
+ **Extending it:** `computeFingerprint(err, ctx, opts)`
280
+ (`src/fingerprint/compose.js`) accepts `opts.classifiers` to prepend your
281
+ own detection rules ahead of the built-in eight, and `opts.stackFrames` to
282
+ change how many stack frames feed the signature — see
283
+ `test/fingerprint/compose.test.js`'s "uses a custom classifiers list" and
284
+ "respects a custom opts.stackFrames count" tests, and
285
+ `examples/fingerprint-demo.js` for a runnable, standalone demo (`node
286
+ examples/fingerprint-demo.js`). Not yet wired through
287
+ `instrumentMcpServer()`'s own options — today this means importing
288
+ `computeFingerprint` directly rather than configuring the automatic
289
+ per-call-site wrapping; tracked in the roadmap below.
290
+
291
+ ## Configuration
292
+
293
+ All options passed to `instrumentMcpServer(server, options)`. Source of
294
+ truth: `src/config.js`.
295
+
296
+ | Option | Type | Default | Description |
297
+ |---|---|---|---|
298
+ | `serviceName` | string | — | Resource name for traces[^2] |
299
+ | `setupNodeSdk` | boolean | `false` | Dev mode: stderr tracer, no setup[^3] |
300
+ | `exporterUrl` | string | — | OTLP/HTTP traces endpoint[^4] |
301
+ | `enabled` | boolean | `true` | `false` disables all instrumentation |
302
+ | `enableMetrics` | boolean | `true` | `false` disables `mcp.tool.*` metrics only |
303
+ | `fingerprinting` | boolean | `true` | `false` disables `mcp.failure.*` attributes |
304
+
305
+ [^2]: Required only when `setupNodeSdk` is `true`. Has no effect otherwise — the host app's registered `TracerProvider` owns the resource; passing it anyway logs a one-time `diag.warn`.
306
+ [^3]: Creates and registers a `NodeTracerProvider` that always prints to stderr (safe alongside stdio-transport servers — ADR 003), additionally exporting via OTLP/HTTP if `exporterUrl` is set.
307
+ [^4]: Only takes effect when `setupNodeSdk` is `true`.
128
308
 
129
309
  ## Ordering constraint
130
310
 
131
- Call `instrumentMcpServer()` BEFORE registering any tool handlers
311
+ Instrumentation works by wrapping the tool-call handler at the moment
312
+ it's registered. If a handler is registered before `instrumentMcpServer()`
313
+ runs, that handler was never wrapped — it slipped past the trap before it
314
+ was set.
315
+
316
+ Call `instrumentMcpServer()` **before** registering any tool handlers —
132
317
  before `server.setRequestHandler(CallToolRequestSchema, ...)` (low-level
133
318
  `Server`) or before any `.tool()`/`.registerTool()` call (`McpServer`).
134
- See ADR 002 in docs/adr/ for why.
319
+ See ADR 002 in `docs/adr/` for the detection logic that catches violations
320
+ of this at instrument time.
321
+
322
+ ## Two modes
323
+
324
+ ### Quick dev setup
325
+
326
+ `setupNodeSdk: true` sets up a `NodeTracerProvider` that prints spans to
327
+ stderr (safe alongside stdio-transport MCP servers — see ADR 003),
328
+ optionally plus an OTLP exporter if `exporterUrl` is provided. No separate
329
+ OTel SDK setup needed — `serviceName` is required in this mode, since it
330
+ names the resource of the provider opentel-mcp creates.
331
+
332
+ ### Production setup
333
+
334
+ Omit `setupNodeSdk` (default `false`). opentel-mcp uses whatever
335
+ `TracerProvider` is already registered via
336
+ `trace.setGlobalTracerProvider()`, so it plugs into any existing OTel
337
+ setup without conflict. The host's `TracerProvider` owns the resource
338
+ here, so `serviceName` is not needed and has no effect — set
339
+ `service.name` on the host's `Resource` instead. Passing `serviceName`
340
+ anyway is harmless but logs a one-time `diag.warn`.
341
+
342
+ ## Semantic conventions
343
+
344
+ `0.x` — the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
345
+ this library implements are Development-stage, not Stable, and may still
346
+ change upstream; breaking attribute renames will land in minor versions
347
+ until `1.0`, tracked in release notes rather than silently shipped.
348
+
349
+ opentel-mcp follows those conventions (published by the OTel GenAI SIG,
350
+ moved there from the main `semantic-conventions` repo, where the MCP
351
+ conventions are now deprecated) for everything they define, and adds two
352
+ namespaces of its own where they don't yet: `mcp.tool.*` (call-count and
353
+ duration metrics) and `mcp.failure.*` (failure fingerprinting). Both are
354
+ documented as non-spec at every attribute (`src/attributes.js`,
355
+ `src/fingerprint/attributes.js`), and are candidates to fold into the
356
+ spec's own metrics/error vocabulary if it grows an equivalent. Full
357
+ reasoning: ADR 004 in `docs/adr/`.
135
358
 
136
359
  ## Compatibility
137
360
 
@@ -141,15 +364,24 @@ See ADR 002 in docs/adr/ for why.
141
364
  - Supports both low-level `Server` and high-level `McpServer` APIs
142
365
  - @modelcontextprotocol/sdk ^1.0.0
143
366
  - @opentelemetry/api ^1.9.0
367
+ - 127 tests (`npm test`) — see `test/`
144
368
 
145
369
  ## Roadmap
146
370
 
147
- - v0.2: OTel metrics (`mcp.server.operation.duration`,
148
- `mcp.server.session.duration`) alongside traces, plus opt-in
149
- `gen_ai.tool.call.arguments` support with a redaction callback
150
- - v0.3: W3C trace context propagation via `params._meta` per
151
- [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta),
152
- plus client-side instrumentation, so a single trace can span the client
371
+ - v0.4: Deep Failure Fingerprinting ✓ — see "Failure Fingerprinting" above
372
+ and ADR 006.
373
+ - v0.5: Failure clustering + regression detection
374
+ - Future: recovery hints, root-cause chaining across parent spans,
375
+ alignment with the OTel GenAI SIG's MCP semantic conventions when
376
+ published
377
+ - Also still tracked, not silently dropped: exposing `computeFingerprint`'s
378
+ `classifiers`/`stackFrames` options through `instrumentMcpServer()`
379
+ itself; opt-in `gen_ai.tool.call.arguments` support with a redaction
380
+ callback; the spec's own `mcp.server.operation.duration` /
381
+ `mcp.server.session.duration` metrics; W3C trace context propagation via
382
+ `params._meta` per
383
+ [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta);
384
+ and client-side instrumentation, so a single trace can span the client
153
385
  call and the server's tool execution
154
386
 
155
387
  ## Contributing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "src",
16
16
  "README.md",
17
+ "CHANGELOG.md",
17
18
  "LICENSE"
18
19
  ],
19
20
  "engines": {
@@ -22,6 +23,8 @@
22
23
  "scripts": {
23
24
  "test": "vitest run",
24
25
  "test:watch": "vitest",
26
+ "test:coverage": "vitest run --coverage",
27
+ "bench": "vitest bench --run",
25
28
  "prepack": "node scripts/strip-workspaces.js",
26
29
  "postpack": "node scripts/restore-workspaces.js"
27
30
  },
@@ -56,7 +59,9 @@
56
59
  },
57
60
  "devDependencies": {
58
61
  "@opentelemetry/api": "^1.9.0",
62
+ "@opentelemetry/sdk-metrics": "^2.9.0",
59
63
  "@opentelemetry/sdk-trace-base": "^2.9.0",
64
+ "@vitest/coverage-v8": "^2.1.9",
60
65
  "typescript": "^7.0.2",
61
66
  "vitest": "^2.1.8"
62
67
  }
package/src/attributes.js CHANGED
@@ -63,3 +63,25 @@ export const ATTR_MCP_TOOL_ARGUMENT_COUNT = 'mcp.tool.argument_count';
63
63
 
64
64
  export const ATTR_MCP_SERVER_NAME = 'mcp.server.name';
65
65
  export const ATTR_MCP_SERVER_VERSION = 'mcp.server.version';
66
+
67
+ /**
68
+ * NOT part of the MCP semantic conventions. Our own addition, on the
69
+ * mcp.tool.duration histogram (see src/metrics.js): which of the three
70
+ * call outcomes a given duration measurement belongs to. The spec's
71
+ * mcp.server.operation.duration metric (not yet implemented here — see
72
+ * README roadmap) expresses failure only via error.type; this attribute
73
+ * additionally distinguishes "thrown/protocol error" from "silent failure"
74
+ * (isError: true) so both are visible on the same histogram without
75
+ * requiring a join against error.type, which silent failures don't set
76
+ * on the duration metric.
77
+ */
78
+ export const ATTR_MCP_TOOL_OUTCOME = 'mcp.tool.outcome';
79
+
80
+ /** Well-known mcp.tool.outcome value: the call succeeded. */
81
+ export const MCP_TOOL_OUTCOME_SUCCESS = 'success';
82
+
83
+ /** Well-known mcp.tool.outcome value: the handler threw or its promise rejected. */
84
+ export const MCP_TOOL_OUTCOME_ERROR = 'error';
85
+
86
+ /** Well-known mcp.tool.outcome value: isError: true (see ERROR_TYPE_TOOL_ERROR above). */
87
+ export const MCP_TOOL_OUTCOME_SILENT_FAILURE = 'silent_failure';
package/src/config.js CHANGED
@@ -16,12 +16,23 @@ import { diag } from '@opentelemetry/api';
16
16
  * Only takes effect when `setupNodeSdk` is true.
17
17
  * @property {boolean} [enabled=true] - Set to false to disable instrumentation entirely; instrumentMcpServer()
18
18
  * becomes a no-op.
19
+ * @property {boolean} [enableMetrics=true] - Set to false to disable the mcp.tool.* metrics (tracing is
20
+ * unaffected). Metrics are already a zero-overhead no-op when no MeterProvider is registered — the default
21
+ * @opentelemetry/api behavior — so this flag exists for opting out even when one *is* registered, not as a
22
+ * substitute for that default.
19
23
  * @property {boolean} [setupNodeSdk=false] - When true, instrumentMcpServer() creates and registers its own
20
24
  * NodeTracerProvider (always exporting to stderr — safe alongside stdio-transport MCP servers, see ADR 003;
21
25
  * additionally to `exporterUrl` via OTLP/HTTP if set). When false (the default), spans are emitted via
22
26
  * whatever OpenTelemetry TracerProvider the host application
23
27
  * has already registered globally — or dropped silently if none has been registered. This default keeps
24
28
  * instrumentMcpServer() from ever overriding a host application's own OpenTelemetry setup.
29
+ * @property {boolean} [fingerprinting=true] - Set to false to disable deep-failure fingerprinting. When enabled
30
+ * (the default), every thrown error and tool-level failure (isError: true) is run through
31
+ * src/fingerprint/compose.js's computeFingerprint(), adding mcp.failure.* span attributes and an
32
+ * mcp.failure.category attribute on the mcp.tool.errors / mcp.tool.silent_failures / mcp.tool.duration
33
+ * metrics (see src/fingerprint/attributes.js). computeFingerprint() never throws, so this only trades a
34
+ * small amount of per-failure CPU (see the p99 < 200µs budget in test/fingerprint/benchmark.test.js) for
35
+ * fingerprinting.
25
36
  */
26
37
 
27
38
  // Guards the "serviceName has no effect" diagnostic below so it fires once
@@ -66,6 +77,8 @@ export function resolveOptions(options) {
66
77
  serviceName: opts.serviceName,
67
78
  exporterUrl: opts.exporterUrl,
68
79
  enabled: opts.enabled ?? true,
80
+ enableMetrics: opts.enableMetrics ?? true,
69
81
  setupNodeSdk,
82
+ fingerprinting: opts.fingerprinting ?? true,
70
83
  };
71
84
  }