opentel-mcp 0.3.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/README.md CHANGED
@@ -1,19 +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 and
6
- > metrics.
3
+ > Turn every MCP tool call into an OpenTelemetry trace — including the
4
+ > failures your logs won't show you.
7
5
 
8
6
  [![CI](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
9
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)
10
9
  [![license](https://img.shields.io/npm/l/opentel-mcp.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/LICENSE)
11
10
 
12
- ## 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.
13
16
 
14
- `0.x` the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
15
- this library implements are Development-stage and may change. Breaking
16
- 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.
17
28
 
18
29
  ## Install
19
30
 
@@ -21,141 +32,121 @@ attribute changes will land in minor versions until `1.0`.
21
32
  npm install opentel-mcp @opentelemetry/api
22
33
  ```
23
34
 
24
- `opentel-mcp` is an ES module. Your project needs `"type": "module"` in
25
- its package.json, or you must use `.mjs` file extensions.
26
-
27
- ## Why
35
+ opentel-mcp is an ES module add `"type": "module"` to package.json.
28
36
 
29
- When Claude Code calls 15 MCP tools across 3 servers, you have zero
30
- visibility into which was slow, which errored silently, which sequence
31
- ran. opentel-mcp wraps any MCP server and emits one OTel span per tool
32
- invocation with rich attributes, using standard OpenTelemetry APIs so
33
- it plugs into your existing observability stack (Jaeger, Grafana Tempo,
34
- Honeycomb, Datadog, whatever).
37
+ ## 30-second quickstart
35
38
 
36
- ## What you get
39
+ ```js
40
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
41
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
42
+ import { instrumentMcpServer } from 'opentel-mcp';
43
+ import { z } from 'zod';
37
44
 
38
- MCP tool calls are invisible by default. When an agent turn takes 2.5
39
- seconds, you have no idea which tool caused it. opentel-mcp turns every
40
- tool call into an OpenTelemetry span you can read.
45
+ const server = new McpServer({ name: 'my-server', version: '1.0.0' });
41
46
 
42
- ### Example trace
47
+ // Wraps every tool registered below. Must run BEFORE server.tool() —
48
+ // see "Ordering constraint" below for why.
49
+ instrumentMcpServer(server, {
50
+ serviceName: 'my-mcp-server', // shows up on your traces
51
+ setupNodeSdk: true, // dev mode: prints traces to your terminal
52
+ });
43
53
 
44
- One agent turn, captured in Jaeger (spans generated by opentel-mcp,
45
- exported over OTLP):
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
+ }));
46
58
 
47
- ```
48
- agent_turn ............................. 2470ms OK
49
- ├─ tools/call search_documents ........... 46ms OK
50
- ├─ tools/call fetch_weather ............. 601ms OK
51
- └─ tools/call generate_advice .......... 1802ms OK ← the bottleneck
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
52
61
  ```
53
62
 
54
- Without tracing you'd know only that the turn took ~2.5s. With it, the
55
- waterfall shows `generate_advice` is where the time goes —
56
- `search_documents` and `fetch_weather` are cheap by comparison. That's
57
- the difference between guessing and knowing.
63
+ That's it. Every tool call now emits a trace. Wire an exporter to see them
64
+ (next section).
58
65
 
59
- ### Tool-level failures
66
+ ## See it working
60
67
 
61
- An MCP tool can fail two ways: it can throw, or it can return
62
- `isError: true` on an otherwise-successful response. The second is how a
63
- tool reports a failure to the agent without crashing the server — and
64
- most instrumentation records it as a success, because the call
65
- technically succeeded.
66
-
67
- opentel-mcp catches it:
68
+ Run the snippet above and this prints to your terminal a real, captured
69
+ run (full dump: `examples/hello-mcpserver/README.md`):
68
70
 
69
71
  ```
70
- tools/call fetch_weather ................. 605ms ERROR
71
- error.type = tool_error
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
+ }
72
81
  ```
73
82
 
74
- The span is marked ERROR with `error.type = tool_error`, the result is
75
- returned to the agent unchanged, and nothing is thrown. Your error rate
76
- reflects reality instead of reading as a flat 100% success.
83
+ No dashboard needed `setupNodeSdk: true`'s dev exporter printed this
84
+ directly. Point it at a real backend later; see "Two modes" below.
77
85
 
78
- ### Span attributes
86
+ ---
79
87
 
80
- Every span follows the OpenTelemetry MCP semantic conventions, so it
81
- drops into existing dashboards (Jaeger, Grafana Tempo, Honeycomb, SigNoz)
82
- with no custom mapping:
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.
83
91
 
84
- | Attribute | Example value |
85
- |---|---|
86
- | `mcp.method.name` | `tools/call` |
87
- | `gen_ai.tool.name` | `generate_advice` |
88
- | `gen_ai.operation.name` | `execute_tool` |
89
- | `jsonrpc.request.id` | `3` |
90
- | `mcp.tool.argument_count` | `2` |
92
+ ## Both server APIs
91
93
 
92
- Span name follows the spec's `{method} {target}` form (e.g.
93
- `tools/call generate_advice`), and span kind is `SERVER`.
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).
94
97
 
95
- ## Quickstart (5-line usage)
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.
96
101
 
97
- Works with either the low-level `Server` API:
102
+ **`Server`** the low-level API, for when you're handling raw JSON-RPC
103
+ yourself or building a library on top of MCP:
98
104
 
99
105
  ```js
100
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';
101
109
  import { instrumentMcpServer } from 'opentel-mcp';
102
110
 
103
- const server = new Server({ name: 'my-server', version: '1.0.0' }, {
104
- capabilities: { tools: {} }
105
- });
111
+ const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
106
112
 
107
- instrumentMcpServer(server, {
108
- serviceName: 'my-mcp-server', // required because setupNodeSdk is true below
109
- setupNodeSdk: true, // dev-friendly stderr output; omit in prod
110
- // if you already have OTel configured
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}` }] };
111
119
  });
112
120
 
113
- // ...register tools as usual...
114
- server.setRequestHandler(CallToolRequestSchema, async (request) => { /*...*/ });
121
+ const transport = new StdioServerTransport();
122
+ await server.connect(transport);
115
123
  ```
116
124
 
117
- ...or the high-level `McpServer` API most servers actually use:
125
+ Runnable versions of both live in `examples/hello-server/` and
126
+ `examples/hello-mcpserver/`.
118
127
 
119
- ```js
120
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
121
- import { instrumentMcpServer } from 'opentel-mcp';
128
+ ## What gets emitted
122
129
 
123
- const server = new McpServer({ name: 'my-server', version: '1.0.0' });
130
+ ### Tool-level failures, specifically
124
131
 
125
- instrumentMcpServer(server, {
126
- serviceName: 'my-mcp-server', // required because setupNodeSdk is true below
127
- setupNodeSdk: true,
128
- });
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:
129
136
 
130
- // ...register tools as usual...
131
- server.tool('my-tool', async (args) => { /*...*/ });
137
+ ```
138
+ tools/call fetch_weather ................. 605ms ERROR
139
+ error.type = tool_error
132
140
  ```
133
141
 
134
- Either way, `instrumentMcpServer()` must run before any tool is
135
- registered — see "Ordering constraint" below.
136
-
137
- ## Semantic conventions
138
-
139
- opentel-mcp follows the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
140
- published by the OTel GenAI SIG (they moved there from the main
141
- `semantic-conventions` repo, where the MCP conventions are now marked
142
- deprecated). **That spec's status is Development, not Stable** — attribute
143
- names and requirement levels may still change upstream, and this package
144
- will follow suit when they do. See ADR 004 in `docs/adr/` for the full
145
- reasoning.
146
-
147
- One attribute, `mcp.tool.argument_count`, is **not** part of the spec — it's
148
- our own addition, documented as such in `src/attributes.js`. It's a
149
- privacy-preserving alternative to the spec's opt-in
150
- `gen_ai.tool.call.arguments`: it gives shape/anomaly signal (argument count
151
- changed) without capturing any argument values.
142
+ Verified in `test/instrument.test.js`'s "tool-level failure" tests.
152
143
 
153
- ## Span attributes emitted
144
+ ### Span attributes
154
145
 
155
- Span (server-side `tools/call` handling) follows the spec's MCP server
156
- span: name `{mcp.method.name} {tool name}` (e.g. `tools/call echo`, falling
157
- back to just `mcp.method.name` when no tool name is available), kind
158
- `SERVER`, status `ERROR` whenever `error.type` is set.
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.
159
150
 
160
151
  | Attribute | Requirement Level | Description | Example |
161
152
  |---|---|---|---|
@@ -167,46 +158,33 @@ back to just `mcp.method.name` when no tool name is available), kind
167
158
  | mcp.tool.argument_count | **Custom — not spec** | Number of arguments (values not captured) | 2 |
168
159
 
169
160
  Span status description carries the error message on failure (thrown
170
- errors); no separate error-message attribute is emitted — the spec
171
- 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`.
172
164
 
173
- ## Metrics
165
+ ### Metrics
174
166
 
175
- Alongside spans, opentel-mcp emits four `mcp.tool.*` metrics via
176
- `@opentelemetry/api`'s Metrics API — same API-only pattern as tracing (see
177
- "Two modes" below): no SDK or exporter is bundled, and recording is a
178
- zero-overhead no-op until the host application registers a
179
- `MeterProvider`. Set `enableMetrics: false` to opt out even when one is
180
- registered; tracing is unaffected either way.
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`.
181
172
 
182
- | Instrument | Type | Unit | Attributes | Emitted when |
173
+ | Metric | Type | Unit | Attributes | Emitted when |
183
174
  |---|---|---|---|---|
184
175
  | `mcp.tool.calls` | Counter | — | `gen_ai.tool.name`, `mcp.method.name` | Every tool call |
185
- | `mcp.tool.errors` | Counter | — | `gen_ai.tool.name`, `error.type` | The handler threw or its promise rejected |
186
- | `mcp.tool.silent_failures` | Counter | — | `gen_ai.tool.name` | The JSON-RPC response succeeded but `CallToolResult.isError` was `true` |
187
- | `mcp.tool.duration` | Histogram | `ms` | `gen_ai.tool.name`, `mcp.tool.outcome` (`success` \| `error` \| `silent_failure`) | Every tool call, on completion |
188
-
189
- `mcp.tool.silent_failures` is incremented from the exact same `isError`
190
- check that marks the span ERROR (see `isToolResultError()` in
191
- `src/instrument.js`) the detection logic isn't duplicated between traces
192
- and metrics. `gen_ai.tool.name`, `mcp.method.name`, and `error.type` are
193
- the same spec-aligned attribute names the spans already use (see "Span
194
- attributes emitted" above); `mcp.tool.outcome` is a custom (non-spec)
195
- attribute, documented in `src/attributes.js`, that lets a single duration
196
- histogram distinguish thrown/protocol errors from silent failures without
197
- joining against `error.type`, which silent failures don't set on this
198
- metric.
199
-
200
- ### Wiring a MeterProvider and TracerProvider (example: SigNoz)
201
-
202
- opentel-mcp never creates a `MeterProvider` itself (except the stderr/dev
203
- `setupNodeSdk: true` tracing path, which doesn't touch metrics) — register
204
- one the same way you would for any OTel-instrumented Node app, before
205
- calling `instrumentMcpServer()`. The same is true of the `TracerProvider`
206
- in bring-your-own-SDK mode (`setupNodeSdk: false`, the default): without
207
- registering one, `trace.getTracer()` inside opentel-mcp resolves to the
208
- `@opentelemetry/api` no-op default and spans are silently dropped, even
209
- though `mcp.tool.*` metrics keep flowing:
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:
210
188
 
211
189
  ```js
212
190
  import { metrics, trace } from '@opentelemetry/api';
@@ -236,51 +214,147 @@ const tracerProvider = new NodeTracerProvider({
236
214
  });
237
215
  tracerProvider.register();
238
216
 
239
- const server = new Server({ name: 'my-server', version: '1.0.0' }, {
240
- capabilities: { tools: {} },
241
- });
217
+ const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
242
218
 
243
- instrumentMcpServer(server, {
244
- // setupNodeSdk: false (default) this example brings its own SDK for
245
- // both signals: the MeterProvider above for metrics, and the
246
- // NodeTracerProvider above — registered globally via
247
- // tracerProvider.register(), which is what trace.getTracer() picks up —
248
- // for traces.
249
- });
219
+ // setupNodeSdk: false (default) — both providers above are already
220
+ // registered globally, so instrumentMcpServer() picks them up as-is.
221
+ instrumentMcpServer(server, {});
250
222
  ```
251
223
 
252
- `http://localhost:4318` is SigNoz's default local OTLP/HTTP endpoint (both
253
- `/v1/metrics` and `/v1/traces`); point it at your collector/SigNoz
254
- ingestion endpoint in production. `@opentelemetry/sdk-metrics` and
255
- `@opentelemetry/exporter-metrics-otlp-http` are peer/host-app
256
- dependencies — opentel-mcp doesn't bundle them (see the peerDependencies
257
- note in `package.json`). `@opentelemetry/sdk-trace-node` and
258
- `@opentelemetry/exporter-trace-otlp-http`, by contrast, are already
259
- runtime dependencies of opentel-mcp itself (used by its `setupNodeSdk: true`
260
- dev path), so no extra install is needed to use them here.
261
-
262
- ## Two modes
263
-
264
- **One-line (dev):** `setupNodeSdk: true` sets up a NodeTracerProvider
265
- that prints spans to stderr (safe alongside stdio-transport MCP servers
266
- see ADR 003), optionally + an OTLP exporter if `exporterUrl` is provided.
267
- No separate OTel SDK setup needed — `serviceName` is required in this
268
- mode, since it names the resource of the provider opentel-mcp creates.
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 |
269
304
 
270
- **Bring-your-own-SDK (prod):** Omit `setupNodeSdk` (default false).
271
- opentel-mcp uses whatever tracer provider you've already registered
272
- via `trace.setGlobalTracerProvider()`. This means it plugs into any
273
- existing OTel setup without conflict. In this mode the host's
274
- TracerProvider owns the resource, so `serviceName` is not needed and has
275
- no effect — set `service.name` on the host's Resource instead. Passing
276
- `serviceName` anyway is harmless but logs a one-time `diag.warn`.
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`.
277
308
 
278
309
  ## Ordering constraint
279
310
 
280
- 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 —
281
317
  before `server.setRequestHandler(CallToolRequestSchema, ...)` (low-level
282
318
  `Server`) or before any `.tool()`/`.registerTool()` call (`McpServer`).
283
- 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/`.
284
358
 
285
359
  ## Compatibility
286
360
 
@@ -290,16 +364,24 @@ See ADR 002 in docs/adr/ for why.
290
364
  - Supports both low-level `Server` and high-level `McpServer` APIs
291
365
  - @modelcontextprotocol/sdk ^1.0.0
292
366
  - @opentelemetry/api ^1.9.0
367
+ - 127 tests (`npm test`) — see `test/`
293
368
 
294
369
  ## Roadmap
295
370
 
296
- - Shipped in v0.3: `mcp.tool.*` metrics (see "Metrics" above). Opt-in
297
- `gen_ai.tool.call.arguments` support with a redaction callback, and the
298
- spec's own `mcp.server.operation.duration` / `mcp.server.session.duration`
299
- metrics, remain unimplemented tracked here, not silently dropped.
300
- - Next: W3C trace context propagation via `params._meta` per
301
- [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta),
302
- 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
303
385
  call and the server's tool execution
304
386
 
305
387
  ## Contributing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.3.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",
@@ -23,6 +23,8 @@
23
23
  "scripts": {
24
24
  "test": "vitest run",
25
25
  "test:watch": "vitest",
26
+ "test:coverage": "vitest run --coverage",
27
+ "bench": "vitest bench --run",
26
28
  "prepack": "node scripts/strip-workspaces.js",
27
29
  "postpack": "node scripts/restore-workspaces.js"
28
30
  },
@@ -59,6 +61,7 @@
59
61
  "@opentelemetry/api": "^1.9.0",
60
62
  "@opentelemetry/sdk-metrics": "^2.9.0",
61
63
  "@opentelemetry/sdk-trace-base": "^2.9.0",
64
+ "@vitest/coverage-v8": "^2.1.9",
62
65
  "typescript": "^7.0.2",
63
66
  "vitest": "^2.1.8"
64
67
  }
package/src/config.js CHANGED
@@ -26,6 +26,13 @@ import { diag } from '@opentelemetry/api';
26
26
  * whatever OpenTelemetry TracerProvider the host application
27
27
  * has already registered globally — or dropped silently if none has been registered. This default keeps
28
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.
29
36
  */
30
37
 
31
38
  // Guards the "serviceName has no effect" diagnostic below so it fires once
@@ -72,5 +79,6 @@ export function resolveOptions(options) {
72
79
  enabled: opts.enabled ?? true,
73
80
  enableMetrics: opts.enableMetrics ?? true,
74
81
  setupNodeSdk,
82
+ fingerprinting: opts.fingerprinting ?? true,
75
83
  };
76
84
  }