opentel-mcp 0.16.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,96 @@
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
+
3
94
  ## 0.16.0
4
95
 
5
96
  Finishes what 0.15 started. Telemetry now survives the way MCP clients
package/README.md CHANGED
@@ -77,7 +77,7 @@ 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
82
  instrumentMcpServer(server, {
83
83
  serviceName: 'my-mcp-server', // shows up on your traces
@@ -85,7 +85,7 @@ instrumentMcpServer(server, {
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
 
@@ -300,7 +300,7 @@ problem" above) — exactly like the quickstart's `echo` tool, but with
300
300
  `isError: true` added and no `throw` involved:
301
301
 
302
302
  ```js
303
- server.tool('fetch_weather', { city: z.string() }, async ({ city }) => ({
303
+ server.registerTool('fetch_weather', { inputSchema: { city: z.string() } }, async ({ city }) => ({
304
304
  isError: true,
305
305
  content: [{ type: 'text', text: `couldn't reach the weather service for ${city}` }],
306
306
  }));
@@ -399,11 +399,12 @@ The four base `mcp.tool.*` call/duration metrics below, via
399
399
  registered — **except under `setupNodeSdk: true`, which registers one for
400
400
  you (v0.15.0+); see "Dev-mode metrics" below.** Set `enableMetrics: false`
401
401
  to opt out even when one is (dev-mode included); tracing is unaffected
402
- either way. Source of truth: `src/metrics.js`. Twelve metrics exist
403
- across the library in total — this section's four, plus two cost/token
404
- metrics ("Cost & Token Attribution" below), five Agent Thrash Detection
405
- metrics ("Agent Thrash Detection" below), and one schema-drift metric
406
- ("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).
407
408
 
408
409
  | Metric | Type | Unit | Attributes | Emitted when |
409
410
  |---|---|---|---|---|
@@ -579,6 +580,181 @@ See `dashboards/grafana-mcp-health.json` and `dashboards/dev/` in the repo
579
580
  for a full worked example (dashboard JSON + a script that exercises this
580
581
  exact setup end to end).
581
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
+
582
758
  ## Failure Fingerprinting (v0.4.0+)
583
759
 
584
760
  Groups logically identical failures under one stable identifier, even
@@ -663,7 +839,11 @@ per-call-site wrapping; tracked in the roadmap below.
663
839
  **What this controls, and who needs it.** Whenever a `tools/call`/
664
840
  `tools/list` handler throws, this library records the thrown error's
665
841
  message and stack trace onto the span it creates for that call — by
666
- 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
667
847
  third-party services, wrap unaudited dependencies, or you otherwise don't
668
848
  control every string a tool might throw, that's a real channel for
669
849
  whatever those errors happen to contain (an email address, an internal
@@ -683,7 +863,7 @@ span:
683
863
 
684
864
  | Mode | `exception.message` / status message | `exception.stacktrace` | When to use |
685
865
  |---|---|---|---|
686
- | `'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 |
687
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 |
688
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 |
689
869
 
@@ -2221,11 +2401,13 @@ found')`, or a tool result puts a credential in a field this library
2221
2401
  reads, that content is exactly what reaches your telemetry backend. Three
2222
2402
  channels carry it:
2223
2403
 
2224
- - **`span.recordException(err)` / `span.setStatus({ message })`** on every
2404
+ - **The `exception` event / `span.setStatus({ message })`** on every
2225
2405
  thrown `tools/call`/`tools/list` error — `exception.message` and
2226
- `exception.stacktrace` are set from `err.message`/`err.stack` verbatim
2227
- by default. `errorRecording.mode` controls this; see "Error recording"
2228
- 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.
2229
2411
  - **`mcp.tool.model` / `gen_ai.response.model`** — a tool result's own
2230
2412
  declared model field, admitted once it passes a length/character-shape
2231
2413
  check (v0.13.0). The check bounds shape, not content: a well-formed but
@@ -2319,9 +2501,10 @@ so they're spelled out instead of forced into one bucket.
2319
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 |
2320
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) |
2321
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) |
2322
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) |
2323
- | `exception.type` (event) | event attr | tool-controlled | length-capped only under `'normalized'` mode; uncapped under `'full'` (the native SDK call) |
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 |
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 |
2325
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 |
2326
2509
  | `mcp.tool.schema_drift.type` | event attr, metric label | library-computed | classifier enum |
2327
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 |
@@ -2399,6 +2582,9 @@ truth: `src/config.js`.
2399
2582
  | `thrashDetection` | object | see below | Controls Agent Thrash Detection[^10] — see "Agent Thrash Detection" above. Requires `fingerprinting: true` |
2400
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" |
2401
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 |
2402
2588
 
2403
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.
2404
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.16.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",
package/src/config.js CHANGED
@@ -12,6 +12,7 @@ import { resolveSchemaDriftConfig } from './schema-drift/config.js';
12
12
  import { resolveErrorRecordingConfig } from './error-recording/config.js';
13
13
  import { resolveFlushTimeout } from './flush-on-exit.js';
14
14
  import { resolveUnactionableErrorsConfig } from './unactionable.js';
15
+ import { resolveStalenessConfig } from './staleness.js';
15
16
 
16
17
  /**
17
18
  * @typedef {object} CostTrackingOptions
@@ -91,8 +92,9 @@ import { resolveUnactionableErrorsConfig } from './unactionable.js';
91
92
  * what a THROWN exception (not a tool-level `isError: true` result, which never carries a JS Error and is
92
93
  * unaffected) puts on the mcp.tool.call / tools/list span (ADR 019, docs/adr/019-raw-content-on-spans.md
93
94
  * Part 1, v0.13.0): `{ mode: 'full' | 'normalized' | 'none' }`. Defaults to `{ mode: 'full' }` —
94
- * byte-identical to every release before v0.13.0 (span.recordException(err) plus setStatus({ message:
95
- * err.message }), uncapped). `'normalized'` reuses fingerprint/normalize/message.js's normalizeMessage()
95
+ * the exception event span.recordException(err) would record plus setStatus({ message }), with credentials
96
+ * scrubbed (fingerprint/normalize/secrets.js) and the message/stack capped at 4096/16384 characters (v0.17.0;
97
+ * raw and uncapped before that). `'normalized'` reuses fingerprint/normalize/message.js's normalizeMessage()
96
98
  * for the exception message and fingerprint/normalize/stack.js's parseAndNormalizeStack() for the
97
99
  * stacktrace (cwd-stripped, node_modules-version-collapsed) — no new scrubbing pipeline. `'none'` sets
98
100
  * only the ERROR status code, no message, no exception event — the same pattern already used for a
@@ -117,6 +119,12 @@ import { resolveUnactionableErrorsConfig } from './unactionable.js';
117
119
  * (default 10) characters of text with no image/audio/resource item. `{ enabled: true, minTextLength: 10 }`
118
120
  * by default; independent of `fingerprinting`. Span-only: never a metric label. Also settable via
119
121
  * `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` / `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH` (lower precedence).
122
+ * @property {{ enabled?: boolean, tools?: Record<string, { maxAgeMs: number }> }} [staleness] - ADR 027
123
+ * (docs/adr/027-staleness-detection.md): a tool reports freshness in
124
+ * `result._meta['io.github.thirumalaiboobathi.opentel-mcp/freshness']` (`{ asOf, maxAgeMs | freshUntil }`) and
125
+ * successful responses get `mcp.tool.freshness.*` span attributes plus the `mcp.tool.stale_responses`
126
+ * counter. `tools[name].maxAgeMs` overrides that tool's own window (config can't supply `asOf`). On by
127
+ * default; tools that don't report freshness are untouched. A stale response is NOT an error.
120
128
  * @property {{ resources?: boolean, prompts?: boolean }} [coverage] - ADR 026 (docs/adr/026-resources-prompts-coverage.md),
121
129
  * opt-in, both off by default: also trace `resources/read`, `resources/list`, `resources/templates/list`
122
130
  * (`resources: true`) and `prompts/get`, `prompts/list` (`prompts: true`). Spans are named by method
@@ -350,5 +358,6 @@ export function resolveOptions(options) {
350
358
  flushOnExit: setupNodeSdk ? resolveFlushOnExit(opts.flushOnExit) : null,
351
359
  unactionableErrors: resolveUnactionableErrorsConfig(opts.unactionableErrors),
352
360
  coverage: resolveCoverage(opts.coverage),
361
+ staleness: resolveStalenessConfig(opts.staleness),
353
362
  };
354
363
  }
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { diag } from '@opentelemetry/api';
14
+ import { isPlainObject, readResultMetaField } from '../result-meta.js';
14
15
 
15
16
  /**
16
17
  * @typedef {Object} TokenUsage
@@ -35,14 +36,6 @@ const TOKEN_FIELD_PAIRS = [
35
36
  ['inputTokens', 'outputTokens'],
36
37
  ];
37
38
 
38
- /**
39
- * @param {unknown} value
40
- * @returns {value is Record<string, unknown>}
41
- */
42
- function isPlainObject(value) {
43
- return typeof value === 'object' && value !== null && !Array.isArray(value);
44
- }
45
-
46
39
  /**
47
40
  * @param {unknown} value
48
41
  * @returns {value is number}
@@ -84,7 +77,7 @@ function readModel(root) {
84
77
  const candidates = [
85
78
  root.model,
86
79
  isPlainObject(root.usage) ? root.usage.model : undefined,
87
- isPlainObject(root._meta) ? root._meta.model : undefined,
80
+ readResultMetaField(root, 'model'),
88
81
  ];
89
82
 
90
83
  for (const candidate of candidates) {
@@ -176,10 +169,8 @@ export function defaultExtractor(toolResult) {
176
169
  if (fromParsedRoot) return fromParsedRoot;
177
170
  }
178
171
 
179
- if (isPlainObject(toolResult._meta)) {
180
- const fromMeta = buildUsage(toolResult._meta.usage, toolResult);
181
- if (fromMeta) return fromMeta;
182
- }
172
+ const fromMeta = buildUsage(readResultMetaField(toolResult, 'usage'), toolResult);
173
+ if (fromMeta) return fromMeta;
183
174
 
184
175
  return null;
185
176
  } catch (error) {
@@ -62,12 +62,14 @@ export interface ErrorRecordingConfig {
62
62
  * unaffected by this option) puts on the `tools/call`/`tools/list`
63
63
  * span, per ADR 019 Part 1:
64
64
  *
65
- * - `'full'` — `span.recordException(err)` plus `span.setStatus({
66
- * message: err.message })`, exactly as every release before
67
- * v0.13.0. `exception.message`/`exception.stacktrace` carry
68
- * `err.message`/`err.stack` verbatim, uncapped — matching the OTel
69
- * ecosystem's own default (see ADR 019's "What OpenTelemetry's own
70
- * semantic conventions say").
65
+ * - `'full'` — the same `exception` event `span.recordException(err)`
66
+ * records, plus `span.setStatus({ message })`, from the raw
67
+ * `err.message`/`err.stack` (ids, paths, URLs kept). Since v0.17.0,
68
+ * credentials (Bearer/Basic tokens, AWS key ids, `sk-`/`ghp_`/`xox…`
69
+ * keys, JWTs, URL passwords, `token=`/`api_key=`/`password=` values)
70
+ * are replaced with `<SECRET>`, and the message/stack are capped at
71
+ * 4096/16384 characters. Before v0.17.0 they were verbatim and
72
+ * uncapped.
71
73
  * - `'normalized'` — the same `exception` event shape, but
72
74
  * `exception.message` is run through `normalizeMessage()`
73
75
  * (`src/fingerprint/normalize/message.js` — the same
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { NORMALIZE_STEPS } from './patterns.js';
10
+ import { scrubSecrets } from './secrets.js';
10
11
 
11
12
  // Exported as of v0.14.0 (ADR 020, docs/adr/020-redactor-hook.md, Decision
12
13
  // 5): errorRecording.redactor's own defensive output cap reuses this exact
@@ -26,7 +27,9 @@ export const MAX_INPUT_LENGTH = 2048;
26
27
  * this again on its own output returns the same string.
27
28
  */
28
29
  export function normalizeMessage(raw) {
29
- let result = raw.slice(0, MAX_INPUT_LENGTH);
30
+ // Credentials first (v0.17.0): a leaked token must neither reach the
31
+ // span nor split one error into a fingerprint per token value.
32
+ let result = scrubSecrets(raw.slice(0, MAX_INPUT_LENGTH));
30
33
  for (const { pattern, replacement } of NORMALIZE_STEPS) {
31
34
  result = result.replace(pattern, replacement);
32
35
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @module fingerprint/normalize/secrets
3
+ *
4
+ * Credential scrubbing for error text (v0.17.0). Runs FIRST in
5
+ * normalizeMessage() (so fingerprints group by error shape rather than by
6
+ * leaked token) and is also applied, on its own, to `errorRecording.mode:
7
+ * 'full'` exception messages and stacks (instrument.js's
8
+ * recordThrownException()), which otherwise keep their ids, paths and URLs.
9
+ *
10
+ * Every pattern targets a recognizable credential SHAPE: a known prefix
11
+ * (AKIA, sk-, ghp_, xoxb-, eyJ…eyJ…), an auth scheme followed by a long
12
+ * token, credentials embedded in a URL, or a value assigned to a key whose
13
+ * name says it's a secret. Plain words, UUIDs, hashes and ordinary ids are
14
+ * deliberately left alone. This is not a general-purpose PII filter.
15
+ */
16
+
17
+ /** What every scrubbed value is replaced with. */
18
+ export const SECRET_PLACEHOLDER = '<SECRET>';
19
+
20
+ // Key names whose value is a secret. Matched case-insensitively, as a
21
+ // whole word (so `tokenizer=` or `secretary:` don't match).
22
+ const SECRET_KEY_NAMES =
23
+ '(?:access[_-]?token|refresh[_-]?token|id[_-]?token|auth[_-]?token|token|x-api-key|api[_-]?key|apikey|' +
24
+ 'client[_-]?secret|secret[_-]?key|secret|password|passwd|pwd|private[_-]?key|aws[_-]?secret[_-]?access[_-]?key|' +
25
+ 'credentials?|signature|sig)';
26
+
27
+ /** @type {readonly { pattern: RegExp, replacement: string }[]} */
28
+ export const SECRET_STEPS = Object.freeze([
29
+ // Credentials inside a URL: scheme://<user>:<password>@host -> scheme://<SECRET>@host.
30
+ {
31
+ pattern: /\b([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^\s/?#@"'<>:]+:[^\s/?#@"'<>]+@/g,
32
+ replacement: `$1${SECRET_PLACEHOLDER}@`,
33
+ },
34
+
35
+ // JSON Web Token: three base64url segments, the first two JSON objects ("eyJ").
36
+ {
37
+ pattern: /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{10,}/g,
38
+ replacement: SECRET_PLACEHOLDER,
39
+ },
40
+
41
+ // HTTP auth schemes followed by a credential of at least 16 characters
42
+ // ("Bearer token missing" has no such credential and is left alone).
43
+ {
44
+ pattern: /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]{16,}=*/gi,
45
+ replacement: `$1 ${SECRET_PLACEHOLDER}`,
46
+ },
47
+
48
+ // AWS access key ids.
49
+ {
50
+ pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g,
51
+ replacement: SECRET_PLACEHOLDER,
52
+ },
53
+
54
+ // Provider-prefixed API keys: OpenAI/Anthropic (sk-, sk-proj-, sk-ant-),
55
+ // Stripe (sk_live_/rk_test_…), GitHub (ghp_/gho_/ghu_/ghs_/ghr_,
56
+ // github_pat_), Slack (xoxa-/xoxb-/xoxp-/xoxr-/xoxs-), Google (AIza…).
57
+ {
58
+ pattern:
59
+ /\b(?:sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}|[sr]k_(?:live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})/g,
60
+ replacement: SECRET_PLACEHOLDER,
61
+ },
62
+
63
+ // A secret-named key in a query string or assignment: ?token=…, &api_key=…,
64
+ // password=…, "client_secret": "…". `=` always counts; `:` only when the
65
+ // value is quoted or contains a digit, so "token: expired" stays readable.
66
+ {
67
+ pattern: new RegExp(`\\b(${SECRET_KEY_NAMES})(=)(["']?)[^\\s&"',;)]+`, 'gi'),
68
+ replacement: `$1$2$3${SECRET_PLACEHOLDER}`,
69
+ },
70
+ {
71
+ pattern: new RegExp(`\\b(${SECRET_KEY_NAMES})(["']?\\s*:\\s*)(["'])[^"'\\n]+\\3`, 'gi'),
72
+ replacement: `$1$2$3${SECRET_PLACEHOLDER}$3`,
73
+ },
74
+ {
75
+ pattern: new RegExp(`\\b(${SECRET_KEY_NAMES})(\\s*:\\s*)(?=[^\\s,;]*\\d)[^\\s,;]{4,}`, 'gi'),
76
+ replacement: `$1$2${SECRET_PLACEHOLDER}`,
77
+ },
78
+ ]);
79
+
80
+ /**
81
+ * Replaces recognizable credentials in `text` with {@link SECRET_PLACEHOLDER}.
82
+ * Leaves everything else exactly as it was. Never throws on a string.
83
+ *
84
+ * @param {string} text
85
+ * @returns {string}
86
+ */
87
+ export function scrubSecrets(text) {
88
+ let result = text;
89
+ for (const { pattern, replacement } of SECRET_STEPS) {
90
+ result = result.replace(pattern, replacement);
91
+ }
92
+ return result;
93
+ }
package/src/index.d.ts CHANGED
@@ -62,7 +62,7 @@ export interface InstrumentOptions {
62
62
  * **Also stands up a dev-mode `MeterProvider` (ADR 023,
63
63
  * `docs/adr/023-dev-mode-metrics.md`, v0.15.0+)**, gated additionally by
64
64
  * `enableMetrics` (above, default `true`): a `PeriodicExportingMetricReader`
65
- * exporting all 12 `mcp.tool.*`/`mcp.tool.loop.*`/`mcp.tool.schema_drift.*`
65
+ * exporting all 13 `mcp.tool.*`/`mcp.tool.loop.*`/`mcp.tool.schema_drift.*`
66
66
  * instruments to stderr every 5 seconds, in a compact
67
67
  * `name{attrs} = value` format — never `@opentelemetry/sdk-metrics`'
68
68
  * own `ConsoleMetricExporter`, which writes to stdout and would corrupt
@@ -104,20 +104,6 @@ export interface InstrumentOptions {
104
104
  */
105
105
  flushOnExit?: boolean | { timeoutMs?: number };
106
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
107
  /**
122
108
  * ADR 026, opt-in (both off by default): also trace `resources/read`,
123
109
  * `resources/list`, `resources/templates/list` (`resources: true`) and
@@ -139,12 +125,51 @@ export interface InstrumentOptions {
139
125
  prompts?: boolean;
140
126
  };
141
127
 
128
+ /**
129
+ * ADR 025: on every `isError: true` result, sets the span attributes
130
+ * `mcp.failure.unactionable` (boolean) and
131
+ * `mcp.failure.content_length_bucket` (`'empty' | 'tiny' | 'short' |
132
+ * 'medium' | 'long'`). Computed only from the whitespace-trimmed length of
133
+ * the text items and the count of non-text items, never from the text
134
+ * itself. Unactionable means no content, or fewer than `minTextLength`
135
+ * characters of text with no image/audio/resource item. Span-only (never a
136
+ * metric label); independent of `fingerprinting`. Also settable via
137
+ * `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` /
138
+ * `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH` (lower precedence).
139
+ *
140
+ * @default { enabled: true, minTextLength: 10 }
141
+ */
142
142
  unactionableErrors?: {
143
143
  enabled?: boolean;
144
144
  /** Integer 0–200. `0` flags only empty (or whitespace-only) errors. @default 10 */
145
145
  minTextLength?: number;
146
146
  };
147
147
 
148
+ /**
149
+ * ADR 027 (docs/adr/027-staleness-detection.md): staleness detection. A
150
+ * tool reports how fresh its data is in
151
+ * `result._meta[FRESHNESS_META_KEY]` (see {@link FreshnessMeta}), and every
152
+ * successful `tools/call` that carries it gets `mcp.tool.freshness.*` span
153
+ * attributes (`verdict`: `fresh` | `stale` | `unknown`, `data_age_ms`,
154
+ * `max_age_ms`, `limit_source`, `unknown_reason`) and, when stale, `+1` on
155
+ * the `mcp.tool.stale_responses` counter (label: `gen_ai.tool.name`). A
156
+ * stale response is NOT an error: span status stays OK and it is never a
157
+ * silent failure. Tools that don't report freshness are untouched.
158
+ *
159
+ * @default { enabled: true }
160
+ */
161
+ staleness?: {
162
+ /** `false` turns the feature off: `_meta` isn't read. @default true */
163
+ enabled?: boolean;
164
+ /**
165
+ * Per-tool override of the freshness window, keyed by tool name. Replaces
166
+ * the tool's own `maxAgeMs`/`freshUntil`. Config can't supply `asOf`: a
167
+ * response without the freshness key still gets no verdict. An invalid
168
+ * value (negative, non-finite) is ignored with a `diag.warn`.
169
+ */
170
+ tools?: Record<string, { maxAgeMs: number }>;
171
+ };
172
+
148
173
  /**
149
174
  * Controls the `mcp.tool.tokens.*` / `mcp.tool.model` / `mcp.tool.cost.*` span attributes, the
150
175
  * `mcp.tool.tokens.total` / `mcp.tool.cost.total` metrics, and the optional per-session/per-tool budget
@@ -201,8 +226,9 @@ export interface InstrumentOptions {
201
226
  * `Error` and is unaffected by this option) puts on the `tools/call`/`tools/list` span (ADR 019,
202
227
  * `docs/adr/019-raw-content-on-spans.md` Part 1, v0.13.0).
203
228
  *
204
- * Defaults to `{ mode: 'full' }` — byte-identical to every release before v0.13.0:
205
- * `span.recordException(err)` plus `span.setStatus({ message: err.message })`, both uncapped.
229
+ * Defaults to `{ mode: 'full' }`: the exception event `span.recordException(err)` would record plus
230
+ * `span.setStatus({ message })`, from the raw message and stack but with credentials replaced by
231
+ * `<SECRET>` and the message/stack capped at 4096/16384 characters (v0.17.0; raw and uncapped before).
206
232
  * `'normalized'` reuses `normalizeException()` (`src/fingerprint/normalize/exception.js`) — the same
207
233
  * coercion + `normalizeMessage()`/`parseAndNormalizeStack()` step `computeFingerprint()`
208
234
  * (`src/fingerprint/compose.js`) uses for `mcp.failure.*` on the same `err` — for the exception message
@@ -546,3 +572,49 @@ export type { ErrorRecordingConfig, ErrorRecordingRedactor, ErrorRecordingRedact
546
572
  // not part of the public API.
547
573
 
548
574
  export type { ToolOutcome, ToolOutcomeCounts, ObservationIntegrity, ObservationState } from './observation/types.d.ts';
575
+
576
+ // --- Staleness detection (ADR 027, src/staleness.js) ---
577
+
578
+ /**
579
+ * The `result._meta` key a tool reports its data freshness under:
580
+ * `'io.github.thirumalaiboobathi.opentel-mcp/freshness'`. A reverse-DNS
581
+ * prefix, per the MCP spec's `_meta` key-name rules.
582
+ */
583
+ export declare const FRESHNESS_META_KEY: 'io.github.thirumalaiboobathi.opentel-mcp/freshness';
584
+
585
+ /** A timestamp: an ISO 8601 string WITH a timezone (`Z` or `±hh:mm`), or epoch milliseconds. */
586
+ export type FreshnessTimestamp = string | number;
587
+
588
+ /**
589
+ * The value a tool puts at `result._meta[FRESHNESS_META_KEY]`. `asOf` plus
590
+ * at least one of `maxAgeMs` / `freshUntil`; with both, the earlier
591
+ * deadline wins.
592
+ *
593
+ * @example
594
+ * return {
595
+ * content: [{ type: 'text', text: JSON.stringify(quote) }],
596
+ * _meta: {
597
+ * [FRESHNESS_META_KEY]: { asOf: quote.timestamp, maxAgeMs: 5_000 } satisfies FreshnessMeta,
598
+ * },
599
+ * };
600
+ */
601
+ export type FreshnessMeta =
602
+ | {
603
+ /** When the data is from (not when the tool ran). */
604
+ asOf: FreshnessTimestamp;
605
+ /** How old the data may be and still count as fresh. Finite, >= 0. */
606
+ maxAgeMs: number;
607
+ /** Optional absolute deadline; the earlier of the two wins. */
608
+ freshUntil?: FreshnessTimestamp;
609
+ }
610
+ | {
611
+ /** When the data is from (not when the tool ran). */
612
+ asOf: FreshnessTimestamp;
613
+ /** Optional duration window; the earlier of the two wins. */
614
+ maxAgeMs?: number;
615
+ /** Absolute deadline, e.g. the close of the trading session. Not before `asOf`. */
616
+ freshUntil: FreshnessTimestamp;
617
+ };
618
+
619
+ /** `mcp.tool.freshness.verdict`. */
620
+ export type FreshnessVerdict = 'fresh' | 'stale' | 'unknown';
package/src/index.js CHANGED
@@ -7,3 +7,5 @@ export { DEFAULT_CLASSIFIERS } from './fingerprint/classify/index.js';
7
7
  export { DEFAULT_PRICING, DEFAULT_PRICING_LAST_VERIFIED, isDefaultPricingStale } from './cost/pricing.js';
8
8
  export { defaultExtractor } from './cost/extractor.js';
9
9
  export { calculateCost } from './cost/calculator.js';
10
+
11
+ export { FRESHNESS_META_KEY } from './staleness.js';
package/src/instrument.js CHANGED
@@ -19,6 +19,7 @@ import { toSpanAttributes, ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
19
19
  import { classifyFailureChannel } from './fingerprint/classify/channel.js';
20
20
  import { extractValidationPaths } from './fingerprint/classify/validation-paths.js';
21
21
  import { normalizeException, coerceError } from './fingerprint/normalize/exception.js';
22
+ import { scrubSecrets } from './fingerprint/normalize/secrets.js';
22
23
  import { applyRedactor } from './error-recording/redactor.js';
23
24
  import { calculateCost, normalizeModelName, isValidModelId, describeInvalidModelId } from './cost/calculator.js';
24
25
  import { DEFAULT_PRICING_LAST_VERIFIED } from './cost/pricing.js';
@@ -33,6 +34,7 @@ import { detectObservationIntegrity } from './observation/integrity.js';
33
34
  import { InstanceRegistry } from './registry/instance-registry.js';
34
35
  import { registerFlushOnExit } from './flush-on-exit.js';
35
36
  import { applyUnactionableAttributes } from './unactionable.js';
37
+ import { applyStaleness, createStalenessState } from './staleness.js';
36
38
  import {
37
39
  ATTR_MCP_METHOD_NAME,
38
40
  ATTR_GEN_AI_PROMPT_NAME,
@@ -514,6 +516,9 @@ export function instrumentMcpServer(input, options) {
514
516
  // not correctness-critical state, so a fresh instrumented server simply
515
517
  // gets a fresh warning budget rather than sharing one process-wide.
516
518
  const errorRecordingState = { warnedFailed: false };
519
+ // ADR 027: same "plain holder, not instanceKey-shared" reasoning — only
520
+ // the once-per-tool malformed-freshness warning lives here.
521
+ const stalenessState = createStalenessState();
517
522
  // Additive to instrumentMcpServer()'s existing return contract (the same
518
523
  // input object, for chaining — see this function's own docblock): a
519
524
  // getThrashSummary() method attached the same way shutdown() is, just
@@ -617,6 +622,8 @@ export function instrumentMcpServer(input, options) {
617
622
  costAttributionState,
618
623
  errorRecordingState,
619
624
  resolved.unactionableErrors,
625
+ resolved.staleness,
626
+ stalenessState,
620
627
  );
621
628
  } else if (schema === v1.ListToolsRequestSchema && schemaDriftDetector) {
622
629
  handler = wrapToolsListHandler(
@@ -677,6 +684,8 @@ export function instrumentMcpServer(input, options) {
677
684
  costAttributionState,
678
685
  errorRecordingState,
679
686
  resolved.unactionableErrors,
687
+ resolved.staleness,
688
+ stalenessState,
680
689
  );
681
690
  } else if (maybeHandler === undefined && method === TOOLS_LIST_METHOD && schemaDriftDetector) {
682
691
  handlerOrSchemas = wrapToolsListHandler(
@@ -1679,6 +1688,53 @@ function applyToolOutcomeThrown(toolOutcomeCounter) {
1679
1688
  }
1680
1689
  }
1681
1690
 
1691
+ /** v0.17.0: 'full' mode caps. Double 'normalized''s 2048-character message input, so 'full' stays the detailed mode. */
1692
+ export const MAX_FULL_EXCEPTION_MESSAGE_LENGTH = 4096;
1693
+ export const MAX_FULL_EXCEPTION_STACK_LENGTH = 16384;
1694
+ const TRUNCATION_MARKER = ' …[truncated by opentel-mcp]';
1695
+
1696
+ /**
1697
+ * @param {string} text
1698
+ * @param {number} max
1699
+ * @returns {string}
1700
+ */
1701
+ function scrubAndCap(text, max) {
1702
+ const scrubbed = scrubSecrets(text.length > max * 2 ? text.slice(0, max * 2) : text);
1703
+ return scrubbed.length > max ? scrubbed.slice(0, max) + TRUNCATION_MARKER : scrubbed;
1704
+ }
1705
+
1706
+ /**
1707
+ * The 'full'-mode exception event, mirroring the installed SDK's own
1708
+ * Span.recordException() (exception.type from `code`, else `name`;
1709
+ * exception.message; exception.stacktrace), with scrubSecrets() applied
1710
+ * and lengths capped. `attributes` is null when there is neither a type
1711
+ * nor a message, where recordException() would also record nothing.
1712
+ *
1713
+ * @param {unknown} err
1714
+ * @returns {{ attributes: Record<string, string> | null, statusMessage: string | undefined }}
1715
+ */
1716
+ function describeFullException(err) {
1717
+ /** @type {Record<string, string>} */
1718
+ const attributes = {};
1719
+ let statusMessage;
1720
+ if (typeof err === 'string') {
1721
+ attributes['exception.message'] = scrubAndCap(err, MAX_FULL_EXCEPTION_MESSAGE_LENGTH);
1722
+ } else if (err) {
1723
+ const e = /** @type {any} */ (err);
1724
+ if (e.code) attributes['exception.type'] = String(e.code).slice(0, MAX_ERROR_CLASS_LENGTH);
1725
+ else if (e.name) attributes['exception.type'] = String(e.name).slice(0, MAX_ERROR_CLASS_LENGTH);
1726
+ if (typeof e.message === 'string' && e.message !== '') {
1727
+ attributes['exception.message'] = scrubAndCap(e.message, MAX_FULL_EXCEPTION_MESSAGE_LENGTH);
1728
+ statusMessage = attributes['exception.message'];
1729
+ }
1730
+ if (typeof e.stack === 'string' && e.stack !== '') {
1731
+ attributes['exception.stacktrace'] = scrubAndCap(e.stack, MAX_FULL_EXCEPTION_STACK_LENGTH);
1732
+ }
1733
+ }
1734
+ const hasAny = attributes['exception.type'] !== undefined || attributes['exception.message'] !== undefined;
1735
+ return { attributes: hasAny ? attributes : null, statusMessage };
1736
+ }
1737
+
1682
1738
  /**
1683
1739
  * Records a thrown exception onto `span` per `errorRecordingConfig.mode`
1684
1740
  * (ADR 019 Part 1, docs/adr/019-raw-content-on-spans.md, v0.13.0 Phase 1).
@@ -1689,12 +1745,15 @@ function applyToolOutcomeThrown(toolOutcomeCounter) {
1689
1745
  * SpanStatusCode.ERROR })` with no message, a few lines above in
1690
1746
  * wrapToolCallHandler — the exact pattern 'none' mode below reuses).
1691
1747
  *
1692
- * - `'full'` (default): `span.recordException(err)` +
1693
- * `setStatus({ message: err?.message })` — byte-identical to every
1694
- * release before v0.13.0. This is the literal two-line call the
1695
- * catch blocks used inline before this function existed; taken
1696
- * unconditionally, with no new branching evaluated first, whenever
1697
- * mode is `'full'`.
1748
+ * - `'full'` (default): the event `span.recordException(err)` would
1749
+ * record (`exception.type` from `code` else `name`, `exception.message`,
1750
+ * `exception.stacktrace`) plus `setStatus({ message })`, built by
1751
+ * describeFullException() with credentials scrubbed (scrubSecrets(),
1752
+ * `fingerprint/normalize/secrets.js`) and the message/stack capped at
1753
+ * MAX_FULL_EXCEPTION_MESSAGE_LENGTH / MAX_FULL_EXCEPTION_STACK_LENGTH
1754
+ * (v0.17.0). Ids, paths, URLs and line numbers are kept, which is what
1755
+ * still separates it from `'normalized'`. Before v0.17.0 this was the
1756
+ * native `recordException(err)` call, raw and uncapped.
1698
1757
  * - `'none'`: `setStatus({ code: ERROR })` only — no message, no
1699
1758
  * `exception` event.
1700
1759
  * - `'normalized'`: a hand-built `exception` event carrying the same
@@ -1718,16 +1777,9 @@ function applyToolOutcomeThrown(toolOutcomeCounter) {
1718
1777
  *
1719
1778
  * `error.type` (`ATTR_ERROR_TYPE`, set by each call site immediately
1720
1779
  * after this call returns) is capped at `MAX_ERROR_CLASS_LENGTH`
1721
- * regardless of mode — see each call site's own comment. The native
1722
- * `exception.type` a `'full'`-mode `span.recordException(err)` call sets
1723
- * internally is deliberately NOT capped here: `'full'` mode is defined
1724
- * as byte-identical to pre-v0.13.0 behavior, so the native OTel SDK call
1725
- * is left completely untouched, uncapped exception.type included. Only
1726
- * `'normalized'` mode's hand-built event applies the cap to
1727
- * `exception.type` too (mirroring `recordException()`'s own
1728
- * `err.code?.toString() ?? err.name` derivation for what goes in that
1729
- * field), since that event is already being constructed by hand either
1730
- * way — there is no "untouched native call" to preserve for that mode.
1780
+ * regardless of mode — see each call site's own comment. Since v0.17.0
1781
+ * both hand-built events (`'full'` and `'normalized'`) cap
1782
+ * `exception.type` the same way.
1731
1783
  *
1732
1784
  * Never throws — matches this library's fail-open discipline for
1733
1785
  * everything downstream of a real thrown error, the same guarantee
@@ -1821,12 +1873,22 @@ function recordThrownException(span, err, errorRecordingConfig, errorRecordingSt
1821
1873
  return;
1822
1874
  }
1823
1875
 
1824
- // 'full' (default) — byte-identical to pre-v0.13.0 behavior. Never
1825
- // references errorRecordingConfig.redactor at all (Decision 6).
1826
- span.recordException(err);
1827
- span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
1876
+ // 'full' (default): the same three attributes span.recordException(err)
1877
+ // sets, from the raw message and stack, but with credentials scrubbed
1878
+ // and each capped (v0.17.0). Keeps the ids, paths, URLs and line numbers
1879
+ // 'normalized' strips. Never references errorRecordingConfig.redactor
1880
+ // at all (Decision 6).
1881
+ const full = describeFullException(err);
1882
+ if (full.attributes) span.addEvent('exception', full.attributes);
1883
+ span.setStatus({ code: SpanStatusCode.ERROR, message: full.statusMessage });
1828
1884
  } catch {
1829
- // Never throw — see this function's own docblock.
1885
+ // Never throw — see this function's own docblock. Still mark the span
1886
+ // failed when reading the error itself threw (e.g. a throwing getter).
1887
+ try {
1888
+ span.setStatus({ code: SpanStatusCode.ERROR });
1889
+ } catch {
1890
+ // ignore
1891
+ }
1830
1892
  }
1831
1893
  }
1832
1894
 
@@ -1930,6 +1992,8 @@ function recordThrownException(span, err, errorRecordingConfig, errorRecordingSt
1930
1992
  * through to applyCostAttribution() — see that function's own docblock and warnRejectedModel()'s.
1931
1993
  * @param {{ warnedFailed: boolean }} errorRecordingState - ADR 020 (v0.14.0 Phase 2): threaded through
1932
1994
  * to recordThrownException()/applyRedactor() — see redactor.js's own docblock for what "once" means here.
1995
+ * @param {import('./staleness.js').StalenessConfig} stalenessConfig - ADR 027: resolved `staleness` option.
1996
+ * @param {{ warnedTools: Set<string> }} stalenessState - ADR 027: once-per-tool malformed-freshness warnings.
1933
1997
  */
1934
1998
  function wrapToolCallHandler(
1935
1999
  handler,
@@ -1950,6 +2014,8 @@ function wrapToolCallHandler(
1950
2014
  costAttributionState,
1951
2015
  errorRecordingState,
1952
2016
  unactionableErrorsConfig,
2017
+ stalenessConfig,
2018
+ stalenessState,
1953
2019
  ) {
1954
2020
  return (request, extra) => {
1955
2021
  const toolName = request?.params?.name;
@@ -2084,6 +2150,9 @@ function wrapToolCallHandler(
2084
2150
  );
2085
2151
  } else {
2086
2152
  span.setStatus({ code: SpanStatusCode.OK });
2153
+ // ADR 027: freshness verdict from the tool's own _meta. Success
2154
+ // branch only, and it never touches status: stale is not an error.
2155
+ applyStaleness(span, metricsRecorder, toolName, result, stalenessConfig, stalenessState);
2087
2156
  applyCostAttribution(span, metricsRecorder, toolName, sessionId, result, costTracking, budgetTracker, costAttributionState);
2088
2157
  if (thrashSessionId !== null) {
2089
2158
  applyThrashSuccessClear(thrashConfig, thrashDetector, thrashSessionId, toolName);
package/src/metrics.js CHANGED
@@ -77,6 +77,7 @@ import { ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
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
79
  * recordOperationDuration: (method: string, durationMs: number, errorType?: string) => void,
80
+ * recordStaleResponse: (toolName: string | undefined) => void,
80
81
  * }}
81
82
  */
82
83
  export function setupMeter(packageVersion) {
@@ -113,7 +114,20 @@ export function setupMeter(packageVersion) {
113
114
  unit: 'ms',
114
115
  });
115
116
 
117
+ // ADR 027 (v0.17.0): successful responses whose data was older than the
118
+ // tool's own freshness window. gen_ai.tool.name only -- no new label key,
119
+ // and no verdict/age label (data age is span-only).
120
+ const staleResponses = meter.createCounter('mcp.tool.stale_responses', {
121
+ description:
122
+ 'Number of successful MCP tool calls whose data was older than the freshness the tool reported (ADR 027). Not an error.',
123
+ });
124
+
116
125
  return {
126
+ recordStaleResponse(toolName) {
127
+ staleResponses.add(1, {
128
+ [ATTR_GEN_AI_TOOL_NAME]: toolName,
129
+ });
130
+ },
117
131
  recordOperationDuration(method, durationMs, errorType) {
118
132
  operationDuration.record(durationMs, {
119
133
  [ATTR_MCP_METHOD_NAME]: method,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @module result-meta
3
+ * The one place core reaches into a tool result's `_meta` (the MCP spec's
4
+ * extension point on every result).
5
+ *
6
+ * Shared by the cost extractor (`_meta.usage`, `_meta.model` —
7
+ * src/cost/extractor.js) and staleness detection (the freshness key —
8
+ * src/staleness.js, ADR 027). Reads exactly the key it's given and never
9
+ * enumerates `_meta`, so a feature only ever sees the field it asked for.
10
+ */
11
+
12
+ /**
13
+ * @param {unknown} value
14
+ * @returns {value is Record<string, unknown>}
15
+ */
16
+ export function isPlainObject(value) {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ /**
21
+ * `result._meta[key]`, or `undefined` when `result` or its `_meta` isn't a
22
+ * plain object. Property access isn't guarded here (a throwing getter
23
+ * propagates), so callers keep their own try/catch, as both callers
24
+ * already do.
25
+ *
26
+ * @param {unknown} result
27
+ * @param {string} key
28
+ * @returns {unknown}
29
+ */
30
+ export function readResultMetaField(result, key) {
31
+ if (!isPlainObject(result)) return undefined;
32
+ const meta = result._meta;
33
+ return isPlainObject(meta) ? meta[key] : undefined;
34
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * @module staleness
3
+ *
4
+ * ADR 027 (docs/adr/027-staleness-detection.md): flag successful tool
5
+ * responses whose data is older than the freshness the tool itself
6
+ * reported. The tool is the source of truth; `staleness.tools[name].maxAgeMs`
7
+ * in config is only an override.
8
+ *
9
+ * PRIVACY: reads `result._meta[FRESHNESS_META_KEY]` through the shared
10
+ * accessor (src/result-meta.js) and then only its `asOf`, `maxAgeMs` and
11
+ * `freshUntil` properties. Never enumerates `_meta`, never reads content,
12
+ * never emits a raw input value: only the derived age and window (numbers)
13
+ * and fixed enum strings reach the span.
14
+ *
15
+ * Staleness is not an error: nothing here touches span status, error.type
16
+ * or the silent-failure counter.
17
+ */
18
+
19
+ import { diag } from '@opentelemetry/api';
20
+ import { isPlainObject, readResultMetaField } from './result-meta.js';
21
+
22
+ /**
23
+ * The `result._meta` key a tool reports freshness under. Reverse-DNS
24
+ * prefix per the MCP spec's `_meta` key rules (ADR 027, Step 0).
25
+ */
26
+ export const FRESHNESS_META_KEY = 'io.github.thirumalaiboobathi.opentel-mcp/freshness';
27
+
28
+ /** `fresh` | `stale` | `unknown`. Set only when the tool sent the freshness key. */
29
+ export const ATTR_MCP_TOOL_FRESHNESS_VERDICT = 'mcp.tool.freshness.verdict';
30
+ /** Integer ms: response time minus `asOf`. Span-only, never a metric. */
31
+ export const ATTR_MCP_TOOL_FRESHNESS_DATA_AGE_MS = 'mcp.tool.freshness.data_age_ms';
32
+ /** Integer ms: the effective window, deadline minus `asOf`. */
33
+ export const ATTR_MCP_TOOL_FRESHNESS_MAX_AGE_MS = 'mcp.tool.freshness.max_age_ms';
34
+ /** `max_age_ms` | `fresh_until` | `config`: which input set the deadline. */
35
+ export const ATTR_MCP_TOOL_FRESHNESS_LIMIT_SOURCE = 'mcp.tool.freshness.limit_source';
36
+ /** Why the verdict is `unknown`; one of UNKNOWN_REASONS. */
37
+ export const ATTR_MCP_TOOL_FRESHNESS_UNKNOWN_REASON = 'mcp.tool.freshness.unknown_reason';
38
+
39
+ export const VERDICTS = Object.freeze(['fresh', 'stale', 'unknown']);
40
+ export const LIMIT_SOURCES = Object.freeze(['max_age_ms', 'fresh_until', 'config']);
41
+ export const UNKNOWN_REASONS = Object.freeze(['invalid_as_of', 'future_as_of', 'missing_window', 'invalid_window']);
42
+
43
+ /** `asOf` up to this far in the future is clock skew: age clamps to 0. */
44
+ export const FUTURE_SKEW_TOLERANCE_MS = 5_000;
45
+
46
+ /** A numeric timestamp below this (March 1973 as ms) is almost certainly epoch seconds. */
47
+ const MIN_EPOCH_MS = 1e11;
48
+ /** ECMAScript's Date range limit, in ms either side of the epoch. */
49
+ const MAX_EPOCH_MS = 8.64e15;
50
+ /** Longer strings aren't parsed at all. */
51
+ const MAX_TIMESTAMP_LENGTH = 64;
52
+ /** ISO 8601 date-time with a mandatory `Z` or `±hh:mm` offset. */
53
+ const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
54
+ /** Cap on the per-server set of tool names already warned about. */
55
+ const MAX_WARNED_TOOLS = 256;
56
+
57
+ /**
58
+ * @typedef {object} StalenessConfig
59
+ * @property {boolean} enabled
60
+ * @property {Map<string, number>} overrides - tool name -> maxAgeMs override.
61
+ */
62
+
63
+ /**
64
+ * Resolves the `staleness` option. Invalid override entries are dropped
65
+ * with one `diag.warn` each; never throws.
66
+ *
67
+ * @param {unknown} partial
68
+ * @returns {StalenessConfig}
69
+ */
70
+ export function resolveStalenessConfig(partial) {
71
+ const overrides = new Map();
72
+ let enabled = true;
73
+ try {
74
+ const p = isPlainObject(partial) ? partial : {};
75
+ enabled = p.enabled !== false;
76
+ const tools = isPlainObject(p.tools) ? p.tools : {};
77
+ for (const toolName of Object.keys(tools)) {
78
+ const entry = tools[toolName];
79
+ const maxAgeMs = isPlainObject(entry) ? entry.maxAgeMs : undefined;
80
+ if (isValidMaxAge(maxAgeMs)) {
81
+ overrides.set(toolName, maxAgeMs);
82
+ } else {
83
+ diag.warn(
84
+ `opentel-mcp: staleness.tools[${JSON.stringify(toolName)}].maxAgeMs must be a finite number >= 0; ` +
85
+ "ignoring this override, so the tool's own freshness window applies. See ADR 027.",
86
+ );
87
+ }
88
+ }
89
+ } catch (err) {
90
+ diag.warn('opentel-mcp: could not read the staleness option; ignoring its overrides. See ADR 027.', err);
91
+ }
92
+ return { enabled, overrides };
93
+ }
94
+
95
+ /**
96
+ * @param {unknown} value
97
+ * @returns {value is number}
98
+ */
99
+ function isValidMaxAge(value) {
100
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
101
+ }
102
+
103
+ /**
104
+ * Epoch ms from an ISO 8601 string with a zone, or an epoch-ms number.
105
+ * `null` for anything else.
106
+ *
107
+ * @param {unknown} value
108
+ * @returns {number | null}
109
+ */
110
+ export function parseTimestamp(value) {
111
+ if (typeof value === 'number') {
112
+ if (!Number.isFinite(value) || value < MIN_EPOCH_MS || value > MAX_EPOCH_MS) return null;
113
+ return value;
114
+ }
115
+ if (typeof value === 'string') {
116
+ if (value.length > MAX_TIMESTAMP_LENGTH || !ISO_WITH_ZONE.test(value)) return null;
117
+ const ms = Date.parse(value);
118
+ return Number.isFinite(ms) ? ms : null;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * @typedef {object} FreshnessAssessment
125
+ * @property {'fresh' | 'stale' | 'unknown'} verdict
126
+ * @property {number} [dataAgeMs]
127
+ * @property {number} [maxAgeMs]
128
+ * @property {'max_age_ms' | 'fresh_until' | 'config'} [limitSource]
129
+ * @property {'invalid_as_of' | 'future_as_of' | 'missing_window' | 'invalid_window'} [unknownReason]
130
+ * @property {string} [warning] - Set for a malformed input worth one diag.warn.
131
+ */
132
+
133
+ /**
134
+ * The verdict for one freshness value. Pure; reads only `asOf`,
135
+ * `maxAgeMs` and `freshUntil` off `freshness`. Can throw only if one of
136
+ * those properties is a throwing getter; the caller catches.
137
+ *
138
+ * @param {unknown} freshness - `result._meta[FRESHNESS_META_KEY]`, known to be present.
139
+ * @param {number | undefined} overrideMaxAgeMs - From config; replaces the tool's window.
140
+ * @param {number} now - Epoch ms.
141
+ * @returns {FreshnessAssessment}
142
+ */
143
+ export function assessFreshness(freshness, overrideMaxAgeMs, now) {
144
+ if (!isPlainObject(freshness)) {
145
+ return { verdict: 'unknown', unknownReason: 'invalid_as_of', warning: 'the freshness value is not an object' };
146
+ }
147
+
148
+ const rawAsOf = freshness.asOf;
149
+ const asOf = parseTimestamp(rawAsOf);
150
+ if (asOf === null) {
151
+ const looksLikeSeconds = typeof rawAsOf === 'number' && Number.isFinite(rawAsOf) && rawAsOf > 0 && rawAsOf < MIN_EPOCH_MS;
152
+ return {
153
+ verdict: 'unknown',
154
+ unknownReason: 'invalid_as_of',
155
+ warning: looksLikeSeconds
156
+ ? 'asOf looks like epoch seconds; it must be epoch milliseconds or an ISO 8601 string with a timezone'
157
+ : 'asOf is missing or unparseable; use epoch milliseconds or an ISO 8601 string with a timezone (Z or +hh:mm)',
158
+ };
159
+ }
160
+
161
+ const rawAge = now - asOf;
162
+ if (rawAge < -FUTURE_SKEW_TOLERANCE_MS) {
163
+ return { verdict: 'unknown', unknownReason: 'future_as_of', warning: 'asOf is more than 5 s in the future' };
164
+ }
165
+ const dataAgeMs = Math.max(0, Math.round(rawAge));
166
+
167
+ let deadline;
168
+ /** @type {FreshnessAssessment['limitSource']} */
169
+ let limitSource;
170
+ if (overrideMaxAgeMs !== undefined) {
171
+ deadline = asOf + overrideMaxAgeMs;
172
+ limitSource = 'config';
173
+ } else {
174
+ const rawMaxAge = freshness.maxAgeMs;
175
+ const rawFreshUntil = freshness.freshUntil;
176
+ const hasMaxAge = rawMaxAge !== undefined;
177
+ const hasFreshUntil = rawFreshUntil !== undefined;
178
+ if (!hasMaxAge && !hasFreshUntil) {
179
+ return {
180
+ verdict: 'unknown',
181
+ dataAgeMs,
182
+ unknownReason: 'missing_window',
183
+ warning: 'asOf was given without maxAgeMs or freshUntil',
184
+ };
185
+ }
186
+ const freshUntil = hasFreshUntil ? parseTimestamp(rawFreshUntil) : null;
187
+ if ((hasMaxAge && !isValidMaxAge(rawMaxAge)) || (hasFreshUntil && (freshUntil === null || freshUntil < asOf))) {
188
+ return {
189
+ verdict: 'unknown',
190
+ dataAgeMs,
191
+ unknownReason: 'invalid_window',
192
+ warning: 'maxAgeMs must be a finite number >= 0, and freshUntil a timestamp no earlier than asOf',
193
+ };
194
+ }
195
+ // Both given: the earlier deadline wins (ADR 027 §1).
196
+ const fromMaxAge = hasMaxAge ? asOf + /** @type {number} */ (rawMaxAge) : Infinity;
197
+ const fromFreshUntil = freshUntil ?? Infinity;
198
+ if (fromFreshUntil < fromMaxAge) {
199
+ deadline = fromFreshUntil;
200
+ limitSource = 'fresh_until';
201
+ } else {
202
+ deadline = fromMaxAge;
203
+ limitSource = 'max_age_ms';
204
+ }
205
+ }
206
+
207
+ // Cap so a huge window never overflows or reports a non-finite number.
208
+ const maxAgeMs = Math.round(Math.min(deadline, MAX_EPOCH_MS) - asOf);
209
+ // Skew within tolerance counts as "now == asOf".
210
+ const verdict = Math.max(now, asOf) > deadline ? 'stale' : 'fresh';
211
+ return { verdict, dataAgeMs, maxAgeMs, limitSource };
212
+ }
213
+
214
+ /**
215
+ * Per-instrumented-server state for the once-per-tool warning.
216
+ *
217
+ * @returns {{ warnedTools: Set<string> }}
218
+ */
219
+ export function createStalenessState() {
220
+ return { warnedTools: new Set() };
221
+ }
222
+
223
+ /**
224
+ * Reads the freshness key off a successful result and, if present, sets
225
+ * the ADR 027 span attributes and counts a stale response. Never throws.
226
+ *
227
+ * @param {import('@opentelemetry/api').Span} span
228
+ * @param {{ recordStaleResponse: (toolName: string | undefined) => void } | null | undefined} metricsRecorder
229
+ * @param {string | undefined} toolName
230
+ * @param {unknown} result
231
+ * @param {StalenessConfig} config
232
+ * @param {{ warnedTools: Set<string> }} state
233
+ * @param {number} [now]
234
+ */
235
+ export function applyStaleness(span, metricsRecorder, toolName, result, config, state, now = Date.now()) {
236
+ try {
237
+ if (!config?.enabled) return;
238
+ const freshness = readResultMetaField(result, FRESHNESS_META_KEY);
239
+ if (freshness === undefined) return;
240
+
241
+ const override = typeof toolName === 'string' ? config.overrides.get(toolName) : undefined;
242
+ const assessment = assessFreshness(freshness, override, now);
243
+
244
+ span.setAttribute(ATTR_MCP_TOOL_FRESHNESS_VERDICT, assessment.verdict);
245
+ if (assessment.dataAgeMs !== undefined) span.setAttribute(ATTR_MCP_TOOL_FRESHNESS_DATA_AGE_MS, assessment.dataAgeMs);
246
+ if (assessment.maxAgeMs !== undefined) span.setAttribute(ATTR_MCP_TOOL_FRESHNESS_MAX_AGE_MS, assessment.maxAgeMs);
247
+ if (assessment.limitSource) span.setAttribute(ATTR_MCP_TOOL_FRESHNESS_LIMIT_SOURCE, assessment.limitSource);
248
+ if (assessment.unknownReason) span.setAttribute(ATTR_MCP_TOOL_FRESHNESS_UNKNOWN_REASON, assessment.unknownReason);
249
+
250
+ if (assessment.verdict === 'stale') metricsRecorder?.recordStaleResponse(toolName);
251
+ if (assessment.warning) warnOnce(state, toolName, assessment.warning);
252
+ } catch {
253
+ // The thrown value isn't logged: it came from the tool's own object.
254
+ warnOnce(state, toolName, 'reading its freshness _meta threw');
255
+ }
256
+ }
257
+
258
+ /**
259
+ * At most one diag.warn per tool name per server; the set is capped so a
260
+ * flood of distinct names can't grow it. Never throws.
261
+ *
262
+ * @param {{ warnedTools: Set<string> } | undefined} state
263
+ * @param {string | undefined} toolName
264
+ * @param {string} problem
265
+ */
266
+ function warnOnce(state, toolName, problem) {
267
+ try {
268
+ const key = typeof toolName === 'string' ? toolName : '';
269
+ if (!state || state.warnedTools.has(key) || state.warnedTools.size >= MAX_WARNED_TOOLS) return;
270
+ state.warnedTools.add(key);
271
+ diag.warn(
272
+ `opentel-mcp: tool ${JSON.stringify(key.slice(0, 128))} reported malformed freshness _meta (${problem}), ` +
273
+ 'so its responses get no staleness verdict. Further problems with this tool are not logged. See ADR 027.',
274
+ );
275
+ } catch {
276
+ // Never throw into the tool-call path.
277
+ }
278
+ }