opentel-mcp 0.15.0 → 0.16.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 CHANGED
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+
5
+ Finishes what 0.15 started. Telemetry now survives the way MCP clients
6
+ actually stop servers (`flushOnExit`), and two new signals land: errors
7
+ your agent can't act on, and opt-in resources/prompts coverage. Plus a fix
8
+ for SDK v2 servers that declare `capabilities.tools`.
9
+ `setupNodeSdk: false` (production) behavior is unchanged apart from the
10
+ new span attributes on `isError: true` results and the v2 fix; resources/
11
+ prompts coverage is off unless you opt in. No new metric label keys.
12
+
13
+ ### Added
14
+
15
+ - **`flushOnExit` (ADR 024, `docs/adr/024-flush-on-exit.md`).** Under
16
+ `setupNodeSdk: true`, buffered spans and dev-mode metrics are now
17
+ flushed automatically when the process stops: on `beforeExit` (stdin
18
+ closed, loop drained), `SIGTERM` and `SIGINT`. MCP clients built on the
19
+ SDK's stdio transport stop servers with exactly that sequence (stdin
20
+ close → SIGTERM → SIGKILL, 2 s apart); the old `process.on('beforeExit')`
21
+ snippet only covered the first step. On by default with
22
+ `setupNodeSdk: true`; `flushOnExit: false` opts out;
23
+ `flushOnExit: { timeoutMs }` lowers the 1000 ms cap.
24
+ - One listener set per process, however many servers are instrumented,
25
+ and it's removed again once every server has shut down.
26
+ - After flushing, a signal is re-raised when no other listener for it
27
+ exists, so the process still dies by that signal. If your code has its
28
+ own listener, yours decides. `beforeExit` never changes the exit code.
29
+ - **No effect at all with `setupNodeSdk: false`** (production
30
+ behavior unchanged): passing it there logs a one-time `diag.warn`.
31
+ - Not covered: `process.exit()` (call `await server.shutdown()` first)
32
+ and `SIGKILL`.
33
+
34
+ - **Unactionable tool errors (ADR 025, `docs/adr/025-unactionable-errors.md`).**
35
+ Every `isError: true` span now carries `mcp.failure.unactionable`
36
+ (boolean) and `mcp.failure.content_length_bucket` (`empty` | `tiny` |
37
+ `short` | `medium` | `long`). Unactionable means no content, or under
38
+ 10 characters of trimmed text with no image/audio/resource item: an
39
+ error that gives the agent nothing to act on. Computed only from text
40
+ lengths and non-text item counts, never from the text; a canary-string
41
+ test checks every exported span and metric. Span-only (no metric label,
42
+ `METRIC_SAFE_ATTRIBUTES` unchanged); fingerprints and categories
43
+ unchanged. Configure with `unactionableErrors: { enabled, minTextLength }`
44
+ or `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` /
45
+ `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH`.
46
+
47
+ - **Resources and prompts coverage, opt-in (ADR 026,
48
+ `docs/adr/026-resources-prompts-coverage.md`).**
49
+ `coverage: { resources: true, prompts: true }` also traces
50
+ `resources/read`, `resources/list`, `resources/templates/list`,
51
+ `prompts/get` and `prompts/list`, on SDK v1 and v2, low-level `Server`
52
+ and `McpServer`. Both are off by default; nothing changes unless you opt in.
53
+ - Spans are named by method (`prompts/get <name>` for prompts/get) and
54
+ carry `mcp.method.name` (+ `gen_ai.prompt.name` on prompts/get, capped
55
+ at 128 characters). `gen_ai.tool.name` is absent.
56
+ - Never captured: resource URIs, prompt arguments, result content, or
57
+ the exception message of a failing request (SDK resource errors embed
58
+ the URI). Failures get status `ERROR`, `error.type` and the usual
59
+ fingerprint/category/channel.
60
+ - New histogram `mcp.server.operation.duration`, labeled by
61
+ `mcp.method.name` + `error.type` only. No new label keys;
62
+ `mcp.method.name` stays a fixed set of constants (now 7 values).
63
+ - A handler already registered when `instrumentMcpServer()` runs is
64
+ skipped with one `diag.warn`, never a throw. That includes SDK v2
65
+ `McpServer` declaring `capabilities.resources/prompts` in its
66
+ constructor.
67
+
68
+ ### Changed
69
+
70
+ - `server.shutdown()` (under `setupNodeSdk: true`) is now idempotent:
71
+ every call returns the same promise, so an explicit `shutdown()` and
72
+ `flushOnExit` never shut the providers down twice.
73
+ - The README quickstart and both examples no longer need the
74
+ `process.on('beforeExit', ...)` block; it's been removed.
75
+
76
+ ### Fixed
77
+
78
+ - **SDK v2: a `McpServer` constructed with `capabilities: { tools: {} }`
79
+ can now be instrumented.** v2's `McpServer` installs its own
80
+ `tools/list`/`tools/call` dispatchers in its constructor when that
81
+ capability is declared, so `instrumentMcpServer()` threw
82
+ `INSTRUMENT_FIRST_ERROR` on every such server, whatever the call order.
83
+ It now recognizes McpServer's own constructor-installed handlers (v2
84
+ McpServer, installer ran, no tool registered yet, not yet connected),
85
+ takes them down with the public `removeRequestHandler()`, and lets
86
+ McpServer re-install them through the instrumented `setRequestHandler`,
87
+ so they're wrapped like any lazily-installed handler. Genuine misorders
88
+ still throw: a tool registered before instrumenting (v1 or v2, with or
89
+ without declared capabilities) or a low-level `Server` handler
90
+ registered first. Present since v2 support shipped (SDK 2.0.0).
91
+
3
92
  ## 0.15.0
4
93
 
5
94
  "Make it visible" — no instrumentation changes, no `tools/call` hot-path
package/README.md CHANGED
@@ -79,7 +79,7 @@ const server = new McpServer({ name: 'my-server', version: '1.0.0' });
79
79
 
80
80
  // Wraps every tool registered below. Must run BEFORE server.tool() —
81
81
  // see "Ordering constraint" below for why.
82
- const instrumented = instrumentMcpServer(server, {
82
+ instrumentMcpServer(server, {
83
83
  serviceName: 'my-mcp-server', // shows up on your traces
84
84
  setupNodeSdk: true, // dev mode: prints traces to your terminal
85
85
  });
@@ -91,26 +91,25 @@ server.tool('echo', { text: z.string() }, async ({ text }) => ({
91
91
 
92
92
  const transport = new StdioServerTransport();
93
93
  await server.connect(transport);
94
-
95
- // Only needed to see metrics (below) immediately when piping ONE request
96
- // in and exiting right after, like the "See it working" example does —
97
- // a real, long-running server sees the same lines on their own, every 5
98
- // seconds, with no extra code.
99
- let flushed = false;
100
- process.on('beforeExit', () => {
101
- if (flushed) return;
102
- flushed = true;
103
- instrumented.shutdown();
104
- });
105
94
  ```
106
95
 
107
96
  That's it. Every tool call now emits a trace. Wire an exporter to see them
108
- (next section).
97
+ (next section). When the server stops — your MCP client closing stdin, or
98
+ sending SIGTERM/SIGINT — anything still buffered is flushed first,
99
+ automatically (see "Flushing on exit" below).
109
100
 
110
101
  ## See it working
111
102
 
112
- Run the snippet above and this prints to your terminal — a real, captured
113
- run (full dump: `examples/hello-mcpserver/README.md`):
103
+ Save the snippet as `server.js`. On its own it just waits: an MCP server
104
+ reads requests from stdin and does nothing until a client sends one. To
105
+ try it without a client, pipe in a single tool call:
106
+
107
+ ```bash
108
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' | node server.js
109
+ ```
110
+
111
+ The JSON-RPC response goes to stdout, and this prints to stderr — a real,
112
+ captured run (full dump: `examples/hello-mcpserver/README.md`):
114
113
 
115
114
  ```
116
115
  name: 'tools/call echo'
@@ -127,10 +126,12 @@ attributes: {
127
126
  No dashboard needed — `setupNodeSdk: true`'s dev exporter printed this
128
127
  directly. Point it at a real backend later; see "Two modes" below.
129
128
 
130
- **Metrics print too (v0.15.0+)** — within 5 seconds (not the default
131
- 60-second export interval), a compact one-line-per-metric summary follows
129
+ **Metrics print too (v0.15.0+)** — a compact one-line-per-metric summary
132
130
  on stderr, reusing the exact `mcp.tool.*` instruments documented in
133
- "Metrics" below:
131
+ "Metrics" below. A long-running server prints it every 5 seconds (not the
132
+ default 60-second export interval). The one-shot piped run above prints it
133
+ once as it exits, because the server flushes when stdin closes (v0.16.0,
134
+ see "Flushing on exit"):
134
135
 
135
136
  ```
136
137
  [opentel-mcp metrics] mcp.tool.calls{gen_ai.tool.name=echo,mcp.method.name=tools/call} = 1
@@ -315,6 +316,60 @@ error.type = tool_error
315
316
 
316
317
  Verified in `test/instrument.test.js`'s "tool-level failure" tests.
317
318
 
319
+ #### Errors your agent can't act on (v0.16.0)
320
+
321
+ An `isError: true` result with no content, or just `"Error"`, tells the
322
+ model nothing about what went wrong or what to do next — a common cause
323
+ of retries and thrash. Every `isError: true` span also carries:
324
+
325
+ | Attribute | Values |
326
+ | --- | --- |
327
+ | `mcp.failure.unactionable` | `true` when there's no content, or fewer than 10 characters of text (whitespace-trimmed) and no image/audio/resource item |
328
+ | `mcp.failure.content_length_bucket` | `empty` (0) · `tiny` (1–9) · `short` (< 80) · `medium` (< 500) · `long` |
329
+
330
+ Both are computed **only from lengths and item counts — never from the
331
+ text itself** (proved by a canary-string test in
332
+ `test/unactionable.test.js`), on spans only, never as metric labels. They
333
+ don't change fingerprints or categories. Configure with
334
+ `unactionableErrors: { enabled, minTextLength }` (defaults `true`, `10`).
335
+ Design: [ADR 025](../../docs/adr/025-unactionable-errors.md).
336
+
337
+ ### Resources and prompts (opt-in, v0.16.0)
338
+
339
+ Tools are traced by default. `resources/*` and `prompts/*` requests can be
340
+ traced too, if you turn them on:
341
+
342
+ ```js
343
+ instrumentMcpServer(server, {
344
+ coverage: { resources: true, prompts: true }, // either or both
345
+ });
346
+ ```
347
+
348
+ | Method | Span name | Attributes |
349
+ | --- | --- | --- |
350
+ | `resources/read`, `resources/list`, `resources/templates/list` | the method, e.g. `resources/read` | `mcp.method.name` |
351
+ | `prompts/get` | `prompts/get <prompt name>` | `mcp.method.name`, `gen_ai.prompt.name` |
352
+ | `prompts/list` | `prompts/list` | `mcp.method.name` |
353
+
354
+ - **Never captured:** resource URIs, prompt arguments, result content. A
355
+ failing request gets status `ERROR`, `error.type`, and the usual
356
+ fingerprint/category/channel (`mcp.failure.*`), but **not** the exception
357
+ message, because SDK resource errors embed the URI in it.
358
+ - `gen_ai.tool.name` is absent (not empty) on these spans, so they never
359
+ show up in per-tool views or tool metrics.
360
+ - Durations go to a separate histogram, `mcp.server.operation.duration`,
361
+ labeled only by `mcp.method.name` (one of the five methods above) and
362
+ `error.type`. No thrash detection or cost tracking applies.
363
+ - **Ordering:** instrument before registering resources/prompts, same as
364
+ tools. If a handler for one of these methods already exists, that method
365
+ is skipped with a single `diag.warn`. It never throws, so turning this on
366
+ can't break an existing server. On an SDK v2 `McpServer`, declaring
367
+ `capabilities: { resources: {}, prompts: {} }` in the constructor
368
+ registers those handlers immediately (and so skips them); leave them out,
369
+ since registering a resource or prompt declares the capability for you.
370
+
371
+ Design: [ADR 026](../../docs/adr/026-resources-prompts-coverage.md).
372
+
318
373
  ### Span attributes
319
374
 
320
375
  Every span follows the OpenTelemetry MCP semantic conventions (see
@@ -449,8 +504,30 @@ What it does, under `setupNodeSdk: true` and `enableMetrics` (default
449
504
  already registered, exactly as it does today.
450
505
  - Extends `server.shutdown()` (already attached under `setupNodeSdk: true`)
451
506
  to also flush and shut down the `MeterProvider` it registered, alongside
452
- the existing `TracerProvider` shutdown — call it during your process's
453
- shutdown sequence, same as before, to avoid losing buffered metric data.
507
+ the existing `TracerProvider` shutdown. As of v0.16.0 this runs
508
+ automatically when the process stops — see "Flushing on exit" below.
509
+
510
+ ### Flushing on exit (`flushOnExit`, v0.16.0)
511
+
512
+ Under `setupNodeSdk: true`, buffered spans and dev-mode metrics are
513
+ flushed automatically when the process stops. It's on by default; pass
514
+ `flushOnExit: false` to turn it off. MCP clients built on the SDK's stdio
515
+ transport stop a server by closing stdin, then sending SIGTERM 2 seconds
516
+ later, then SIGKILL; this covers the first two:
517
+
518
+ | Event | What happens |
519
+ | --- | --- |
520
+ | stdin closes and nothing else keeps the process alive | flushed on `beforeExit`; the process exits with its own exit code |
521
+ | `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 |
522
+ | `process.exit()` | **not covered**: call `await server.shutdown()` before exiting |
523
+ | `SIGKILL` | can't be intercepted |
524
+
525
+ The flush waits at most 1000 ms (`flushOnExit: { timeoutMs }` can only
526
+ lower that), so a stuck exporter can't keep a stopping server alive.
527
+ `server.shutdown()` is safe to call yourself as well: it runs once, however
528
+ many times it's called. With `setupNodeSdk: false` this option does
529
+ nothing — your application owns its providers and its shutdown. Design:
530
+ [ADR 024](../../docs/adr/024-flush-on-exit.md).
454
531
 
455
532
  `@opentelemetry/sdk-metrics` is a real (non-dev) dependency of opentel-mcp
456
533
  as of this feature — not an optional peer, not a dynamic import with a
@@ -2241,6 +2318,7 @@ so they're spelled out instead of forced into one bucket.
2241
2318
  | `mcp.tool.pricing_status` | span attr, metric label | **blend — library-computed, depends on tool + host** | which of three enum values depends on a tool-controlled model name resolving (or not) in a library-default-or-host-overridden pricing table |
2242
2319
  | `mcp.failure.fingerprint` / `.signature` / `.category` / `.origin` / `.channel` | span attr (2 also metric labels) | library-computed | hashes/enums — the governed core this project has kept clean since v0.4.0 |
2243
2320
  | `mcp.failure.error_class` | span attr | **blend — tool-controlled, capped only** | `err.name`, length-capped at 128 characters but, unlike everything hashed into the fingerprint, *not* pattern-scrubbed — a documented, deliberate tradeoff (`docs/known-gaps.md` entry 10) |
2321
+ | `mcp.failure.unactionable` / `.content_length_bucket` | span attr | library-computed | a boolean and a 5-value enum derived from the trimmed text LENGTH and non-text item COUNT of an `isError: true` result — no text is read for anything but its length (ADR 025, v0.16.0) |
2244
2322
  | `mcp.failure.validation_paths` | span attr | mostly library-computed (gated) | one legacy Zod-issue-rendering path can still carry a tool-controlled dynamic object key, now redacted to a `<KEY>` placeholder rather than closed structurally (`docs/known-gaps.md` entry 10, item 3) |
2245
2323
  | `exception.type` (event) | event attr | tool-controlled | length-capped only under `'normalized'` mode; uncapped under `'full'` (the native SDK call) |
2246
2324
  | `exception.message` / `.stacktrace` (event), status message | event attr + status | **blend — layered** | raw tool content under `'full'`; this library's `normalizeMessage()`/`parseAndNormalizeStack()` transform of that same content under `'normalized'`; optionally a host-supplied `errorRecording.redactor`'s output feeding *that*, first (v0.14.0) — the most layered-provenance attribute in the system |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -73,7 +73,8 @@
73
73
  "@opentelemetry/sdk-trace-node": "^2.9.0"
74
74
  },
75
75
  "devDependencies": {
76
- "@modelcontextprotocol/server": "^2.0.0",
76
+ "@modelcontextprotocol/sdk": "1.32.1",
77
+ "@modelcontextprotocol/server": "2.3.1",
77
78
  "@opentelemetry/api": "^1.9.0",
78
79
  "@opentelemetry/context-async-hooks": "^2.9.0",
79
80
  "@opentelemetry/sdk-trace-base": "^2.9.0",
package/src/attributes.js CHANGED
@@ -76,6 +76,29 @@ export const MCP_METHOD_NAME_TOOLS_CALL = 'tools/call';
76
76
  /** Well-known mcp.method.name value for a tools/list request (ADR 010, schema drift detection). */
77
77
  export const MCP_METHOD_NAME_TOOLS_LIST = 'tools/list';
78
78
 
79
+ // ADR 026 (v0.16.0, opt-in `coverage`): the five resource/prompt methods.
80
+ // mcp.method.name only ever takes these fixed string constants, never a
81
+ // value read from a request, so its value space stays bounded (7 total).
82
+
83
+ /** Well-known mcp.method.name value for a resources/read request (ADR 026). */
84
+ export const MCP_METHOD_NAME_RESOURCES_READ = 'resources/read';
85
+ /** Well-known mcp.method.name value for a resources/list request (ADR 026). */
86
+ export const MCP_METHOD_NAME_RESOURCES_LIST = 'resources/list';
87
+ /** Well-known mcp.method.name value for a resources/templates/list request (ADR 026). */
88
+ export const MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST = 'resources/templates/list';
89
+ /** Well-known mcp.method.name value for a prompts/get request (ADR 026). */
90
+ export const MCP_METHOD_NAME_PROMPTS_GET = 'prompts/get';
91
+ /** Well-known mcp.method.name value for a prompts/list request (ADR 026). */
92
+ export const MCP_METHOD_NAME_PROMPTS_LIST = 'prompts/list';
93
+
94
+ /**
95
+ * gen_ai.prompt.name — the prompt a prompts/get span requested (ADR 026).
96
+ * Span attribute only, never a metric label. Capped at
97
+ * MAX_PROMPT_NAME_LENGTH, since the value comes from the request.
98
+ */
99
+ export const ATTR_GEN_AI_PROMPT_NAME = 'gen_ai.prompt.name';
100
+ export const MAX_PROMPT_NAME_LENGTH = 128;
101
+
79
102
  // --- Custom (non-spec) attributes ---
80
103
 
81
104
  /**
package/src/config.js CHANGED
@@ -10,6 +10,8 @@ import { normalizeModelName } from './cost/calculator.js';
10
10
  import { resolveThrashConfig } from './thrash/config.js';
11
11
  import { resolveSchemaDriftConfig } from './schema-drift/config.js';
12
12
  import { resolveErrorRecordingConfig } from './error-recording/config.js';
13
+ import { resolveFlushTimeout } from './flush-on-exit.js';
14
+ import { resolveUnactionableErrorsConfig } from './unactionable.js';
13
15
 
14
16
  /**
15
17
  * @typedef {object} CostTrackingOptions
@@ -98,6 +100,31 @@ import { resolveErrorRecordingConfig } from './error-recording/config.js';
98
100
  * variable (lower precedence than this option), same OTEL_MCP_<FEATURE>_ prefix pattern as
99
101
  * OTEL_MCP_THRASH_ and OTEL_MCP_SCHEMA_DRIFT_; an unrecognized value from either source falls back to
100
102
  * 'full' silently, never a throw.
103
+ * @property {boolean | { timeoutMs?: number }} [flushOnExit] - ADR 024 (docs/adr/024-flush-on-exit.md):
104
+ * flush the providers this library created when the process stops — on `beforeExit`, `SIGTERM` and `SIGINT`
105
+ * — so spans and dev-mode metrics still buffered aren't lost. Only meaningful with `setupNodeSdk: true`,
106
+ * where it defaults to ON; pass `false` to opt out. With `setupNodeSdk: false` (the default) it never has
107
+ * any effect: the host owns its providers and its shutdown, and passing it logs a one-time `diag.warn`.
108
+ * A signal is re-raised after the flush when the host has no listener of its own for it, so the process
109
+ * still dies by that signal; with a host listener, the host decides. The flush waits at most `timeoutMs`
110
+ * (default and maximum 1000 ms). Not covered: `process.exit()` (call `await server.shutdown()` first) and
111
+ * `SIGKILL`.
112
+ * @property {Partial<import('./unactionable.js').UnactionableErrorsConfig>} [unactionableErrors] - ADR 025
113
+ * (docs/adr/025-unactionable-errors.md): on every `isError: true` result, sets the span attributes
114
+ * `mcp.failure.unactionable` (boolean) and `mcp.failure.content_length_bucket` (`empty` | `tiny` | `short` |
115
+ * `medium` | `long`), computed only from the whitespace-trimmed length of the text items and the count of
116
+ * non-text items — never from the text itself. Unactionable = no content, or under `minTextLength`
117
+ * (default 10) characters of text with no image/audio/resource item. `{ enabled: true, minTextLength: 10 }`
118
+ * by default; independent of `fingerprinting`. Span-only: never a metric label. Also settable via
119
+ * `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` / `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH` (lower precedence).
120
+ * @property {{ resources?: boolean, prompts?: boolean }} [coverage] - ADR 026 (docs/adr/026-resources-prompts-coverage.md),
121
+ * opt-in, both off by default: also trace `resources/read`, `resources/list`, `resources/templates/list`
122
+ * (`resources: true`) and `prompts/get`, `prompts/list` (`prompts: true`). Spans are named by method
123
+ * (`prompts/get <name>` for prompts/get), carry `mcp.method.name` and never `gen_ai.tool.name`; resource
124
+ * URIs and prompt arguments are never captured. A handler for one of these methods that's already
125
+ * registered when instrumentMcpServer() runs is skipped (not wrapped) with a single `diag.warn` — never a
126
+ * throw. Durations go to the `mcp.server.operation.duration` histogram (labels: `mcp.method.name`,
127
+ * `error.type`).
101
128
  * @property {string} [instanceKey] - Host-supplied stable identifier for one logical service (ADR 012,
102
129
  * docs/adr/012-tracker-lifecycle-and-shared-state.md, Option C — Phase 2: this option and its wiring).
103
130
  * When provided, the four in-memory trackers this library keeps per instrumented server — budget
@@ -135,6 +162,41 @@ export function __resetServiceNameWarnedForTests() {
135
162
  warnedServiceNameIgnored = false;
136
163
  }
137
164
 
165
+ // Guards the "flushOnExit has no effect without setupNodeSdk" diagnostic
166
+ // (ADR 024), same once-per-process pattern.
167
+ let warnedFlushOnExitIgnored = false;
168
+
169
+ // Test-only, same purpose as __resetServiceNameWarnedForTests above.
170
+ export function __resetFlushOnExitWarnedForTests() {
171
+ warnedFlushOnExitIgnored = false;
172
+ }
173
+
174
+ /**
175
+ * ADR 024: `false` turns it off; an object supplies `timeoutMs`; anything
176
+ * else (true, undefined, null, junk) means "on, defaults". Only consulted
177
+ * when setupNodeSdk is true.
178
+ *
179
+ * @param {unknown} value
180
+ * @returns {{ timeoutMs: number } | null}
181
+ */
182
+ function resolveFlushOnExit(value) {
183
+ if (value === false) return null;
184
+ const timeoutMs = value !== null && typeof value === 'object' ? /** @type {any} */ (value).timeoutMs : undefined;
185
+ return { timeoutMs: resolveFlushTimeout(timeoutMs) };
186
+ }
187
+
188
+ /**
189
+ * ADR 026: opt-in resources/* and prompts/* coverage. Only a literal `true`
190
+ * turns a family on; anything else (including the default) is off.
191
+ *
192
+ * @param {unknown} value
193
+ * @returns {{ resources: boolean, prompts: boolean }}
194
+ */
195
+ function resolveCoverage(value) {
196
+ const v = value !== null && typeof value === 'object' ? /** @type {any} */ (value) : {};
197
+ return { resources: v.resources === true, prompts: v.prompts === true };
198
+ }
199
+
138
200
  // Guards the DEFAULT_PRICING staleness diagnostic below (ADR 016 point 3),
139
201
  // same one-per-process pattern as warnedServiceNameIgnored above.
140
202
  let warnedPricingStale = false;
@@ -210,6 +272,16 @@ export function resolveOptions(options) {
210
272
  );
211
273
  }
212
274
 
275
+ const flushOnExitRequested = opts.flushOnExit !== undefined && opts.flushOnExit !== false;
276
+ if (!setupNodeSdk && flushOnExitRequested && !warnedFlushOnExitIgnored) {
277
+ warnedFlushOnExitIgnored = true;
278
+ diag.warn(
279
+ 'opentel-mcp: flushOnExit was provided but setupNodeSdk is false, so it has no effect. ' +
280
+ 'The host application owns its TracerProvider/MeterProvider and their shutdown — flush them in your ' +
281
+ "own shutdown sequence. See ADR 024, docs/adr/024-flush-on-exit.md.",
282
+ );
283
+ }
284
+
213
285
  const rawCostTracking = opts.costTracking ?? {};
214
286
  const costTrackingEnabled = rawCostTracking.enabled ?? true;
215
287
 
@@ -275,5 +347,8 @@ export function resolveOptions(options) {
275
347
  schemaDrift: resolveSchemaDriftConfig(opts.schemaDrift),
276
348
  errorRecording: resolveErrorRecordingConfig(opts.errorRecording),
277
349
  instanceKey: resolveInstanceKey(opts.instanceKey, process.env[ENV_INSTANCE_KEY]),
350
+ flushOnExit: setupNodeSdk ? resolveFlushOnExit(opts.flushOnExit) : null,
351
+ unactionableErrors: resolveUnactionableErrorsConfig(opts.unactionableErrors),
352
+ coverage: resolveCoverage(opts.coverage),
278
353
  };
279
354
  }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @module flush-on-exit
3
+ *
4
+ * ADR 024 (docs/adr/024-flush-on-exit.md): flush the providers this
5
+ * library owns (setupNodeSdk: true) when the process is about to stop.
6
+ *
7
+ * MCP clients built on the SDK's StdioClientTransport stop a server by
8
+ * closing stdin, then SIGTERM 2 s later, then SIGKILL 2 s after that
9
+ * (@modelcontextprotocol/sdk client/stdio.js close()). `beforeExit` only
10
+ * covers the first step, and only when nothing else holds the loop open.
11
+ *
12
+ * One listener set per PROCESS, not per instrumented server: per-instance
13
+ * listeners would each see the others as "the host's own handler" and none
14
+ * would ever re-raise. Every instrumented server registers its memoized
15
+ * shutdown() here; the listeners flush them all.
16
+ *
17
+ * - beforeExit: flush once. The process then exits on its own with
18
+ * whatever process.exitCode already was; nothing here changes it. No
19
+ * process.exit() call: a host's own beforeExit work must not be cut short.
20
+ * - SIGTERM / SIGINT: remove our listener for that signal first (so a
21
+ * second signal takes Node's default and kills immediately), flush
22
+ * bounded by the timeout, then re-raise the same signal ONLY if no other
23
+ * listener for it remains. Installing a signal listener removes Node's
24
+ * default "exit on signal"; re-raising with no listeners restores it, so
25
+ * the parent sees the same death-by-signal it would have without us. If
26
+ * the host has its own listener, the host owns the exit.
27
+ * - The flush wait is capped (FLUSH_ON_EXIT_MAX_TIMEOUT_MS) with an
28
+ * unref'd timer, so it never holds the process open by itself.
29
+ *
30
+ * Listeners are removed again once no server is registered (every
31
+ * instrumented server shut down), so an explicitly shut-down process has
32
+ * exactly the signal behavior it had before instrumenting.
33
+ *
34
+ * Never throws: everything that touches `process` is guarded.
35
+ */
36
+
37
+ import { diag } from '@opentelemetry/api';
38
+
39
+ /** Upper bound on how long a stop waits for telemetry to flush (ADR 024). */
40
+ export const FLUSH_ON_EXIT_MAX_TIMEOUT_MS = 1000;
41
+
42
+ const SIGNALS = /** @type {const} */ (['SIGTERM', 'SIGINT']);
43
+
44
+ /** @type {Map<() => Promise<void>, number>} memoized shutdown -> its timeout */
45
+ const registered = new Map();
46
+
47
+ /** @type {{ beforeExit: (() => void) | null, SIGTERM: (() => void) | null, SIGINT: (() => void) | null }} */
48
+ const listeners = { beforeExit: null, SIGTERM: null, SIGINT: null };
49
+
50
+ let warnedInstallFailed = false;
51
+
52
+ /**
53
+ * Clamps a user-supplied timeout into (0, FLUSH_ON_EXIT_MAX_TIMEOUT_MS].
54
+ * Anything that isn't a positive finite number gets the maximum.
55
+ *
56
+ * @param {unknown} value
57
+ * @returns {number}
58
+ */
59
+ export function resolveFlushTimeout(value) {
60
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
61
+ return Math.min(value, FLUSH_ON_EXIT_MAX_TIMEOUT_MS);
62
+ }
63
+ return FLUSH_ON_EXIT_MAX_TIMEOUT_MS;
64
+ }
65
+
66
+ /**
67
+ * Runs every registered shutdown once, in parallel, and resolves when all
68
+ * settle or the longest registered timeout elapses, whichever is first.
69
+ * Never rejects.
70
+ *
71
+ * @returns {Promise<void>}
72
+ */
73
+ export function flushAll() {
74
+ const entries = [...registered];
75
+ registered.clear();
76
+ if (entries.length === 0) return Promise.resolve();
77
+
78
+ const timeoutMs = Math.max(...entries.map(([, ms]) => ms));
79
+ const work = Promise.allSettled(
80
+ entries.map(([shutdown]) => {
81
+ try {
82
+ return Promise.resolve(shutdown());
83
+ } catch (err) {
84
+ return Promise.reject(err);
85
+ }
86
+ }),
87
+ );
88
+
89
+ return new Promise((resolve) => {
90
+ let timer;
91
+ try {
92
+ timer = setTimeout(resolve, timeoutMs);
93
+ timer.unref?.();
94
+ } catch {
95
+ // No timers available: fall back to waiting for the work itself.
96
+ }
97
+ work.then(() => {
98
+ if (timer) clearTimeout(timer);
99
+ resolve();
100
+ });
101
+ });
102
+ }
103
+
104
+ function onBeforeExit() {
105
+ // Fires again once our own flush settles and the loop drains: nothing
106
+ // registered by then, so this returns and the process exits normally.
107
+ if (registered.size === 0) return;
108
+ flushAll().then(uninstallIfIdle, uninstallIfIdle);
109
+ }
110
+
111
+ /** @param {'SIGTERM' | 'SIGINT'} signal */
112
+ function makeSignalListener(signal) {
113
+ const listener = () => {
114
+ try {
115
+ process.removeListener(signal, listener);
116
+ } catch {
117
+ // ignore
118
+ }
119
+ listeners[signal] = null;
120
+ flushAll().then(() => {
121
+ try {
122
+ if (process.listenerCount(signal) === 0) {
123
+ process.kill(process.pid, signal);
124
+ return;
125
+ }
126
+ uninstallIfIdle();
127
+ } catch (err) {
128
+ diag.debug(`opentel-mcp: flushOnExit could not re-raise ${signal}: ${err?.message ?? err}`);
129
+ }
130
+ });
131
+ };
132
+ return listener;
133
+ }
134
+
135
+ function install() {
136
+ try {
137
+ if (!listeners.beforeExit) {
138
+ listeners.beforeExit = onBeforeExit;
139
+ process.on('beforeExit', onBeforeExit);
140
+ }
141
+ for (const signal of SIGNALS) {
142
+ if (!listeners[signal]) {
143
+ const listener = makeSignalListener(signal);
144
+ listeners[signal] = listener;
145
+ process.on(signal, listener);
146
+ }
147
+ }
148
+ return true;
149
+ } catch (err) {
150
+ uninstall();
151
+ if (!warnedInstallFailed) {
152
+ warnedInstallFailed = true;
153
+ diag.warn(
154
+ `opentel-mcp: flushOnExit could not install process listeners (${err?.message ?? err}); ` +
155
+ 'telemetry will not be flushed automatically on exit. Call `await server.shutdown()` yourself.',
156
+ );
157
+ }
158
+ return false;
159
+ }
160
+ }
161
+
162
+ function uninstall() {
163
+ try {
164
+ if (listeners.beforeExit) process.removeListener('beforeExit', listeners.beforeExit);
165
+ for (const signal of SIGNALS) {
166
+ if (listeners[signal]) process.removeListener(signal, listeners[signal]);
167
+ }
168
+ } catch {
169
+ // ignore
170
+ }
171
+ listeners.beforeExit = null;
172
+ listeners.SIGTERM = null;
173
+ listeners.SIGINT = null;
174
+ }
175
+
176
+ function uninstallIfIdle() {
177
+ if (registered.size === 0) uninstall();
178
+ }
179
+
180
+ /**
181
+ * Registers one instrumented server's memoized shutdown(). Returns an
182
+ * unregister function (idempotent). Never throws.
183
+ *
184
+ * @param {() => Promise<void>} shutdown
185
+ * @param {number} timeoutMs already resolved via resolveFlushTimeout()
186
+ * @returns {() => void}
187
+ */
188
+ export function registerFlushOnExit(shutdown, timeoutMs) {
189
+ try {
190
+ registered.set(shutdown, timeoutMs);
191
+ if (!install()) registered.delete(shutdown);
192
+ } catch {
193
+ // never throw
194
+ }
195
+ return () => {
196
+ try {
197
+ registered.delete(shutdown);
198
+ uninstallIfIdle();
199
+ } catch {
200
+ // never throw
201
+ }
202
+ };
203
+ }
204
+
205
+ /** Test-only: drop every registration and listener. Not part of the public API. */
206
+ export function __resetFlushOnExitForTests() {
207
+ registered.clear();
208
+ uninstall();
209
+ warnedInstallFailed = false;
210
+ }
211
+
212
+ /** Test-only: how many servers are registered. Not part of the public API. */
213
+ export function __registeredCountForTests() {
214
+ return registered.size;
215
+ }
package/src/index.d.ts CHANGED
@@ -86,6 +86,65 @@ export interface InstrumentOptions {
86
86
  */
87
87
  setupNodeSdk?: boolean;
88
88
 
89
+ /**
90
+ * ADR 024: flush the providers this library created when the process
91
+ * stops — on `beforeExit`, `SIGTERM` and `SIGINT` — so buffered spans and
92
+ * dev-mode metrics aren't lost. Only meaningful with `setupNodeSdk: true`,
93
+ * where it defaults to on; pass `false` to opt out. With `setupNodeSdk:
94
+ * false` it never has any effect (the host owns its providers), and
95
+ * passing it logs a one-time `diag.warn`.
96
+ *
97
+ * After flushing, a signal is re-raised when no other listener for it
98
+ * exists, so the process still dies by that signal; if the host has its
99
+ * own listener, the host decides. The flush waits at most `timeoutMs`
100
+ * (default and maximum 1000 ms). Not covered: `process.exit()` (call
101
+ * `await server.shutdown()` first) and `SIGKILL`.
102
+ *
103
+ * @default true when `setupNodeSdk` is true
104
+ */
105
+ flushOnExit?: boolean | { timeoutMs?: number };
106
+
107
+ /**
108
+ * ADR 025: on every `isError: true` result, sets the span attributes
109
+ * `mcp.failure.unactionable` (boolean) and
110
+ * `mcp.failure.content_length_bucket` (`'empty' | 'tiny' | 'short' |
111
+ * 'medium' | 'long'`). Computed only from the whitespace-trimmed length of
112
+ * the text items and the count of non-text items, never from the text
113
+ * itself. Unactionable means no content, or fewer than `minTextLength`
114
+ * characters of text with no image/audio/resource item. Span-only (never a
115
+ * metric label); independent of `fingerprinting`. Also settable via
116
+ * `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` /
117
+ * `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH` (lower precedence).
118
+ *
119
+ * @default { enabled: true, minTextLength: 10 }
120
+ */
121
+ /**
122
+ * ADR 026, opt-in (both off by default): also trace `resources/read`,
123
+ * `resources/list`, `resources/templates/list` (`resources: true`) and
124
+ * `prompts/get`, `prompts/list` (`prompts: true`). Spans are named by
125
+ * method (`prompts/get <name>` for prompts/get), carry `mcp.method.name`,
126
+ * and never carry `gen_ai.tool.name`. Resource URIs, prompt arguments and
127
+ * result content are never captured; on a thrown error the span gets
128
+ * status ERROR, `error.type` and (with fingerprinting) the
129
+ * `mcp.failure.*` hash/enums, never the exception message. Durations go
130
+ * to the `mcp.server.operation.duration` histogram (labels:
131
+ * `mcp.method.name`, `error.type`). A handler for one of these methods
132
+ * that's already registered when `instrumentMcpServer()` runs is skipped
133
+ * with a single `diag.warn`, never a throw.
134
+ *
135
+ * @default { resources: false, prompts: false }
136
+ */
137
+ coverage?: {
138
+ resources?: boolean;
139
+ prompts?: boolean;
140
+ };
141
+
142
+ unactionableErrors?: {
143
+ enabled?: boolean;
144
+ /** Integer 0–200. `0` flags only empty (or whitespace-only) errors. @default 10 */
145
+ minTextLength?: number;
146
+ };
147
+
89
148
  /**
90
149
  * Controls the `mcp.tool.tokens.*` / `mcp.tool.model` / `mcp.tool.cost.*` span attributes, the
91
150
  * `mcp.tool.tokens.total` / `mcp.tool.cost.total` metrics, and the optional per-session/per-tool budget
@@ -383,6 +442,10 @@ export function instrumentMcpServer<T extends DuckTypedServer | DuckTypedMcpServ
383
442
  server: T,
384
443
  options?: InstrumentOptions,
385
444
  ): T & {
445
+ /**
446
+ * Present under `setupNodeSdk: true`. Flushes and shuts down the providers
447
+ * this library created. Idempotent: every call returns the same promise.
448
+ */
386
449
  shutdown?: () => Promise<void>;
387
450
  getThrashSummary?: (options?: { topOffendersLimit?: number }) => ThrashSummary;
388
451
  getObservationState?: () => ObservationState;
package/src/instrument.js CHANGED
@@ -31,8 +31,17 @@ import { createSchemaDriftEmitter } from './schema-drift/emitter.js';
31
31
  import { ToolOutcomeCounter } from './observation/tool-outcome-counter.js';
32
32
  import { detectObservationIntegrity } from './observation/integrity.js';
33
33
  import { InstanceRegistry } from './registry/instance-registry.js';
34
+ import { registerFlushOnExit } from './flush-on-exit.js';
35
+ import { applyUnactionableAttributes } from './unactionable.js';
34
36
  import {
35
37
  ATTR_MCP_METHOD_NAME,
38
+ ATTR_GEN_AI_PROMPT_NAME,
39
+ MAX_PROMPT_NAME_LENGTH,
40
+ MCP_METHOD_NAME_RESOURCES_READ,
41
+ MCP_METHOD_NAME_RESOURCES_LIST,
42
+ MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST,
43
+ MCP_METHOD_NAME_PROMPTS_GET,
44
+ MCP_METHOD_NAME_PROMPTS_LIST,
36
45
  ATTR_GEN_AI_TOOL_NAME,
37
46
  ATTR_GEN_AI_OPERATION_NAME,
38
47
  ATTR_GEN_AI_RESPONSE_MODEL,
@@ -390,8 +399,29 @@ export function instrumentMcpServer(input, options) {
390
399
  return input;
391
400
  }
392
401
 
402
+ // v2 McpServer declared with `capabilities.tools` installs its own
403
+ // tools/list + tools/call dispatchers in its constructor, before this
404
+ // function can ever run — see hasConstructorInstalledToolHandlers().
405
+ // Those aren't user handlers that would be bypassed: they're McpServer's
406
+ // own, and McpServer will install identical ones again on request. Take
407
+ // them down here (public removeRequestHandler) so the ordering check
408
+ // below only sees genuine user registrations, and re-install them through
409
+ // the patched setRequestHandler at the end of this function. If anything
410
+ // in between throws, McpServer re-installs them (unwrapped) on its next
411
+ // registerTool(), since its own guard flag is reset too.
412
+ const reinstallToolHandlers = hasConstructorInstalledToolHandlers(server, outer, kind);
413
+ if (reinstallToolHandlers) {
414
+ server.removeRequestHandler(TOOLS_CALL_METHOD);
415
+ server.removeRequestHandler(TOOLS_LIST_METHOD);
416
+ outer._toolHandlersInitialized = false;
417
+ }
418
+
393
419
  assertInstrumentFirst(server, resolved);
394
420
 
421
+ // ADR 026: opt-in resources/* and prompts/* methods to wrap — minus any
422
+ // already registered (skipped with one diag.warn, never a throw).
423
+ const coveredMethods = resolveCoveredMethods(server, resolved);
424
+
395
425
  const tracer = setupTracer(server, resolved);
396
426
  const metricsRecorder = resolved.enableMetrics ? setupMeter(PACKAGE_VERSION) : null;
397
427
  // One tracker per instrumented server, not per call — session/tool cost
@@ -586,6 +616,7 @@ export function instrumentMcpServer(input, options) {
586
616
  resolved.errorRecording,
587
617
  costAttributionState,
588
618
  errorRecordingState,
619
+ resolved.unactionableErrors,
589
620
  );
590
621
  } else if (schema === v1.ListToolsRequestSchema && schemaDriftDetector) {
591
622
  handler = wrapToolsListHandler(
@@ -598,6 +629,11 @@ export function instrumentMcpServer(input, options) {
598
629
  resolved.errorRecording,
599
630
  errorRecordingState,
600
631
  );
632
+ } else if (coveredMethods.size > 0) {
633
+ const method = v1CoveredMethodForSchema(v1, schema);
634
+ if (method && coveredMethods.has(method)) {
635
+ handler = wrapNonToolHandler(handler, method, tracer, metricsRecorder, resolved.fingerprinting, kind);
636
+ }
601
637
  }
602
638
  return originalSetRequestHandler(schema, handler);
603
639
  };
@@ -640,6 +676,7 @@ export function instrumentMcpServer(input, options) {
640
676
  resolved.errorRecording,
641
677
  costAttributionState,
642
678
  errorRecordingState,
679
+ resolved.unactionableErrors,
643
680
  );
644
681
  } else if (maybeHandler === undefined && method === TOOLS_LIST_METHOD && schemaDriftDetector) {
645
682
  handlerOrSchemas = wrapToolsListHandler(
@@ -653,17 +690,202 @@ export function instrumentMcpServer(input, options) {
653
690
  errorRecordingState,
654
691
  );
655
692
  }
693
+ if (maybeHandler === undefined && coveredMethods.has(method)) {
694
+ handlerOrSchemas = wrapNonToolHandler(handlerOrSchemas, method, tracer, metricsRecorder, resolved.fingerprinting, kind);
695
+ }
656
696
  return maybeHandler === undefined
657
697
  ? originalSetRequestHandler(method, handlerOrSchemas)
658
698
  : originalSetRequestHandler(method, handlerOrSchemas, maybeHandler);
659
699
  };
660
700
  }
661
701
 
702
+ if (reinstallToolHandlers) {
703
+ // McpServer's own (TS-private) installer, guarded by the flag reset
704
+ // above: re-registers its tools/list + tools/call dispatchers, this time
705
+ // through the patched setRequestHandler, so they come back wrapped.
706
+ outer.setToolRequestHandlers();
707
+ }
708
+
662
709
  server[kInstrumented] = true;
663
710
  if (outer) outer[kInstrumented] = true;
664
711
  return input;
665
712
  }
666
713
 
714
+ /**
715
+ * True when `outer` is a v2 McpServer whose tools/list + tools/call
716
+ * handlers were installed by its own constructor rather than by the user:
717
+ * v2's constructor calls `this.setToolRequestHandlers()` when
718
+ * `capabilities.tools` is declared (@modelcontextprotocol/server 2.x,
719
+ * `mcp-*.mjs`: `if (options?.capabilities?.tools) this.setToolRequestHandlers()`),
720
+ * so with that (common) option the handlers exist before
721
+ * instrumentMcpServer() can be called at all, and ADR 002's check threw on
722
+ * every such server.
723
+ *
724
+ * Deliberately narrow, so every genuine misorder still throws:
725
+ * - v2 McpServer only (v1's McpServer never installs eagerly);
726
+ * - its installer ran (`_toolHandlersInitialized === true`) but no tool has
727
+ * been registered yet (`_registeredTools` empty). A tool registered before
728
+ * instrumenting keeps throwing, same as v1 and same as before;
729
+ * - not yet connected: re-installing calls `registerCapabilities()`, which
730
+ * v2 refuses after connect, so a connected server keeps the old behavior;
731
+ * - every member used is feature-detected. These are McpServer internals
732
+ * (ADR 001/002 avoid them in general); they're read only to recognize
733
+ * McpServer's own handlers, never to reach a handler function. Any
734
+ * mismatch falls back to the previous behavior (ADR 002's throw).
735
+ *
736
+ * Known limit: a user who replaced McpServer's own dispatcher directly on
737
+ * `mcp.server` (setRequestHandler after construction, no tool registered)
738
+ * is indistinguishable from the constructor's handler without reading the
739
+ * private handler map; that handler would be replaced by McpServer's.
740
+ *
741
+ * Never throws.
742
+ *
743
+ * @returns {boolean}
744
+ */
745
+ function hasConstructorInstalledToolHandlers(server, outer, kind) {
746
+ try {
747
+ if (kind !== 'v2' || !outer) return false;
748
+ const registered = outer._registeredTools;
749
+ return (
750
+ outer._toolHandlersInitialized === true &&
751
+ registered !== null &&
752
+ typeof registered === 'object' &&
753
+ Object.keys(registered).length === 0 &&
754
+ typeof outer.setToolRequestHandlers === 'function' &&
755
+ typeof server.removeRequestHandler === 'function' &&
756
+ !server.transport
757
+ );
758
+ } catch {
759
+ return false;
760
+ }
761
+ }
762
+
763
+ const RESOURCE_METHODS = [
764
+ MCP_METHOD_NAME_RESOURCES_READ,
765
+ MCP_METHOD_NAME_RESOURCES_LIST,
766
+ MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST,
767
+ ];
768
+ const PROMPT_METHODS = [MCP_METHOD_NAME_PROMPTS_GET, MCP_METHOD_NAME_PROMPTS_LIST];
769
+
770
+ /**
771
+ * ADR 026: which resources/* and prompts/* methods this instance wraps.
772
+ * Only the families the caller opted into (`coverage`), minus any method
773
+ * whose handler is already registered: wrapping happens at registration
774
+ * (ADR 001), so an existing handler can't be wrapped, and ADR 010 showed
775
+ * that throwing on misorder breaks upgrades. Such methods are skipped with
776
+ * ONE diag.warn naming them — never a throw. Never throws.
777
+ *
778
+ * @returns {Set<string>}
779
+ */
780
+ function resolveCoveredMethods(server, resolved) {
781
+ const covered = new Set();
782
+ try {
783
+ const wanted = [
784
+ ...(resolved.coverage.resources ? RESOURCE_METHODS : []),
785
+ ...(resolved.coverage.prompts ? PROMPT_METHODS : []),
786
+ ];
787
+ const skipped = [];
788
+ for (const method of wanted) {
789
+ if (typeof server.assertCanSetRequestHandler === 'function') {
790
+ try {
791
+ server.assertCanSetRequestHandler(method);
792
+ } catch {
793
+ skipped.push(method);
794
+ continue;
795
+ }
796
+ }
797
+ covered.add(method);
798
+ }
799
+ if (skipped.length > 0) {
800
+ diag.warn(
801
+ `opentel-mcp: coverage requested for ${skipped.join(', ')}, but a handler for ` +
802
+ `${skipped.length === 1 ? 'it' : 'each'} was already registered, so ${skipped.length === 1 ? 'it is' : 'they are'} not traced. ` +
803
+ 'Call instrumentMcpServer() before registering resources/prompts. On an SDK v2 McpServer, ' +
804
+ "don't declare capabilities.resources/prompts in the constructor (that registers the handlers immediately); " +
805
+ 'registering a resource or prompt declares the capability for you. See ADR 026.',
806
+ );
807
+ }
808
+ } catch {
809
+ // never throw
810
+ }
811
+ return covered;
812
+ }
813
+
814
+ /**
815
+ * v1 dispatches by schema object identity (ADR 001); maps a schema to its
816
+ * ADR 026 method name, or undefined. Never throws.
817
+ */
818
+ function v1CoveredMethodForSchema(v1, schema) {
819
+ if (!v1 || !schema) return undefined;
820
+ if (schema === v1.ReadResourceRequestSchema) return MCP_METHOD_NAME_RESOURCES_READ;
821
+ if (schema === v1.ListResourcesRequestSchema) return MCP_METHOD_NAME_RESOURCES_LIST;
822
+ if (schema === v1.ListResourceTemplatesRequestSchema) return MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST;
823
+ if (schema === v1.GetPromptRequestSchema) return MCP_METHOD_NAME_PROMPTS_GET;
824
+ if (schema === v1.ListPromptsRequestSchema) return MCP_METHOD_NAME_PROMPTS_LIST;
825
+ return undefined;
826
+ }
827
+
828
+ /**
829
+ * ADR 026: a SERVER span around one resources/* or prompts/* handler.
830
+ *
831
+ * - Name: the method; `prompts/get <prompt name>` for prompts/get.
832
+ * - `mcp.method.name` always; `gen_ai.prompt.name` on prompts/get (capped);
833
+ * `gen_ai.tool.name` NEVER set — absent, not empty.
834
+ * - Never captured: resource URIs (request params or results), prompt
835
+ * arguments, result content. On a thrown error, only status ERROR,
836
+ * `error.type` (the class name), and — with fingerprinting — the
837
+ * fingerprint hash/category/channel enums. The exception message and
838
+ * stack are deliberately NOT recorded here, whatever errorRecording.mode
839
+ * says: the SDK's own resource errors embed the URI in the message
840
+ * (e.g. `Resource ${uri} not found`).
841
+ * - Duration: mcp.server.operation.duration, labeled by method (+ error.type).
842
+ * - No thrash detection, no cost attribution, no isError handling (these
843
+ * methods have no such result field).
844
+ *
845
+ * The handler's result and errors pass through unchanged.
846
+ */
847
+ function wrapNonToolHandler(handler, method, tracer, metricsRecorder, fingerprintingEnabled, kind) {
848
+ return (request, extra) => {
849
+ let promptName;
850
+ const rawName = request?.params?.name;
851
+ if (method === MCP_METHOD_NAME_PROMPTS_GET && typeof rawName === 'string' && rawName !== '') {
852
+ promptName = rawName.slice(0, MAX_PROMPT_NAME_LENGTH);
853
+ }
854
+ const spanName = promptName ? `${method} ${promptName}` : method;
855
+ const parentContext = extractTraceContext(request?.params?._meta);
856
+
857
+ return tracer.startActiveSpan(spanName, { kind: SpanKind.SERVER }, parentContext, async (span) => {
858
+ span.setAttribute(ATTR_MCP_METHOD_NAME, method);
859
+ if (promptName) span.setAttribute(ATTR_GEN_AI_PROMPT_NAME, promptName);
860
+ const { requestId } = extractSessionAndRequestId(kind, extra);
861
+ if (requestId !== undefined && requestId !== null) {
862
+ span.setAttribute(ATTR_JSONRPC_REQUEST_ID, String(requestId));
863
+ }
864
+
865
+ const startTime = performance.now();
866
+ try {
867
+ const result = await handler(request, extra);
868
+ span.setStatus({ code: SpanStatusCode.OK });
869
+ metricsRecorder?.recordOperationDuration(method, performance.now() - startTime);
870
+ return result;
871
+ } catch (err) {
872
+ span.setStatus({ code: SpanStatusCode.ERROR });
873
+ const errorType = String(err?.name ?? 'Error').slice(0, MAX_ERROR_CLASS_LENGTH);
874
+ span.setAttribute(ATTR_ERROR_TYPE, errorType);
875
+ if (fingerprintingEnabled) {
876
+ const failure = computeFingerprint(err, { toolName: undefined, origin: 'thrown', cwd: process.cwd() });
877
+ span.setAttributes(toSpanAttributes(failure));
878
+ span.setAttribute(ATTRIBUTE_KEYS.CHANNEL, classifyFailureChannel(err));
879
+ }
880
+ metricsRecorder?.recordOperationDuration(method, performance.now() - startTime, errorType);
881
+ throw err;
882
+ } finally {
883
+ span.end();
884
+ }
885
+ });
886
+ };
887
+ }
888
+
667
889
  /**
668
890
  * Throws INSTRUMENT_FIRST_ERROR if a tools/call handler — or, when schema
669
891
  * drift detection is enabled, a tools/list handler — is already
@@ -811,10 +1033,25 @@ function setupTracer(server, resolved) {
811
1033
  }
812
1034
  }
813
1035
 
814
- server.shutdown = async () => {
815
- await provider.shutdown();
816
- if (meterProvider) await meterProvider.shutdown();
1036
+ // Memoized (ADR 024): the first call — from the host, or from
1037
+ // flushOnExit's process listeners — creates the promise; every later
1038
+ // call gets the same one, so the providers are shut down exactly once.
1039
+ let shutdownPromise = null;
1040
+ let unregisterFlushOnExit = null;
1041
+ const shutdown = () => {
1042
+ if (!shutdownPromise) {
1043
+ unregisterFlushOnExit?.();
1044
+ shutdownPromise = (async () => {
1045
+ await provider.shutdown();
1046
+ if (meterProvider) await meterProvider.shutdown();
1047
+ })();
1048
+ }
1049
+ return shutdownPromise;
817
1050
  };
1051
+ server.shutdown = shutdown;
1052
+ if (resolved.flushOnExit) {
1053
+ unregisterFlushOnExit = registerFlushOnExit(shutdown, resolved.flushOnExit.timeoutMs);
1054
+ }
818
1055
  }
819
1056
 
820
1057
  // Always the final step: when setupNodeSdk is false, this picks up
@@ -1712,6 +1949,7 @@ function wrapToolCallHandler(
1712
1949
  errorRecordingConfig,
1713
1950
  costAttributionState,
1714
1951
  errorRecordingState,
1952
+ unactionableErrorsConfig,
1715
1953
  ) {
1716
1954
  return (request, extra) => {
1717
1955
  const toolName = request?.params?.name;
@@ -1772,6 +2010,10 @@ function wrapToolCallHandler(
1772
2010
  if (isToolResultError(result)) {
1773
2011
  span.setAttribute(ATTR_ERROR_TYPE, ERROR_TYPE_TOOL_ERROR);
1774
2012
  span.setStatus({ code: SpanStatusCode.ERROR });
2013
+ // ADR 025: content SHAPE only (lengths, item counts), own
2014
+ // try/catch, before fingerprinting so a classifier problem can't
2015
+ // suppress it. Doesn't feed the fingerprint or the category.
2016
+ applyUnactionableAttributes(span, result, unactionableErrorsConfig);
1775
2017
 
1776
2018
  let failureCategory = '';
1777
2019
  let failure = null;
package/src/metrics.js CHANGED
@@ -76,6 +76,7 @@ import { ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
76
76
  * recordDuration: (toolName: string | undefined, durationMs: number, outcome: string, failureCategory?: string) => void,
77
77
  * recordTokens: (toolName: string | undefined, model: string | undefined, totalTokens: number, pricingStatus: string) => void,
78
78
  * recordCost: (toolName: string | undefined, model: string | undefined, costUsd: number, pricingStatus: string) => void,
79
+ * recordOperationDuration: (method: string, durationMs: number, errorType?: string) => void,
79
80
  * }}
80
81
  */
81
82
  export function setupMeter(packageVersion) {
@@ -103,8 +104,22 @@ export function setupMeter(packageVersion) {
103
104
  description: 'Total estimated cost of MCP tool calls (see src/cost/calculator.js).',
104
105
  unit: 'USD',
105
106
  });
107
+ // ADR 026 (v0.16.0): resources/* and prompts/* handler durations, only
108
+ // recorded when that opt-in coverage is on. Labeled by mcp.method.name
109
+ // (one of five fixed constants) and, on failure, error.type: no new
110
+ // label keys, and nothing request-derived (no URI, no prompt name).
111
+ const operationDuration = meter.createHistogram('mcp.server.operation.duration', {
112
+ description: 'Duration of MCP resources/* and prompts/* request handling (opt-in coverage, ADR 026).',
113
+ unit: 'ms',
114
+ });
106
115
 
107
116
  return {
117
+ recordOperationDuration(method, durationMs, errorType) {
118
+ operationDuration.record(durationMs, {
119
+ [ATTR_MCP_METHOD_NAME]: method,
120
+ ...(errorType ? { [ATTR_ERROR_TYPE]: errorType } : {}),
121
+ });
122
+ },
108
123
  recordCall(toolName) {
109
124
  calls.add(1, {
110
125
  [ATTR_GEN_AI_TOOL_NAME]: toolName,
package/src/sdk/detect.js CHANGED
@@ -74,13 +74,20 @@ const [v1ServerMod, v1TypesMod, v2Mod] = await Promise.all([
74
74
  tryImport('@modelcontextprotocol/server'),
75
75
  ]);
76
76
 
77
- /** @type {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown } | null} */
77
+ /** @type {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown, [schema: string]: unknown } | null} */
78
78
  const v1Sdk =
79
79
  v1ServerMod && v1TypesMod
80
80
  ? {
81
81
  Server: v1ServerMod.Server,
82
82
  CallToolRequestSchema: v1TypesMod.CallToolRequestSchema,
83
83
  ListToolsRequestSchema: v1TypesMod.ListToolsRequestSchema,
84
+ // ADR 026 (opt-in coverage): v1 dispatches by schema identity, so
85
+ // each resource/prompt method needs its schema object too.
86
+ ReadResourceRequestSchema: v1TypesMod.ReadResourceRequestSchema,
87
+ ListResourcesRequestSchema: v1TypesMod.ListResourcesRequestSchema,
88
+ ListResourceTemplatesRequestSchema: v1TypesMod.ListResourceTemplatesRequestSchema,
89
+ GetPromptRequestSchema: v1TypesMod.GetPromptRequestSchema,
90
+ ListPromptsRequestSchema: v1TypesMod.ListPromptsRequestSchema,
84
91
  }
85
92
  : null;
86
93
 
@@ -101,7 +108,7 @@ const v2Sdk = v2Mod ? { Server: v2Mod.Server } : null;
101
108
  * Resolves `@modelcontextprotocol/sdk` (v1). `null` when not installed (an
102
109
  * optional peer dependency — see this module's docblock).
103
110
  *
104
- * @returns {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown } | null}
111
+ * @returns {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown, [schema: string]: unknown } | null}
105
112
  */
106
113
  export function getV1Sdk() {
107
114
  return v1Sdk;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @module unactionable
3
+ *
4
+ * ADR 025 (docs/adr/025-unactionable-errors.md): flag tool errors that give
5
+ * the agent nothing to act on — `isError: true` with empty or near-empty
6
+ * content.
7
+ *
8
+ * PRIVACY: computed only from the SHAPE of `result.content` — how many
9
+ * items are text vs. non-text, and the whitespace-trimmed LENGTH of the
10
+ * text items. Strings are read only to be measured; no text is stored,
11
+ * hashed, compared, or emitted. The two attributes produced have fixed,
12
+ * tiny value domains (a boolean, and one of five bucket names).
13
+ *
14
+ * Span-only. Neither attribute is a metric label (ADR 025 "Metric"); they
15
+ * are deliberately NOT in any METRIC_SAFE_ATTRIBUTES list.
16
+ */
17
+
18
+ /** Boolean: the tool error gave the agent nothing to act on. */
19
+ export const ATTR_MCP_FAILURE_UNACTIONABLE = 'mcp.failure.unactionable';
20
+
21
+ /** One of CONTENT_LENGTH_BUCKETS: the trimmed text length of the error, bucketed. */
22
+ export const ATTR_MCP_FAILURE_CONTENT_LENGTH_BUCKET = 'mcp.failure.content_length_bucket';
23
+
24
+ /** The only values `mcp.failure.content_length_bucket` ever takes. */
25
+ export const CONTENT_LENGTH_BUCKETS = Object.freeze(['empty', 'tiny', 'short', 'medium', 'long']);
26
+
27
+ const SHORT_LIMIT = 80;
28
+ const MEDIUM_LIMIT = 500;
29
+
30
+ const ENV_PREFIX = 'OTEL_MCP_UNACTIONABLE_ERRORS_';
31
+ const DEFAULTS = Object.freeze({ enabled: true, minTextLength: 10 });
32
+ const MAX_MIN_TEXT_LENGTH = 200;
33
+
34
+ /**
35
+ * @typedef {object} UnactionableErrorsConfig
36
+ * @property {boolean} enabled
37
+ * @property {number} minTextLength
38
+ */
39
+
40
+ function readEnvBoolean(name) {
41
+ const raw = process.env[name];
42
+ if (raw === 'true' || raw === '1') return true;
43
+ if (raw === 'false' || raw === '0') return false;
44
+ return undefined;
45
+ }
46
+
47
+ function readEnvInt(name) {
48
+ const raw = process.env[name];
49
+ if (raw === undefined || raw.trim() === '') return undefined;
50
+ const parsed = Number(raw);
51
+ return Number.isInteger(parsed) ? parsed : undefined;
52
+ }
53
+
54
+ /**
55
+ * Same partial-overrides-individual-defaults behavior, and the same
56
+ * OTEL_MCP_<FEATURE>_ env-var fallback, as resolveSchemaDriftConfig().
57
+ * Invalid values fall back to the defaults; never throws.
58
+ *
59
+ * @param {Partial<UnactionableErrorsConfig> | undefined} partial
60
+ * @returns {UnactionableErrorsConfig}
61
+ */
62
+ export function resolveUnactionableErrorsConfig(partial) {
63
+ const p = partial !== null && typeof partial === 'object' ? partial : {};
64
+ const enabled = p.enabled !== undefined ? p.enabled : readEnvBoolean(`${ENV_PREFIX}ENABLED`);
65
+ const minTextLength = p.minTextLength !== undefined ? p.minTextLength : readEnvInt(`${ENV_PREFIX}MIN_TEXT_LENGTH`);
66
+ return {
67
+ enabled: typeof enabled === 'boolean' ? enabled : DEFAULTS.enabled,
68
+ minTextLength:
69
+ typeof minTextLength === 'number' &&
70
+ Number.isInteger(minTextLength) &&
71
+ minTextLength >= 0 &&
72
+ minTextLength <= MAX_MIN_TEXT_LENGTH
73
+ ? minTextLength
74
+ : DEFAULTS.minTextLength,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * @param {unknown} result a CallToolResult with isError: true
80
+ * @param {number} minTextLength
81
+ * @returns {{ unactionable: boolean, bucket: string }}
82
+ */
83
+ export function assessToolErrorContent(result, minTextLength) {
84
+ const content = /** @type {any} */ (result)?.content;
85
+ let textLength = 0;
86
+ let nonText = 0;
87
+
88
+ if (Array.isArray(content)) {
89
+ for (const item of content) {
90
+ if (item === null || typeof item !== 'object') continue;
91
+ if (item.type === 'text') {
92
+ if (typeof item.text === 'string') textLength += item.text.trim().length;
93
+ } else {
94
+ // image, audio, resource, resource_link, or anything else typed:
95
+ // the agent was handed something other than prose.
96
+ nonText++;
97
+ }
98
+ }
99
+ }
100
+
101
+ let bucket;
102
+ if (textLength === 0) bucket = 'empty';
103
+ else if (textLength < minTextLength) bucket = 'tiny';
104
+ else if (textLength < SHORT_LIMIT) bucket = 'short';
105
+ else if (textLength < MEDIUM_LIMIT) bucket = 'medium';
106
+ else bucket = 'long';
107
+
108
+ const unactionable = nonText === 0 && (textLength === 0 || textLength < minTextLength);
109
+ return { unactionable, bucket };
110
+ }
111
+
112
+ /**
113
+ * Sets both attributes on `span` for an isError: true result. Never throws:
114
+ * on any failure both attributes are omitted rather than guessed.
115
+ *
116
+ * @param {import('@opentelemetry/api').Span} span
117
+ * @param {unknown} result
118
+ * @param {UnactionableErrorsConfig} config
119
+ */
120
+ export function applyUnactionableAttributes(span, result, config) {
121
+ try {
122
+ if (!config?.enabled) return;
123
+ const { unactionable, bucket } = assessToolErrorContent(result, config.minTextLength);
124
+ span.setAttribute(ATTR_MCP_FAILURE_UNACTIONABLE, unactionable);
125
+ span.setAttribute(ATTR_MCP_FAILURE_CONTENT_LENGTH_BUCKET, bucket);
126
+ } catch {
127
+ // Never throw into the tool-call path (ADR 025 "Order").
128
+ }
129
+ }