opentel-mcp 0.15.0 → 0.17.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,185 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ Staleness detection: a read tool that returns data older than its own
6
+ freshness window now shows up, even though the call succeeded. Suggested by
7
+ the r/mcp community. Opt-in per tool; tools that don't report freshness
8
+ are untouched. No new metric label keys.
9
+
10
+ ### Added
11
+
12
+ - **Staleness detection (ADR 027, `docs/adr/027-staleness-detection.md`).**
13
+ A tool reports freshness in its result's `_meta` under
14
+ `FRESHNESS_META_KEY` (`'io.github.thirumalaiboobathi.opentel-mcp/freshness'`,
15
+ a reverse-DNS key per the MCP spec): `{ asOf, maxAgeMs }` or
16
+ `{ asOf, freshUntil }` (both allowed; the earlier deadline wins).
17
+ `asOf`/`freshUntil` are epoch milliseconds or ISO 8601 strings with a
18
+ timezone. Every successful `tools/call` carrying it gets:
19
+ - span attributes `mcp.tool.freshness.verdict` (`fresh` | `stale` |
20
+ `unknown`), `.data_age_ms`, `.max_age_ms`, `.limit_source`
21
+ (`max_age_ms` | `fresh_until` | `config`) and, for `unknown`,
22
+ `.unknown_reason` (`invalid_as_of` | `future_as_of` | `missing_window`
23
+ | `invalid_window`);
24
+ - `+1` on the new `mcp.tool.stale_responses` counter (label:
25
+ `gen_ai.tool.name` only) when stale.
26
+ - **Stale is not an error:** span status stays `OK`, no `error.type`,
27
+ never a silent failure, ToolOutcome stays `SUCCESS`.
28
+ - Responses without the key get no verdict and no attributes.
29
+ `isError: true` responses are skipped.
30
+ - Malformed input (epoch seconds, timezone-less ISO strings, negative
31
+ windows, `freshUntil` before `asOf`, `asOf` more than 5 s in the
32
+ future, throwing getters…) never throws: it yields `unknown` with a
33
+ reason and at most one `diag.warn` per tool.
34
+ - Only `asOf`, `maxAgeMs` and `freshUntil` under that one key are read;
35
+ content and other `_meta` fields are never read for this, and raw
36
+ values never reach spans, metrics or logs (canary-tested).
37
+ - **`staleness` option.** `staleness.tools[toolName].maxAgeMs` overrides a
38
+ tool's own window (config can't supply `asOf`); `staleness.enabled:
39
+ false` turns the feature off. On by default, which is a no-op for tools
40
+ that don't report freshness.
41
+ - **Types:** `FRESHNESS_META_KEY`, `FreshnessMeta` (autocomplete for the
42
+ `_meta` value), `FreshnessTimestamp`, `FreshnessVerdict`, and
43
+ `InstrumentOptions.staleness`.
44
+
45
+ ### Security
46
+
47
+ - **Credentials are scrubbed from recorded exceptions, in every mode.**
48
+ Bearer and Basic tokens, AWS access key ids, `sk-`/`sk-proj-`/`sk-ant-`,
49
+ Stripe, `ghp_`/`github_pat_`, Slack `xox…` and Google `AIza…` keys, JWTs,
50
+ passwords in URLs (`https://user:<password>@host`), and the values of keys like
51
+ `token=`, `api_key=`, `access_token=`, `password=`, `client_secret` are
52
+ replaced with `<SECRET>`. Ordinary words and ids (`token expired`, UUIDs,
53
+ commit hashes) are left alone.
54
+ - **Behavior change: the default `errorRecording.mode: 'full'` is no
55
+ longer raw and uncapped.** It still records the error's type, message and
56
+ stack, keeping ids, paths, URLs and line numbers, but with credentials
57
+ scrubbed as above, the message capped at 4096 characters and the stack at
58
+ 16384 (a ` …[truncated by opentel-mcp]` marker shows where).
59
+ `exception.type` is capped at 128 characters, as in `'normalized'` mode.
60
+ Ordinary errors are recorded exactly as before. There is no switch back
61
+ to unscrubbed output. To record *less*, use `'normalized'`, `'none'`, or
62
+ a `redactor`.
63
+ - **Fingerprints change only for errors that contain a credential.** The
64
+ same patterns now run first in `'normalized'` mode and in fingerprinting,
65
+ so two errors that differ only by a leaked token now share one
66
+ fingerprint (before, each token value made its own). Expect a one-time
67
+ regrouping of those errors only.
68
+ - A thrown value whose properties throw when read (e.g. a throwing
69
+ `message` getter) now still marks the span `ERROR`; before, the span was
70
+ left with no status.
71
+ - New test: one canary test runs every feature at once (fingerprinting,
72
+ cost and budget, thrash, schema drift, unactionable errors,
73
+ resources/prompts coverage, staleness) on SDK v1 and v2, `Server` and
74
+ `McpServer`, and checks that no tool argument, result content, `_meta`
75
+ value, resource URI, prompt argument or request `_meta` reaches any span,
76
+ metric or diag log. Nothing leaked; no code change was needed.
77
+ - New `SECURITY.md` at the repository root: private vulnerability reporting
78
+ and what the library does and doesn't record.
79
+
80
+ ### Changed
81
+
82
+ - The cost extractor and staleness detection now read `result._meta`
83
+ through one shared accessor (`src/result-meta.js`). Extraction behavior is
84
+ unchanged.
85
+ - Dev-mode metrics (`setupNodeSdk: true`) print 13 instruments, up from 12.
86
+ - README examples and `examples/hello-mcpserver` use `registerTool()`
87
+ instead of the deprecated `server.tool()`.
88
+ - README's configuration table now lists `unactionableErrors`, `coverage`
89
+ and `staleness`; the misplaced `unactionableErrors` doc comment in
90
+ `index.d.ts` is back on its own option.
91
+ - Test: the flush-on-exit "capped at 1000 ms" check now allows up to 5 s
92
+ (half the uncapped 10 s), so a loaded CI runner can't fail it.
93
+
94
+ ## 0.16.0
95
+
96
+ Finishes what 0.15 started. Telemetry now survives the way MCP clients
97
+ actually stop servers (`flushOnExit`), and two new signals land: errors
98
+ your agent can't act on, and opt-in resources/prompts coverage. Plus a fix
99
+ for SDK v2 servers that declare `capabilities.tools`.
100
+ `setupNodeSdk: false` (production) behavior is unchanged apart from the
101
+ new span attributes on `isError: true` results and the v2 fix; resources/
102
+ prompts coverage is off unless you opt in. No new metric label keys.
103
+
104
+ ### Added
105
+
106
+ - **`flushOnExit` (ADR 024, `docs/adr/024-flush-on-exit.md`).** Under
107
+ `setupNodeSdk: true`, buffered spans and dev-mode metrics are now
108
+ flushed automatically when the process stops: on `beforeExit` (stdin
109
+ closed, loop drained), `SIGTERM` and `SIGINT`. MCP clients built on the
110
+ SDK's stdio transport stop servers with exactly that sequence (stdin
111
+ close → SIGTERM → SIGKILL, 2 s apart); the old `process.on('beforeExit')`
112
+ snippet only covered the first step. On by default with
113
+ `setupNodeSdk: true`; `flushOnExit: false` opts out;
114
+ `flushOnExit: { timeoutMs }` lowers the 1000 ms cap.
115
+ - One listener set per process, however many servers are instrumented,
116
+ and it's removed again once every server has shut down.
117
+ - After flushing, a signal is re-raised when no other listener for it
118
+ exists, so the process still dies by that signal. If your code has its
119
+ own listener, yours decides. `beforeExit` never changes the exit code.
120
+ - **No effect at all with `setupNodeSdk: false`** (production
121
+ behavior unchanged): passing it there logs a one-time `diag.warn`.
122
+ - Not covered: `process.exit()` (call `await server.shutdown()` first)
123
+ and `SIGKILL`.
124
+
125
+ - **Unactionable tool errors (ADR 025, `docs/adr/025-unactionable-errors.md`).**
126
+ Every `isError: true` span now carries `mcp.failure.unactionable`
127
+ (boolean) and `mcp.failure.content_length_bucket` (`empty` | `tiny` |
128
+ `short` | `medium` | `long`). Unactionable means no content, or under
129
+ 10 characters of trimmed text with no image/audio/resource item: an
130
+ error that gives the agent nothing to act on. Computed only from text
131
+ lengths and non-text item counts, never from the text; a canary-string
132
+ test checks every exported span and metric. Span-only (no metric label,
133
+ `METRIC_SAFE_ATTRIBUTES` unchanged); fingerprints and categories
134
+ unchanged. Configure with `unactionableErrors: { enabled, minTextLength }`
135
+ or `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` /
136
+ `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH`.
137
+
138
+ - **Resources and prompts coverage, opt-in (ADR 026,
139
+ `docs/adr/026-resources-prompts-coverage.md`).**
140
+ `coverage: { resources: true, prompts: true }` also traces
141
+ `resources/read`, `resources/list`, `resources/templates/list`,
142
+ `prompts/get` and `prompts/list`, on SDK v1 and v2, low-level `Server`
143
+ and `McpServer`. Both are off by default; nothing changes unless you opt in.
144
+ - Spans are named by method (`prompts/get <name>` for prompts/get) and
145
+ carry `mcp.method.name` (+ `gen_ai.prompt.name` on prompts/get, capped
146
+ at 128 characters). `gen_ai.tool.name` is absent.
147
+ - Never captured: resource URIs, prompt arguments, result content, or
148
+ the exception message of a failing request (SDK resource errors embed
149
+ the URI). Failures get status `ERROR`, `error.type` and the usual
150
+ fingerprint/category/channel.
151
+ - New histogram `mcp.server.operation.duration`, labeled by
152
+ `mcp.method.name` + `error.type` only. No new label keys;
153
+ `mcp.method.name` stays a fixed set of constants (now 7 values).
154
+ - A handler already registered when `instrumentMcpServer()` runs is
155
+ skipped with one `diag.warn`, never a throw. That includes SDK v2
156
+ `McpServer` declaring `capabilities.resources/prompts` in its
157
+ constructor.
158
+
159
+ ### Changed
160
+
161
+ - `server.shutdown()` (under `setupNodeSdk: true`) is now idempotent:
162
+ every call returns the same promise, so an explicit `shutdown()` and
163
+ `flushOnExit` never shut the providers down twice.
164
+ - The README quickstart and both examples no longer need the
165
+ `process.on('beforeExit', ...)` block; it's been removed.
166
+
167
+ ### Fixed
168
+
169
+ - **SDK v2: a `McpServer` constructed with `capabilities: { tools: {} }`
170
+ can now be instrumented.** v2's `McpServer` installs its own
171
+ `tools/list`/`tools/call` dispatchers in its constructor when that
172
+ capability is declared, so `instrumentMcpServer()` threw
173
+ `INSTRUMENT_FIRST_ERROR` on every such server, whatever the call order.
174
+ It now recognizes McpServer's own constructor-installed handlers (v2
175
+ McpServer, installer ran, no tool registered yet, not yet connected),
176
+ takes them down with the public `removeRequestHandler()`, and lets
177
+ McpServer re-install them through the instrumented `setRequestHandler`,
178
+ so they're wrapped like any lazily-installed handler. Genuine misorders
179
+ still throw: a tool registered before instrumenting (v1 or v2, with or
180
+ without declared capabilities) or a low-level `Server` handler
181
+ registered first. Present since v2 support shipped (SDK 2.0.0).
182
+
3
183
  ## 0.15.0
4
184
 
5
185
  "Make it visible" — no instrumentation changes, no `tools/call` hot-path
package/README.md CHANGED
@@ -77,40 +77,39 @@ import { z } from 'zod';
77
77
 
78
78
  const server = new McpServer({ name: 'my-server', version: '1.0.0' });
79
79
 
80
- // Wraps every tool registered below. Must run BEFORE server.tool() —
80
+ // Wraps every tool registered below. Must run BEFORE server.registerTool() —
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
  });
86
86
 
87
87
  // A normal tool, registered exactly as usual.
88
- server.tool('echo', { text: z.string() }, async ({ text }) => ({
88
+ server.registerTool('echo', { inputSchema: { text: z.string() } }, async ({ text }) => ({
89
89
  content: [{ type: 'text', text: `you said: ${text}` }],
90
90
  }));
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
@@ -299,7 +300,7 @@ problem" above) — exactly like the quickstart's `echo` tool, but with
299
300
  `isError: true` added and no `throw` involved:
300
301
 
301
302
  ```js
302
- server.tool('fetch_weather', { city: z.string() }, async ({ city }) => ({
303
+ server.registerTool('fetch_weather', { inputSchema: { city: z.string() } }, async ({ city }) => ({
303
304
  isError: true,
304
305
  content: [{ type: 'text', text: `couldn't reach the weather service for ${city}` }],
305
306
  }));
@@ -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
@@ -344,11 +399,12 @@ The four base `mcp.tool.*` call/duration metrics below, via
344
399
  registered — **except under `setupNodeSdk: true`, which registers one for
345
400
  you (v0.15.0+); see "Dev-mode metrics" below.** Set `enableMetrics: false`
346
401
  to opt out even when one is (dev-mode included); tracing is unaffected
347
- either way. Source of truth: `src/metrics.js`. Twelve metrics exist
348
- across the library in total — this section's four, plus two cost/token
349
- metrics ("Cost & Token Attribution" below), five Agent Thrash Detection
350
- metrics ("Agent Thrash Detection" below), and one schema-drift metric
351
- ("Tool schema drift detection" below).
402
+ either way. Source of truth: `src/metrics.js`. Thirteen `mcp.tool.*`
403
+ metrics exist across the library in total — this section's four, plus two
404
+ cost/token metrics ("Cost & Token Attribution" below), five Agent Thrash
405
+ Detection metrics ("Agent Thrash Detection" below), one schema-drift metric
406
+ ("Tool schema drift detection" below), and `mcp.tool.stale_responses`
407
+ ("Staleness detection" below).
352
408
 
353
409
  | Metric | Type | Unit | Attributes | Emitted when |
354
410
  |---|---|---|---|---|
@@ -449,8 +505,30 @@ What it does, under `setupNodeSdk: true` and `enableMetrics` (default
449
505
  already registered, exactly as it does today.
450
506
  - Extends `server.shutdown()` (already attached under `setupNodeSdk: true`)
451
507
  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.
508
+ the existing `TracerProvider` shutdown. As of v0.16.0 this runs
509
+ automatically when the process stops — see "Flushing on exit" below.
510
+
511
+ ### Flushing on exit (`flushOnExit`, v0.16.0)
512
+
513
+ Under `setupNodeSdk: true`, buffered spans and dev-mode metrics are
514
+ flushed automatically when the process stops. It's on by default; pass
515
+ `flushOnExit: false` to turn it off. MCP clients built on the SDK's stdio
516
+ transport stop a server by closing stdin, then sending SIGTERM 2 seconds
517
+ later, then SIGKILL; this covers the first two:
518
+
519
+ | Event | What happens |
520
+ | --- | --- |
521
+ | stdin closes and nothing else keeps the process alive | flushed on `beforeExit`; the process exits with its own exit code |
522
+ | `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 |
523
+ | `process.exit()` | **not covered**: call `await server.shutdown()` before exiting |
524
+ | `SIGKILL` | can't be intercepted |
525
+
526
+ The flush waits at most 1000 ms (`flushOnExit: { timeoutMs }` can only
527
+ lower that), so a stuck exporter can't keep a stopping server alive.
528
+ `server.shutdown()` is safe to call yourself as well: it runs once, however
529
+ many times it's called. With `setupNodeSdk: false` this option does
530
+ nothing — your application owns its providers and its shutdown. Design:
531
+ [ADR 024](../../docs/adr/024-flush-on-exit.md).
454
532
 
455
533
  `@opentelemetry/sdk-metrics` is a real (non-dev) dependency of opentel-mcp
456
534
  as of this feature — not an optional peer, not a dynamic import with a
@@ -502,6 +580,181 @@ See `dashboards/grafana-mcp-health.json` and `dashboards/dev/` in the repo
502
580
  for a full worked example (dashboard JSON + a script that exercises this
503
581
  exact setup end to end).
504
582
 
583
+ ## Staleness detection (v0.17.0)
584
+
585
+ A read tool can return data older than it should be: a quote from four
586
+ minutes ago, yesterday's bars after today's session opened. The call
587
+ succeeds, the span is green, and the agent acts on stale data with full
588
+ confidence. opentel-mcp can flag that, if the tool says how fresh its data
589
+ is. The tool is the source of truth, because only it knows its own data
590
+ cadence. Config is only an override. (Suggested by the r/mcp community.)
591
+
592
+ **It's opt-in, per tool.** A tool adds one key to its result's `_meta`:
593
+
594
+ ```js
595
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
596
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
597
+ import { instrumentMcpServer, FRESHNESS_META_KEY } from 'opentel-mcp';
598
+ import { z } from 'zod';
599
+
600
+ const server = new McpServer({ name: 'market-data', version: '1.0.0' });
601
+
602
+ // Before any registerTool() call.
603
+ instrumentMcpServer(server, {
604
+ serviceName: 'market-data',
605
+ setupNodeSdk: true, // dev mode: prints spans to stderr
606
+ });
607
+
608
+ // Stand-in for your real feed. This quote is 4 minutes old.
609
+ async function fetchQuote(symbol) {
610
+ return { symbol, price: 101.5, timestamp: Date.now() - 4 * 60_000 };
611
+ }
612
+
613
+ server.registerTool(
614
+ 'get_quote',
615
+ { description: 'Latest quote for a symbol', inputSchema: { symbol: z.string() } },
616
+ async ({ symbol }) => {
617
+ const quote = await fetchQuote(symbol);
618
+ return {
619
+ content: [{ type: 'text', text: JSON.stringify(quote) }],
620
+ _meta: {
621
+ [FRESHNESS_META_KEY]: {
622
+ asOf: quote.timestamp, // when the data is from: epoch ms, or ISO 8601 with a timezone
623
+ maxAgeMs: 5_000, // a quote is fresh for 5 seconds
624
+ },
625
+ },
626
+ };
627
+ },
628
+ );
629
+
630
+ await server.connect(new StdioServerTransport());
631
+ ```
632
+
633
+ Save it as `server.js` and send it one call:
634
+
635
+ ```bash
636
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_quote","arguments":{"symbol":"ACME"}}}' | node server.js
637
+ ```
638
+
639
+ The span on stderr (from a real run) is still `OK`, and now says why the
640
+ answer shouldn't be trusted:
641
+
642
+ ```
643
+ name: 'tools/call get_quote'
644
+ attributes: {
645
+ ...
646
+ 'mcp.tool.freshness.verdict': 'stale',
647
+ 'mcp.tool.freshness.data_age_ms': 240001,
648
+ 'mcp.tool.freshness.max_age_ms': 5000,
649
+ 'mcp.tool.freshness.limit_source': 'max_age_ms'
650
+ },
651
+ status: { code: 1 } // OK: stale data isn't an error
652
+ [opentel-mcp metrics] mcp.tool.stale_responses{gen_ai.tool.name=get_quote} = 1
653
+ ```
654
+
655
+ ### The `_meta` contract
656
+
657
+ `FRESHNESS_META_KEY` is `'io.github.thirumalaiboobathi.opentel-mcp/freshness'`
658
+ (a reverse-DNS key, as the MCP spec recommends for custom `_meta` keys).
659
+ Its value:
660
+
661
+ | Field | Type | Meaning |
662
+ | --- | --- | --- |
663
+ | `asOf` | epoch **milliseconds**, or ISO 8601 string **with** `Z`/`±hh:mm` | When the data is from (the quote's timestamp, the bar's close), not when the tool ran. Required. |
664
+ | `maxAgeMs` | number ≥ 0 | How long the data stays fresh. |
665
+ | `freshUntil` | same as `asOf` | An absolute deadline, for windows that aren't a fixed duration. |
666
+
667
+ Give `asOf` plus `maxAgeMs`, `freshUntil`, or both. With both, the
668
+ **earlier deadline wins**. For data that's good until the trading session
669
+ closes:
670
+
671
+ ```js
672
+ _meta: {
673
+ [FRESHNESS_META_KEY]: {
674
+ asOf: bars.closeTime, // '2026-10-12T20:00:00Z'
675
+ freshUntil: nextSessionClose, // '2026-10-13T20:00:00Z'
676
+ },
677
+ },
678
+ ```
679
+
680
+ TypeScript users get autocomplete from the exported `FreshnessMeta` type:
681
+ `{ asOf, maxAgeMs } satisfies FreshnessMeta`.
682
+
683
+ ### Overriding a tool's window from config
684
+
685
+ ```js
686
+ instrumentMcpServer(server, {
687
+ staleness: {
688
+ tools: {
689
+ get_quote: { maxAgeMs: 2_000 }, // replaces get_quote's own maxAgeMs/freshUntil
690
+ },
691
+ },
692
+ });
693
+ ```
694
+
695
+ The override replaces the tool's window entirely (`limit_source` becomes
696
+ `config`). It can't supply `asOf`: a response without the freshness key
697
+ gets no verdict, whatever the config says. An invalid value (negative,
698
+ non-finite) is ignored with one `diag.warn`. `staleness: { enabled: false }`
699
+ turns the whole feature off.
700
+
701
+ ### What gets emitted
702
+
703
+ | Attribute | Type | When |
704
+ | --- | --- | --- |
705
+ | `mcp.tool.freshness.verdict` | `fresh` \| `stale` \| `unknown` | The tool sent the freshness key |
706
+ | `mcp.tool.freshness.data_age_ms` | integer ms | `asOf` was readable |
707
+ | `mcp.tool.freshness.max_age_ms` | integer ms | A verdict exists: the effective window (deadline minus `asOf`) |
708
+ | `mcp.tool.freshness.limit_source` | `max_age_ms` \| `fresh_until` \| `config` | A verdict exists |
709
+ | `mcp.tool.freshness.unknown_reason` | `invalid_as_of` \| `future_as_of` \| `missing_window` \| `invalid_window` | Verdict is `unknown` |
710
+
711
+ Plus one counter, `mcp.tool.stale_responses` (label: `gen_ai.tool.name`),
712
+ `+1` per stale response.
713
+
714
+ - **Tools that don't report freshness get nothing.** No attribute, no
715
+ verdict; never `fresh`, never `stale`. Their spans are unchanged.
716
+ - **Stale isn't an error.** Span status stays `OK`, there's no
717
+ `error.type`, and it's never counted as a silent failure. Alert on the
718
+ verdict or the counter, not on error rate.
719
+ - **`isError: true` responses are skipped.** They're already failures.
720
+ - **Malformed freshness never breaks a call.** It gets `unknown` with a
721
+ reason, at most one `diag.warn` per tool, and the result goes back to
722
+ the client untouched. Common mistakes it catches: epoch **seconds**
723
+ instead of milliseconds, an ISO string with no timezone, `asOf` more than
724
+ 5 s in the future (up to 5 s is treated as clock skew), and `freshUntil`
725
+ before `asOf`.
726
+ - **Privacy:** only `asOf`, `maxAgeMs` and `freshUntil` under that one key
727
+ are read. Result content and other `_meta` fields are never read for
728
+ this, and raw values are never emitted, only the derived numbers
729
+ (checked by a canary test in `test/staleness.test.js`).
730
+ - **Data age is span-only**, never a metric label.
731
+
732
+ ### Seeing it in the dashboard
733
+
734
+ `opentel-mcp-ui` 0.3.0+ shows the data age in its feed, a **Stale data**
735
+ filter, and stale rate as a fifth health-grade signal. Start it, then point
736
+ the server above at it with one more option:
737
+
738
+ ```bash
739
+ npx opentel-mcp-ui # dashboard at http://127.0.0.1:4319
740
+ ```
741
+
742
+ ```js
743
+ instrumentMcpServer(server, {
744
+ serviceName: 'market-data',
745
+ setupNodeSdk: true,
746
+ exporterUrl: 'http://127.0.0.1:4319/v1/traces', // send spans to the dashboard too
747
+ });
748
+ ```
749
+
750
+ Each run of the `echo … | node server.js` command above now shows up in the
751
+ dashboard's feed and under **Stale data**. The tool gets a grade once it has
752
+ 10 calls, and the stale-data signal joins it once 10 of those carried a
753
+ `fresh` or `stale` verdict, so send the command ten times. With every call
754
+ stale, `get_quote` grades **F**, "set by stale data 100% (F)".
755
+
756
+ Design and every edge case: [ADR 027](../../docs/adr/027-staleness-detection.md).
757
+
505
758
  ## Failure Fingerprinting (v0.4.0+)
506
759
 
507
760
  Groups logically identical failures under one stable identifier, even
@@ -586,7 +839,11 @@ per-call-site wrapping; tracked in the roadmap below.
586
839
  **What this controls, and who needs it.** Whenever a `tools/call`/
587
840
  `tools/list` handler throws, this library records the thrown error's
588
841
  message and stack trace onto the span it creates for that call — by
589
- default, exactly as the tool wrote them, no redaction. If your tools call
842
+ default, as the tool wrote them, except that recognizable credentials
843
+ (Bearer/Basic tokens, AWS key ids, `sk-`/`ghp_`/`xox…` API keys, JWTs,
844
+ passwords in URLs, `token=`/`api_key=`/`password=` values) are replaced
845
+ with `<SECRET>` and the message and stack are capped at 4096 and 16384
846
+ characters (v0.17.0). If your tools call
590
847
  third-party services, wrap unaudited dependencies, or you otherwise don't
591
848
  control every string a tool might throw, that's a real channel for
592
849
  whatever those errors happen to contain (an email address, an internal
@@ -606,7 +863,7 @@ span:
606
863
 
607
864
  | Mode | `exception.message` / status message | `exception.stacktrace` | When to use |
608
865
  |---|---|---|---|
609
- | `'full'` (default) | Raw `err.message`, unmodified | Raw `err.stack`, unmodified | Today's behavior, unchanged — matches what every other OTel-instrumented library in the same trace does for the same kind of event |
866
+ | `'full'` (default) | Raw `err.message`, credentials → `<SECRET>`, capped at 4096 chars (v0.17.0) | Raw `err.stack`, same scrubbing, capped at 16384 chars | Everything else kept: ids, paths, URLs, line numbers. Before v0.17.0 both were unmodified |
610
867
  | `'normalized'` | `normalizeMessage(err.message)` — the exact scrubbing pipeline (`src/fingerprint/normalize/message.js`) fingerprinting already runs before hashing: UUIDs, emails, URLs, IPs, timestamps, filesystem paths, hex runs, quoted ids | Reconstructed from `parseAndNormalizeStack()` (`src/fingerprint/normalize/stack.js`) — keeps every function name/file/line, strips only the local `cwd` prefix (and collapses `node_modules` package versions) | Tool results come from third-party or unaudited MCP servers and you want the same scrubbing fingerprinting already trusts, applied to the raw exception content too |
611
868
  | `'none'` | Not set — `span.setStatus({ code: ERROR })` with no message, the same pattern already used for tool-level `isError: true` failures | Not set | You rely entirely on `mcp.failure.*` (category/fingerprint/signature — already hashed/normalized) and don't want any free-text exception content on the span at all |
612
869
 
@@ -2144,11 +2401,13 @@ found')`, or a tool result puts a credential in a field this library
2144
2401
  reads, that content is exactly what reaches your telemetry backend. Three
2145
2402
  channels carry it:
2146
2403
 
2147
- - **`span.recordException(err)` / `span.setStatus({ message })`** on every
2404
+ - **The `exception` event / `span.setStatus({ message })`** on every
2148
2405
  thrown `tools/call`/`tools/list` error — `exception.message` and
2149
- `exception.stacktrace` are set from `err.message`/`err.stack` verbatim
2150
- by default. `errorRecording.mode` controls this; see "Error recording"
2151
- above.
2406
+ `exception.stacktrace` are set from `err.message`/`err.stack` by
2407
+ default, with recognizable credentials replaced by `<SECRET>` and
2408
+ lengths capped (v0.17.0), but otherwise unchanged: a customer's name or
2409
+ any other free text in a message still reaches the span.
2410
+ `errorRecording.mode` controls this; see "Error recording" above.
2152
2411
  - **`mcp.tool.model` / `gen_ai.response.model`** — a tool result's own
2153
2412
  declared model field, admitted once it passes a length/character-shape
2154
2413
  check (v0.13.0). The check bounds shape, not content: a well-formed but
@@ -2241,9 +2500,11 @@ so they're spelled out instead of forced into one bucket.
2241
2500
  | `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
2501
  | `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
2502
  | `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) |
2503
+ | `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) |
2504
+ | `mcp.tool.freshness.verdict` / `.data_age_ms` / `.max_age_ms` / `.limit_source` / `.unknown_reason` | span attr | library-computed | enums and integer ms derived from the tool's own `_meta[FRESHNESS_META_KEY]` `asOf`/`maxAgeMs`/`freshUntil`; raw values are never emitted, and no other `_meta` field or content is read (ADR 027, v0.17.0) |
2244
2505
  | `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
- | `exception.type` (event) | event attr | tool-controlled | length-capped only under `'normalized'` mode; uncapped under `'full'` (the native SDK call) |
2246
- | `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 |
2506
+ | `exception.type` (event) | event attr | tool-controlled | length-capped at 128 characters under both `'full'` and `'normalized'` (v0.17.0) |
2507
+ | `exception.message` / `.stacktrace` (event), status message | event attr + status | **blend — layered** | raw tool content under `'full'`, minus recognizable credentials and length-capped (v0.17.0); 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 |
2247
2508
  | `mcp.tool.schema_drift_detected` / `mcp.tool.thrash_detected` | boolean span attr | library-computed | only ever set to `true`; omitted on a clean call |
2248
2509
  | `mcp.tool.schema_drift.type` | event attr, metric label | library-computed | classifier enum |
2249
2510
  | `mcp.tool.schema_drift.previous_hash` / `.current_hash` | event attr | library-computed | a hash *of* tool-controlled content (the tool's own `inputSchema`), not the schema itself |
@@ -2321,6 +2582,9 @@ truth: `src/config.js`.
2321
2582
  | `thrashDetection` | object | see below | Controls Agent Thrash Detection[^10] — see "Agent Thrash Detection" above. Requires `fingerprinting: true` |
2322
2583
  | `schemaDrift` | object | see below | Controls tool schema drift detection[^11] — see "Tool schema drift detection" above. `enabled: true` by default changes `instrumentMcpServer()`'s throw behavior on upgrade — see that section's "Behavior change on upgrade" |
2323
2584
  | `errorRecording` | object | see below | Controls what raw exception content reaches `recordException()`/`setStatus()`[^12] — see "Error recording" above |
2585
+ | `unactionableErrors` | object | `{ enabled: true, minTextLength: 10 }` | `{ enabled?, minTextLength? }`: flags `isError: true` results with no usable text via `mcp.failure.unactionable` / `mcp.failure.content_length_bucket` (span-only). Env: `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED`, `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH` — see "Errors your agent can't act on" above |
2586
+ | `coverage` | object | `{ resources: false, prompts: false }` | Opt-in tracing of `resources/*` and `prompts/*` — see "Resources and prompts" above |
2587
+ | `staleness` | object | `{ enabled: true }` | `{ enabled?, tools?: { [toolName]: { maxAgeMs } } }`: per-tool override of the freshness window a tool reports in `_meta` — see "Staleness detection" above |
2324
2588
 
2325
2589
  [^10]: `{ enabled?, threshold?, windowMs?, maxTrackedKeys?, entryTtlMs?, reEmitAfter?, assumeSingleSession? }`, all fields optional, individually defaulted, and individually overridable via an `OTEL_MCP_THRASH_*` env var — see "Agent Thrash Detection" → "Configuration" above for the full table.
2326
2590
  [^11]: `{ enabled?, maxTrackedTools? }`, all fields optional, individually defaulted, and individually overridable via an `OTEL_MCP_SCHEMA_DRIFT_*` env var — see "Tool schema drift detection" → "Configuration" above for the full table.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.17.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",