opentel-mcp 0.8.0 → 0.9.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.9.0
4
+
5
+ **⚠️ Fixed, with a fingerprint behavior change — read this before the
6
+ feature below.** The `auth` failure classifier
7
+ (`src/fingerprint/classify/auth.js`) missed "permission denied" and
8
+ "access denied" — the standard phrasing from Unix, git, AWS IAM, and GCP
9
+ for a permission/authorization failure. It only recognized
10
+ HTTP-status-derived wording (`unauthorized`, `forbidden`,
11
+ `authenticat(e|ion)`) plus 401/403 status codes and a handful of known
12
+ auth-library error names. Messages using the OS/CLI phrasing above fell
13
+ through every classifier and landed in the `internal` catch-all instead.
14
+ Found by running realistic error text through this project's own UI demo
15
+ (`packages/ui/demo/populate.js`) and checking what `DEFAULT_CLASSIFIERS`
16
+ actually returned for it — not assumed. Now also matches "not authorized",
17
+ "permission(s) denied", "access denied", "insufficient permission(s)", and
18
+ Node's own `EACCES`/`EPERM` error codes; still does not match bare
19
+ "authorized" or "permission" alone (see the classifier's own docblock for
20
+ the false-positive cases this deliberately excludes, e.g. "user denied the
21
+ permission request").
22
+
23
+ **This changes fingerprints for affected messages.** `category` is one of
24
+ the hashed inputs `computeFingerprint()` combines into
25
+ `mcp.failure.fingerprint` (see ADR 006). A permission-denied failure that
26
+ previously classified as `internal` now classifies as `auth` — the fields
27
+ feeding the hash change, so the fingerprint itself changes for anyone whose
28
+ tool emits this wording. This does **not** amend the closed 8-category
29
+ taxonomy ADR 006 established (`validation | timeout | network | auth |
30
+ dependency | serialization | internal | unknown`) — `auth` already existed;
31
+ this is a pattern-coverage fix to when the existing category fires, not a
32
+ new category. If you alert or dashboard on a specific `mcp.failure.fingerprint`
33
+ value for a permission error, expect a new value after upgrading.
34
+
35
+ ### Added — `instanceKey`: sharing tracker state across `instrumentMcpServer()` calls
36
+
37
+ `instanceKey` (a string option on `instrumentMcpServer()`, or the
38
+ `OTEL_MCP_INSTANCE_KEY` env var — lower precedence than the option) lets
39
+ repeated `instrumentMcpServer()` calls that pass the same key share Agent
40
+ Thrash Detection, budget tracking, schema drift detection, and the
41
+ `ToolOutcome` counter's state, instead of each call constructing all four
42
+ fresh and discarding them. Fixes the gap documented in the README's
43
+ "In-memory tracker state is scoped to one `instrumentMcpServer()` call"
44
+ section and `docs/known-gaps.md` entry 6, under a "stateless" Streamable
45
+ HTTP deployment shape (a fresh `Server`/`McpServer` re-instrumented on
46
+ every incoming request). Full design: ADR 012
47
+ (`docs/adr/012-tracker-lifecycle-and-shared-state.md`).
48
+
49
+ Backed by an internal, bounded, TTL-evicting registry (1000 distinct keys
50
+ per process, 24h TTL renewed on every use — both ADR 012's proposed
51
+ defaults) — fully internal, no new public type. Omit `instanceKey` (the
52
+ default) for behavior byte-identical to every prior version: trackers are
53
+ constructed fresh on every call, and the registry is never touched.
54
+
55
+ **⚠️ Composition requirement: `instanceKey` alone does not fix Agent
56
+ Thrash Detection.** `ThrashDetector` looks episodes up by `(sessionId,
57
+ toolName, fingerprint)` — `instanceKey` shares the tracker object, but
58
+ without a real, transport-provided `extra.sessionId` on every call, each
59
+ `instrumentMcpServer()` call still generates its own random per-connection
60
+ fallback session id, fresh, regardless of `instanceKey`. Sharing the
61
+ tracker doesn't help if the lookup key inside it differs every call —
62
+ each request lands as its own one-off episode instead of contributing to
63
+ one shared loop. Real Streamable HTTP transports provide a real session id
64
+ automatically, so the common case works with `instanceKey` alone — but a
65
+ custom `Transport`, `assumeSingleSession: true`, or anything else on the
66
+ generated-fallback path will set `instanceKey`, see nothing happen, and
67
+ have every reason to think the fix is broken. Same silent-inertness shape
68
+ as the original gap, one layer deeper. See the README's new "instanceKey"
69
+ section for the full explanation and what to do about it — this is not a
70
+ footnote there either.
71
+
72
+ **⚠️ Does not help across process boundaries.** `instanceKey`'s registry
73
+ is one process's in-memory state. On Lambda, Cloud Run, or any
74
+ horizontally-scaled deployment, concurrent/recycled instances each hold
75
+ their own independent registry — passing the identical `instanceKey`
76
+ string everywhere does not change that. Counters remain instance-local and
77
+ best-effort by design; this is a structural limitation, not a
78
+ configuration gap, and this library deliberately does not add an external
79
+ store (Redis/DynamoDB) to close it — see ADR 012's Update section for the
80
+ full reasoning.
81
+
82
+ **Documentation:** the "Metrics" section now covers wiring the Prometheus
83
+ exporter specifically (`@opentelemetry/exporter-prometheus`), not just the
84
+ OTLP example that was already there. Its pull-based text exposition format
85
+ does not attach resource attributes (including `service.name`) to
86
+ individual metric points by default — only to a separate `target_info`
87
+ series — which is invisible with one service but means every series looks
88
+ identical the moment you're scraping more than one instrumented server
89
+ into the same Prometheus. Documents the fix
90
+ (`withResourceConstantLabels: /^service\.name$/`) with a worked example.
91
+ No code change; this behavior was always there, just undocumented. Found
92
+ building `dashboards/grafana-mcp-health.json`'s verification harness.
93
+
3
94
  ## 0.8.0
4
95
 
5
96
  Three features. **Tool schema drift detection**: a server that silently
package/README.md CHANGED
@@ -234,6 +234,49 @@ opentel-mcp doesn't bundle them (see `package.json`'s `peerDependencies`).
234
234
  are already runtime dependencies of opentel-mcp itself (its `setupNodeSdk:
235
235
  true` dev path uses them), so no extra install is needed for those two.
236
236
 
237
+ #### Using the Prometheus exporter instead of OTLP — `service.name` needs an extra option
238
+
239
+ If you scrape metrics with `@opentelemetry/exporter-prometheus` (pull-based)
240
+ rather than exporting over OTLP (push-based, the example above), be aware
241
+ that the Prometheus exporter does **not** attach resource attributes —
242
+ including `service.name` — to every metric point by default. It only
243
+ exposes them on a separate `target_info` series, which most PromQL you'd
244
+ actually write (`rate(mcp_tool_calls_total[5m])`, grouped `sum by
245
+ (gen_ai_tool_name)`, etc.) never joins against. In a single-service setup
246
+ this is invisible; the moment you're scraping more than one instrumented
247
+ server into the same Prometheus and need to tell their metrics apart —
248
+ which is exactly what a `service.name` filter/template variable is for —
249
+ every series looks identical without it.
250
+
251
+ Fix: pass `withResourceConstantLabels`, a regex matching the resource
252
+ attribute(s) you want flattened onto every point:
253
+
254
+ ```js
255
+ import { metrics } from '@opentelemetry/api';
256
+ import { MeterProvider } from '@opentelemetry/sdk-metrics';
257
+ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
258
+ import { resourceFromAttributes } from '@opentelemetry/resources';
259
+
260
+ const meterProvider = new MeterProvider({
261
+ resource: resourceFromAttributes({ 'service.name': 'my-mcp-server' }),
262
+ readers: [
263
+ new PrometheusExporter({
264
+ port: 9464,
265
+ withResourceConstantLabels: /^service\.name$/,
266
+ }),
267
+ ],
268
+ });
269
+ metrics.setGlobalMeterProvider(meterProvider);
270
+ ```
271
+
272
+ With this set, `mcp_tool_calls_total{...}` (and every other `mcp.tool.*`
273
+ series) carries a `service_name` label directly, so
274
+ `mcp_tool_calls_total{service_name="my-mcp-server"}` and a Grafana
275
+ `service.name` template variable both work without a `target_info` join.
276
+ See `dashboards/grafana-mcp-health.json` and `dashboards/dev/` in the repo
277
+ for a full worked example (dashboard JSON + a script that exercises this
278
+ exact setup end to end).
279
+
237
280
  ## Failure Fingerprinting (v0.4.0+)
238
281
 
239
282
  Groups logically identical failures under one stable identifier, even
@@ -1401,7 +1444,8 @@ Attribution's budget totals, Tool schema drift detection's per-tool schema
1401
1444
  history, and the Two-axis observation contract's `toolOutcome` counts.
1402
1445
  **All four live inside the object `instrumentMcpServer()` constructs for
1403
1446
  one call — they do not survive past it, and nothing shares state between
1404
- two separate calls.**
1447
+ two separate calls, unless you set `instanceKey` (v0.9.0+ — see the
1448
+ section immediately below).**
1405
1449
 
1406
1450
  This is invisible, and correct, for the deployment shape every one of
1407
1451
  these features was designed against: one `Server`/`McpServer` instance,
@@ -1411,23 +1455,160 @@ instance around across many sessions. It becomes a real problem under a
1411
1455
  different, also-common shape: **"stateless" Streamable HTTP, where a fresh
1412
1456
  `Server` is constructed — and re-instrumented — on every incoming POST.**
1413
1457
  Under that topology, every one of these four trackers is discarded and
1414
- rebuilt from empty before it ever sees a second data point. Nothing
1415
- accumulates, nothing crosses a threshold, and today nothing warns that
1416
- this is happening — the affected feature is silently inert.
1417
-
1418
- **This is a confirmed, currently-unfixed gap, not a hypothetical.**
1419
- Reproduced directly in `test/integration/thrash-stateless-http-lifecycle.test.js`
1420
- (`describe.skip` — a living reproduction, not a working fix): it drives 5
1421
- identical tool failures across 5 separate `instrumentMcpServer()` calls and
1422
- confirms `mcp.tool.loop.detected` never fires, even past the default
1423
- `threshold: 3`, purely because of this lifecycle mismatch. Full
1424
- investigation, root cause across all four trackers, and the design under
1425
- consideration to fix it: ADR 012
1458
+ rebuilt from empty before it ever sees a second data point, unless
1459
+ `instanceKey` is set — and, for Agent Thrash Detection specifically, a
1460
+ real session id is also available on every call. Without both, nothing
1461
+ accumulates, nothing crosses a threshold, and nothing warns that this is
1462
+ happening — the affected feature is silently inert.
1463
+
1464
+ **Confirmed, not a hypothetical, and now has a partial fix.** Reproduced
1465
+ directly in `test/integration/thrash-stateless-http-lifecycle.test.js`:
1466
+ without `instanceKey`, it still drives 5 identical tool failures across 5
1467
+ separate `instrumentMcpServer()` calls and confirms `mcp.tool.loop.detected`
1468
+ never fires, even past the default `threshold: 3` — that remains the
1469
+ default, unchanged behavior when you don't opt in. With a shared
1470
+ `instanceKey` *and* a real session id on every call, the same file's other
1471
+ test confirms it now does. Full investigation, root cause across all four
1472
+ trackers, and the `instanceKey` design: ADR 012
1426
1473
  (`docs/adr/012-tracker-lifecycle-and-shared-state.md`). Tracked in
1427
1474
  `docs/known-gaps.md`.
1428
1475
 
1429
- **If you instrument a fresh `Server`/`McpServer` per request, assume none
1430
- of these four features work as documented until ADR 012's fix ships.**
1476
+ **If you instrument a fresh `Server`/`McpServer` per request: set
1477
+ `instanceKey`.** Read the section immediately below in full before relying
1478
+ on it — it has one required companion for thrash detection specifically
1479
+ (a real session id, not the generated fallback), and it does not help at
1480
+ all across multiple processes or containers (Lambda, Cloud Run, or any
1481
+ horizontally-scaled deployment). Both are easy to miss and produce the
1482
+ exact same silent-inertness symptom as this section describes.
1483
+
1484
+ ## instanceKey: sharing tracker state across instrumentMcpServer() calls (v0.9.0+)
1485
+
1486
+ `instanceKey` (a string option on `instrumentMcpServer()`, or the
1487
+ `OTEL_MCP_INSTANCE_KEY` environment variable) lets repeated
1488
+ `instrumentMcpServer()` calls that pass the same key share the four
1489
+ trackers described above instead of each one resetting to empty. Full
1490
+ design: ADR 012 (`docs/adr/012-tracker-lifecycle-and-shared-state.md`).
1491
+
1492
+ ### When to set it
1493
+
1494
+ Set it when `instrumentMcpServer()` runs more than once per process for
1495
+ what is logically **one** service — the case the previous section
1496
+ describes: a fresh `Server`/`McpServer` constructed and re-instrumented on
1497
+ every incoming request, on an otherwise long-lived process. "Stateless"
1498
+ Streamable HTTP — a fresh `McpServer` per POST, the process itself kept
1499
+ alive — is the common real example. Pick one stable string per logical
1500
+ service and pass the same one on every call:
1501
+
1502
+ ```js
1503
+ instrumentMcpServer(server, { instanceKey: 'my-mcp-server' });
1504
+ ```
1505
+
1506
+ (`serviceName` deliberately omitted here — it only has an effect when
1507
+ `setupNodeSdk: true`, see "Two modes" below; passing it without that logs
1508
+ a one-time `diag.warn` and is unrelated to `instanceKey`, which is
1509
+ orthogonal to how spans/metrics get exported.)
1510
+
1511
+ Omit it (the default) for the normal case — one `Server`/`McpServer`
1512
+ instrumented once and kept alive for the process's life (stdio, or an
1513
+ HTTP server that keeps one instrumented instance around across many
1514
+ sessions). Behavior is byte-identical to every version before v0.9.0:
1515
+ trackers are constructed fresh on every call, and the internal registry is
1516
+ never looked up or written to.
1517
+
1518
+ ### ⚠️ instanceKey alone does not fix thrash detection — read this before relying on it
1519
+
1520
+ > **`instanceKey` shares the tracker OBJECT. Agent Thrash Detection also
1521
+ > needs a real, transport-provided session id on every call — without
1522
+ > both, thrash detection stays silently inert even with `instanceKey`
1523
+ > set.** This is the exact same silent-inertness shape as the original
1524
+ > gap, now hiding behind what looks like a fix. It was found writing this
1525
+ > feature's own regression test, not anticipated in the original design.
1526
+
1527
+ Why: `ThrashDetector` — the tracker `instanceKey` shares — looks up
1528
+ episodes by `(sessionId, toolName, fingerprint)`, not just fingerprint
1529
+ alone (see "Session id resolution" above). Without a real
1530
+ `extra.sessionId`, `instrumentMcpServer()` generates its own random
1531
+ per-connection fallback session id — and it does this **fresh, on every
1532
+ single call**, regardless of `instanceKey`. Sharing the tracker instance
1533
+ doesn't change that: five stateless-HTTP requests sharing one
1534
+ `instanceKey` still each get recorded under a different, unrelated
1535
+ fallback id, so the same shared `ThrashDetector` sees five separate
1536
+ one-off episodes instead of one five-long loop. Nothing ever accumulates
1537
+ past 1, and nothing warns you.
1538
+
1539
+ **Both of these are required together, not either/or:**
1540
+
1541
+ 1. `instanceKey`, so the tracker itself is shared across calls, **and**
1542
+ 2. a real `extra.sessionId` on every call, so the lookup key inside that
1543
+ shared tracker is stable across calls too.
1544
+
1545
+ Real Streamable HTTP transports give you (2) automatically — the SDK
1546
+ threads a real client session id through `extra.sessionId` on every
1547
+ request regardless of whether the `Server` object handling it was just
1548
+ constructed, so the common "stateless Streamable HTTP" case works with
1549
+ `instanceKey` alone, no extra effort. **You will NOT get (2) for free —
1550
+ and thrash detection will stay silently inert despite `instanceKey` being
1551
+ set — if:** you're using a custom `Transport` implementation that never
1552
+ exposes a `sessionId`, you've set `assumeSingleSession: true` (which
1553
+ exists specifically to opt into the generated fallback), or anything else
1554
+ lands on the fallback path described in "Session id resolution" above. If
1555
+ you're in any of those cases, you need your own mechanism for threading a
1556
+ stable, real session identity into each call — `instanceKey` cannot
1557
+ manufacture one for you, and there is no configuration of it that will.
1558
+
1559
+ This composition requirement is specific to Agent Thrash Detection's
1560
+ per-session lookup key. Schema drift detection and the `ToolOutcome`
1561
+ counter have no session-id dependency at all — `instanceKey` alone is
1562
+ sufficient for both. Budget tracking's `perToolUsd` scope is also
1563
+ session-independent; its `perSessionUsd` scope inherits the identical
1564
+ requirement, for the identical reason.
1565
+
1566
+ ### Registry bounds, and what eviction means
1567
+
1568
+ The internal registry `instanceKey` looks trackers up in is bounded, not
1569
+ unbounded (ADR 012's proposed defaults):
1570
+
1571
+ - **Cap:** 1000 distinct `instanceKey` values per process. Normal usage —
1572
+ one stable key per logical service, reused across arbitrarily many
1573
+ calls — should never approach this.
1574
+ - **TTL:** 24 hours, renewed on every use. Every `instrumentMcpServer()`
1575
+ call under a given key resets that key's 24-hour clock, so a busy
1576
+ service's entry never expires from age alone as long as it keeps being
1577
+ used.
1578
+
1579
+ **Eviction mid-use silently resets that key's accumulated state.** Cap
1580
+ pressure (more than 1000 distinct keys in active use) or a key genuinely
1581
+ going quiet for the full 24-hour TTL both mean the *next* call under that
1582
+ key finds nothing and builds fresh trackers — exactly the original bug's
1583
+ own behavior, just now gated behind a much narrower condition than "the
1584
+ next request arrived." Nothing warns when this happens.
1585
+
1586
+ ### Does NOT help across process boundaries — Lambda, Cloud Run, or any recycled/horizontally-scaled deployment
1587
+
1588
+ **Counters are instance-local and best-effort. The registry is an
1589
+ optimization for one process fielding many `instrumentMcpServer()` calls
1590
+ — it is not, and will not become, a distributed-counting mechanism.**
1591
+ `instanceKey`'s registry lives in one process's memory. On Lambda, Cloud
1592
+ Run, or any horizontally-scaled container fleet, concurrent requests are
1593
+ routed across concurrently-running instances, and instances themselves get
1594
+ recycled — passing the identical `instanceKey` string everywhere does
1595
+ **not** change this: each process loads its own copy of the registry and
1596
+ only ever sees the calls actually routed to it. A retry loop of N requests
1597
+ landing on N different instances still resets to empty on every one of
1598
+ them — the same silent inertness this whole feature exists to fix,
1599
+ reached through a different door. There is no `instanceKey` configuration
1600
+ that closes this gap; it is a structural limitation of an in-process
1601
+ registry, not a tuning problem. Full reasoning — including why this
1602
+ library deliberately does not add an external store (Redis, DynamoDB, or
1603
+ similar) to solve it, consistent with its dependency-free posture
1604
+ elsewhere — is in ADR 012's Update section.
1605
+
1606
+ ### Configuration
1607
+
1608
+ `instanceKey?: string` on `instrumentMcpServer()`'s options. Also settable
1609
+ via the `OTEL_MCP_INSTANCE_KEY` environment variable (lower precedence
1610
+ than the option itself). An empty or whitespace-only value from either
1611
+ source is treated the same as omitting it entirely.
1431
1612
 
1432
1613
  ## Two modes
1433
1614
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -9,7 +9,8 @@
9
9
  ".": {
10
10
  "types": "./src/index.d.ts",
11
11
  "default": "./src/index.js"
12
- }
12
+ },
13
+ "./package.json": "./package.json"
13
14
  },
14
15
  "files": [
15
16
  "src",
@@ -25,6 +26,7 @@
25
26
  "test:watch": "vitest",
26
27
  "test:coverage": "vitest run --coverage",
27
28
  "typecheck": "tsc --noEmit",
29
+ "build": "node -e \"console.log('opentel-mcp: no build step — plain JS shipped as-is')\"",
28
30
  "verify:tarball": "node scripts/verify-tarball.js",
29
31
  "bench": "vitest bench --run",
30
32
  "prepack": "node scripts/strip-workspaces.js",
package/src/config.js CHANGED
@@ -76,6 +76,30 @@ import { resolveSchemaDriftConfig } from './schema-drift/config.js';
76
76
  * `enabled: false` (or the default when this option is omitted) is a true no-op: tools/list is not
77
77
  * wrapped at all, unlike thrashDetection/costTracking whose disabled state still wraps tools/call for
78
78
  * other reasons and merely skips inner logic.
79
+ * @property {string} [instanceKey] - Host-supplied stable identifier for one logical service (ADR 012,
80
+ * docs/adr/012-tracker-lifecycle-and-shared-state.md, Option C — Phase 2: this option and its wiring).
81
+ * When provided, the four in-memory trackers this library keeps per instrumented server — budget
82
+ * (src/cost/budget.js), Agent Thrash Detection (src/thrash/detector.js), the ToolOutcome counter
83
+ * (src/observation/tool-outcome-counter.js), and schema drift (src/schema-drift/detector.js) — are looked
84
+ * up from an internal, bounded, TTL-evicting registry (src/registry/instance-registry.js) keyed by this
85
+ * string, instead of being constructed fresh on every instrumentMcpServer() call. Repeated calls that pass
86
+ * the SAME instanceKey therefore share accumulated tracker state — fixing the gap ADR 012 documents under
87
+ * a "fresh Server per request" deployment shape, where every tracker previously reset to empty before ever
88
+ * accumulating anything.
89
+ *
90
+ * Omit (the default, `undefined`) for behavior byte-identical to pre-v0.9.0: trackers are constructed
91
+ * fresh on every call exactly as before, the registry is never looked up or written to, and no extra
92
+ * allocation happens beyond the trackers themselves.
93
+ *
94
+ * Also settable via the `OTEL_MCP_INSTANCE_KEY` environment variable (lower precedence than this option;
95
+ * an empty or whitespace-only value from either source is treated as omitted, matching this codebase's
96
+ * `serviceName` validation). Distinct instanceKey values never share state with each other or with calls
97
+ * that omit the option — each is its own independent registry entry.
98
+ *
99
+ * Passing an unstable value (e.g. a per-request id) silently defeats the whole point while looking
100
+ * configured — see ADR 012's Option C "Against" for why this is a real footgun, not a hypothetical one.
101
+ * Registry bounds (cap, TTL) and known limitations (single-process only — no cross-instance/serverless
102
+ * sharing) are documented in ADR 012, not repeated here.
79
103
  */
80
104
 
81
105
  // Guards the "serviceName has no effect" diagnostic below so it fires once
@@ -89,6 +113,36 @@ export function __resetServiceNameWarnedForTests() {
89
113
  warnedServiceNameIgnored = false;
90
114
  }
91
115
 
116
+ // ADR 012, Phase 2: the first env var this codebase reads for a bare
117
+ // top-level InstrumentOptions field, not one nested inside a feature's own
118
+ // sub-config (contrast OTEL_MCP_THRASH_*/OTEL_MCP_SCHEMA_DRIFT_*, both
119
+ // scoped to their feature's own resolve*Config() module — thrash/config.js's
120
+ // own docblock: "No precedent for env-var-driven config exists elsewhere in
121
+ // this codebase [outside thrash/schema-drift]"). instanceKey has no feature
122
+ // namespace of its own to nest under, so it takes the bare OTEL_MCP_ prefix
123
+ // directly, the same base namespace those two already share.
124
+ const ENV_INSTANCE_KEY = 'OTEL_MCP_INSTANCE_KEY';
125
+
126
+ /**
127
+ * Resolves `instanceKey`: the explicit option wins if it's a non-empty
128
+ * (post-trim) string, else the env var if IT is, else `undefined` — no
129
+ * fallback default beyond that, per ADR 012 ("omit for behavior
130
+ * byte-identical to pre-v0.9.0"). An invalid value from either source
131
+ * (non-string, empty, or whitespace-only) is silently treated as absent,
132
+ * matching this file's own `hasServiceName` validation and this codebase's
133
+ * general "invalid input degrades to the next source, never throws" env-var
134
+ * discipline (thrash/config.js, schema-drift/config.js).
135
+ *
136
+ * @param {unknown} optionValue
137
+ * @param {string | undefined} envValue
138
+ * @returns {string | undefined}
139
+ */
140
+ function resolveInstanceKey(optionValue, envValue) {
141
+ if (typeof optionValue === 'string' && optionValue.trim() !== '') return optionValue;
142
+ if (typeof envValue === 'string' && envValue.trim() !== '') return envValue;
143
+ return undefined;
144
+ }
145
+
92
146
  /**
93
147
  * Validates and applies defaults to raw instrumentMcpServer() options.
94
148
  *
@@ -133,5 +187,6 @@ export function resolveOptions(options) {
133
187
  },
134
188
  thrashDetection: resolveThrashConfig(opts.thrashDetection),
135
189
  schemaDrift: resolveSchemaDriftConfig(opts.schemaDrift),
190
+ instanceKey: resolveInstanceKey(opts.instanceKey, process.env[ENV_INSTANCE_KEY]),
136
191
  };
137
192
  }
@@ -2,15 +2,50 @@
2
2
  * @module fingerprint/classify/auth
3
3
  *
4
4
  * Classifies authn/authz failures: 401/403 status codes, known
5
- * auth-related error names, and generic "unauthorized" / "forbidden" /
6
- * "authenticat(e|ion)" wording.
5
+ * auth-related error names, Node's own OS-level permission error codes,
6
+ * and message wording covering both HTTP-style ("unauthorized",
7
+ * "forbidden") and OS/CLI-style ("permission denied", "access denied")
8
+ * phrasing — the latter verified against real tool output, not guessed:
9
+ *
10
+ * - Node's `fs` module: a permission-denied filesystem operation throws
11
+ * with `err.code === 'EACCES'` and `err.message === "EACCES: permission
12
+ * denied, <syscall> '<path>'"` (confirmed directly against the
13
+ * installed Node runtime — `fs.writeFileSync()`/`readFileSync()` against
14
+ * a path without permission). `EPERM` ("operation not permitted") is
15
+ * Node's other OS-level permission error code, used e.g. when an
16
+ * operation needs elevated privileges.
17
+ * - git: an SSH-auth failure prints OpenSSH's own literal wording,
18
+ * `Permission denied (publickey)`; an HTTPS push without access prints
19
+ * `remote: Permission to <owner>/<repo>.git denied to <user>.`.
20
+ * - AWS: IAM's standard explicit-deny message is "User: ... is *not
21
+ * authorized to perform*: <action> on resource: ..."; S3 and many other
22
+ * services instead return an `AccessDenied` error code/name with
23
+ * message "Access Denied".
24
+ * - GCP: the standard gRPC/REST status is literally `PERMISSION_DENIED`,
25
+ * with messages like "Permission '...' denied on resource ..." or
26
+ * "The caller does not have permission".
27
+ *
28
+ * "insufficient permission(s)" is included as its own phrase — common,
29
+ * distinct wording (SQL Server, Salesforce, Windows UAC-style messages)
30
+ * that doesn't contain "denied" at all.
31
+ *
32
+ * DELIBERATELY NOT matching bare "authorized" or "permission" alone:
33
+ * both are common in non-error application text ("user is authorized to
34
+ * proceed", "permission granted", "user denied the permission request" —
35
+ * that last one is application semantics about a permission *prompt*,
36
+ * not an auth failure, and must NOT match here). Every message pattern
37
+ * below requires the specific denial phrase as a unit ("not authorized",
38
+ * "permission(s) denied", "access denied", "insufficient permission(s)"),
39
+ * not just the presence of an auth-adjacent word.
7
40
  */
8
41
 
9
42
  /** @typedef {import('../types.d.ts').Classifier} Classifier */
10
43
 
11
44
  const KNOWN_STATUSES = new Set([401, 403]);
12
45
  const KNOWN_NAMES = new Set(['UnauthorizedError', 'ForbiddenError', 'AuthError']);
13
- const MESSAGE_RE = /\bunauthorized\b|\bforbidden\b|\bauthenticat/i;
46
+ const KNOWN_CODES = new Set(['EACCES', 'EPERM']);
47
+ const MESSAGE_RE =
48
+ /\bunauthorized\b|\bforbidden\b|\bauthenticat|\bnot authorized\b|\bpermissions? denied\b|\baccess denied\b|\binsufficient permissions?\b/i;
14
49
 
15
50
  /** @type {Classifier} */
16
51
  export default {
@@ -21,6 +56,9 @@ export default {
21
56
  const name = err?.name;
22
57
  if (typeof name === 'string' && KNOWN_NAMES.has(name)) return 'auth';
23
58
 
59
+ const code = err?.code;
60
+ if (typeof code === 'string' && KNOWN_CODES.has(code)) return 'auth';
61
+
24
62
  const message = err?.message ?? '';
25
63
  if (typeof message === 'string' && MESSAGE_RE.test(message)) return 'auth';
26
64
 
@@ -3,26 +3,57 @@
3
3
  *
4
4
  * Extracts which schema field(s) a Zod validation failure named, per ADR
5
5
  * 009 (docs/adr/009-field-level-convergence.md). The MCP TypeScript SDK's
6
- * `getParseErrorMessage()` (server/zod-compat.js) falls back to a
7
- * ZodError's own `.message` getter, which — confirmed against the
8
- * installed `zod` package — is the full JSON-serialized issues array
9
- * (`JSON.stringify(this.issues, ..., 2)`), embedding each failing issue's
10
- * `path`. That JSON survives verbatim into the thrown McpError's message,
11
- * and — same mechanism `classifyFailureChannel()` relies on
6
+ * `getParseErrorMessage()` (server/zod-compat.js) renders a ZodError into
7
+ * a message string that survives verbatim into the thrown McpError's
8
+ * message, and — same mechanism `classifyFailureChannel()` relies on
12
9
  * (fingerprint/classify/channel.js) — verbatim again into McpServer's
13
- * `isError: true` disguise of it.
10
+ * `isError: true` disguise of it. This module parses that rendered text,
11
+ * not a live ZodError: by the time a failure reaches this instrumentation,
12
+ * the structured object is gone (see ADR 009's Q2) — only ever the
13
+ * rendered text remains.
14
14
  *
15
- * This is best-effort, string-parsing extraction, not access to a live
16
- * ZodError: by the time a failure reaches this instrumentation, the
17
- * structured object is gone (see ADR 009's Q2) — only ever the rendered
18
- * text remains. Pure, synchronous, no OTel, never throws: any input that
19
- * doesn't confidently look like a Zod issues array resolves to an empty
20
- * array rather than a wrong or partial guess.
15
+ * TWO rendering formats are parsed, because the SDK has changed this
16
+ * rendering once already (confirmed empirically going from
17
+ * @modelcontextprotocol/sdk@1.29.0 to 1.30.0 — see ADR 009's addendum):
18
+ *
19
+ * 1. JSON issues array — `JSON.stringify(error.issues, ..., 2)`, the
20
+ * whole-array-with-`path`-fields shape `getParseErrorMessage()` used
21
+ * through SDK 1.29.0 (and still what you get if a low-level `Server`
22
+ * author throws a raw, unrendered `ZodError` directly — nothing
23
+ * guarantees they've upgraded, or ever rendered it through the SDK's
24
+ * helper at all).
25
+ * 2. Rendered "`<message> at <dotPath>`" lines, one per issue, joined by
26
+ * `\n` — what `getParseErrorMessage()` produces as of SDK 1.30.0.
27
+ * Root-level issues (empty `path`) render as just `<message>`, no
28
+ * " at " suffix at all.
29
+ *
30
+ * JSON is tried first (it's self-validating — a successful, Zod-issue-
31
+ * shaped parse is strong evidence either way — see `parseZodIssuesArray`)
32
+ * and unconditionally preferred when it confidently matches. The rendered
33
+ * form is tried only as a fallback, and only within a message that already
34
+ * confirms it's SDK-validation-shaped text (see `extractRenderedPaths`) —
35
+ * unlike the JSON path, a bare "<x> at <y>" line is not self-validating,
36
+ * so this format leans on the same INPUT/OUTPUT_VALIDATION marker
37
+ * `classify/channel.js` already trusts (ADR 007) rather than pattern-
38
+ * matching arbitrary text.
39
+ *
40
+ * This is best-effort, string-parsing extraction. Pure, synchronous, no
41
+ * OTel, never throws: any input that doesn't confidently look like either
42
+ * known rendering resolves to an empty array rather than a wrong or
43
+ * partial guess.
21
44
  *
22
45
  * Standalone, like channel.js: not wired into computeFingerprint() (the
23
46
  * path text is already implicit in the hashed normalized message — see
24
47
  * ADR 009's "Where would extraction belong" — so hashing it again would
25
48
  * be redundant, not more correct) and not re-exported from src/index.js.
49
+ *
50
+ * FRAGILITY, stated plainly: this is now coupled to TWO independent SDK
51
+ * rendering formats instead of one. A third format in a future SDK
52
+ * version breaks this again, exactly the way 1.30.0 broke the
53
+ * JSON-only version — and the failure mode is silent: `[]`, not an
54
+ * exception, not a warning. `test/fingerprint/classify.validation-paths.test.js`'s
55
+ * SDK-version pin exists specifically so that a future SDK bump fails a
56
+ * test loudly instead of only degrading data quality unnoticed.
26
57
  */
27
58
 
28
59
  /**
@@ -133,6 +164,85 @@ function parseZodIssuesArray(jsonText) {
133
164
  return paths;
134
165
  }
135
166
 
167
+ // Same literal markers classify/channel.js already trusts (ADR 007) as the
168
+ // SDK's own convention for distinguishing input- vs output-validation
169
+ // -32602 failures from every other condition sharing that code. Reused
170
+ // here as the precondition for attempting rendered-format extraction at
171
+ // all: extractRenderedPaths() only ever runs on text that already
172
+ // contains one of these, never on arbitrary business-logic text (e.g. a
173
+ // tool's own "please look at docs" message), since a bare "<x> at <y>"
174
+ // line is not self-validating the way a Zod-issue-shaped JSON array is.
175
+ const INPUT_VALIDATION_MARKER = 'Input validation error:';
176
+ const OUTPUT_VALIDATION_MARKER = 'Output validation error:';
177
+
178
+ // A dotPath segment as SDK 1.30.0's getDotPath() renders it: an
179
+ // identifier-like key, or `[digits]` for an array index, chained with `.`
180
+ // or `[...]`. Deliberately excludes whitespace and punctuation other than
181
+ // `.`/`_`/`$`/brackets, so a suffix like "least 5" (the tail of a
182
+ // root-level message that happens to contain the substring " at ", e.g.
183
+ // "Number must be at least 5") fails this pattern and is correctly not
184
+ // mistaken for a path -- see this function's docblock for the residual
185
+ // risk that remains even with this restriction.
186
+ const RENDERED_DOT_PATH_RE = /^[\w$]+(?:\.[\w$]+|\[\d+\])*$/;
187
+
188
+ /**
189
+ * Normalizes a rendered dotPath's `[digit]` array-index segments to `.digit`,
190
+ * matching parseZodIssuesArray()'s `path.join('.')` output. Chosen over
191
+ * leaving brackets as-is so both extraction paths (JSON and rendered) agree
192
+ * on canonical form for the same logical field -- callers/operators
193
+ * comparing `mcp.failure.validation_paths` values across SDK versions (or
194
+ * across the low-level Server's JSON-rendered path and McpServer's
195
+ * SDK-rendered path in the same fleet) should never see "items[3]" from one
196
+ * and "items.3" from the other for what is, semantically, the same path.
197
+ *
198
+ * @param {string} dotPath
199
+ * @returns {string}
200
+ */
201
+ function normalizeRenderedDotPath(dotPath) {
202
+ return dotPath.replace(/\[(\d+)\]/g, '.$1');
203
+ }
204
+
205
+ /**
206
+ * Parses SDK 1.30.0+'s rendered `"<message> at <dotPath>"` format: one
207
+ * issue per line, joined by `\n`, root-level issues (empty `path`)
208
+ * rendering as just `<message>` with no " at " suffix at all.
209
+ *
210
+ * Only attempted when `text` contains the SDK's own input/output
211
+ * validation marker (see INPUT_VALIDATION_MARKER above) -- this format,
212
+ * unlike the JSON array, is not self-validating on its own (any English
213
+ * sentence ending in "at <word>" would otherwise look plausible), so this
214
+ * gate is the load-bearing precondition that keeps this from firing on
215
+ * unrelated business-logic failure text.
216
+ *
217
+ * Per-line, best-effort: a line with no confidently-path-shaped " at "
218
+ * suffix (root-level issues; a message whose own text happens to contain
219
+ * " at " followed by more prose) is silently skipped, not treated as
220
+ * disqualifying the whole message -- unlike parseZodIssuesArray()'s
221
+ * all-or-nothing JSON handling, a real Zod validation failure routinely
222
+ * mixes root-level and field-level issues in the same result, so a
223
+ * per-line miss here is expected, not a sign of corruption.
224
+ *
225
+ * @param {string} text
226
+ * @returns {readonly string[]}
227
+ */
228
+ function extractRenderedPaths(text) {
229
+ if (!text.includes(INPUT_VALIDATION_MARKER) && !text.includes(OUTPUT_VALIDATION_MARKER)) {
230
+ return [];
231
+ }
232
+
233
+ const paths = [];
234
+ for (const line of text.split('\n')) {
235
+ const atIndex = line.lastIndexOf(' at ');
236
+ if (atIndex === -1) continue;
237
+
238
+ const suffix = line.slice(atIndex + 4);
239
+ if (!RENDERED_DOT_PATH_RE.test(suffix)) continue;
240
+
241
+ paths.push(normalizeRenderedDotPath(suffix));
242
+ }
243
+ return paths;
244
+ }
245
+
136
246
  /**
137
247
  * Extracts which schema field(s) a validation failure named, from
138
248
  * whichever shape the caller has in hand: a CallToolResult with
@@ -140,10 +250,14 @@ function parseZodIssuesArray(jsonText) {
140
250
  * McpServer's disguised protocol failures live, see this module's
141
251
  * docblock), or an error-like object with a string `.message`.
142
252
  *
253
+ * Tries the JSON issues array first (SDK <=1.29.0, and any low-level
254
+ * `Server` author who throws a raw, unrendered `ZodError`); falls back to
255
+ * the SDK 1.30.0+ rendered "<message> at <path>" format only when no
256
+ * confident JSON match was found. See this module's docblock for why both
257
+ * exist and the fragility of depending on either.
258
+ *
143
259
  * Never throws, never guesses: returns `[]` — not a partial or
144
- * best-guess result — whenever the message is missing, doesn't contain a
145
- * balanced JSON array, or that array doesn't confidently look like a Zod
146
- * issues array.
260
+ * best-guess result — whenever neither format confidently matches.
147
261
  *
148
262
  * @param {unknown} failure
149
263
  * @returns {readonly string[]} One dot-joined path per failing issue
@@ -157,9 +271,12 @@ export function extractValidationPaths(failure) {
157
271
  if (typeof text !== 'string') return [];
158
272
 
159
273
  const jsonSubstring = extractJsonArraySubstring(text);
160
- if (jsonSubstring === null) return [];
274
+ if (jsonSubstring !== null) {
275
+ const jsonPaths = parseZodIssuesArray(jsonSubstring);
276
+ if (jsonPaths !== null) return jsonPaths;
277
+ }
161
278
 
162
- return parseZodIssuesArray(jsonSubstring) ?? [];
279
+ return extractRenderedPaths(text);
163
280
  } catch {
164
281
  return [];
165
282
  }
package/src/index.d.ts CHANGED
@@ -114,6 +114,34 @@ export interface InstrumentOptions {
114
114
  * (`src/schema-drift/types.d.ts`) for the full field list and defaults.
115
115
  */
116
116
  schemaDrift?: Partial<SchemaDriftConfig>;
117
+
118
+ /**
119
+ * Host-supplied stable identifier for one logical service (ADR 012,
120
+ * `docs/adr/012-tracker-lifecycle-and-shared-state.md`, Option C). When provided, the four in-memory
121
+ * trackers this library keeps per instrumented server — the budget tracker, Agent Thrash Detection, the
122
+ * `ToolOutcome` counter, and schema drift detection — are looked up from an internal, bounded,
123
+ * TTL-evicting registry keyed by this string instead of being constructed fresh on every
124
+ * {@link instrumentMcpServer} call. Repeated calls that pass the SAME `instanceKey` therefore share
125
+ * accumulated tracker state — fixing the gap ADR 012 documents under a "fresh Server per request"
126
+ * deployment shape, where every tracker previously reset to empty before ever accumulating anything.
127
+ *
128
+ * Omit (the default, `undefined`) for behavior byte-identical to pre-v0.9.0: trackers are constructed
129
+ * fresh on every call exactly as before, and the registry is never looked up or written to.
130
+ *
131
+ * Also settable via the `OTEL_MCP_INSTANCE_KEY` environment variable (lower precedence than this option;
132
+ * an empty or whitespace-only value from either source is treated as omitted). Distinct `instanceKey`
133
+ * values never share state with each other or with calls that omit the option.
134
+ *
135
+ * The registry itself (`src/registry/instance-registry.js`) stays fully internal — there is no public
136
+ * type for it, and none is needed: nothing in this package's public API accepts or returns a registry
137
+ * instance, so a consumer only ever interacts with this feature through this one string field.
138
+ *
139
+ * Passing an unstable value (e.g. a per-request id) silently defeats the whole point while looking
140
+ * configured — see ADR 012's Option C "Against" for why this is a real footgun, not a hypothetical one.
141
+ * Registry bounds (cap, TTL) and known limitations (single-process only — no cross-instance/serverless
142
+ * sharing) are documented in ADR 012, not repeated here.
143
+ */
144
+ instanceKey?: string;
117
145
  }
118
146
 
119
147
  /**
package/src/instrument.js CHANGED
@@ -25,6 +25,7 @@ import { SchemaDriftDetector } from './schema-drift/detector.js';
25
25
  import { createSchemaDriftEmitter } from './schema-drift/emitter.js';
26
26
  import { ToolOutcomeCounter } from './observation/tool-outcome-counter.js';
27
27
  import { detectObservationIntegrity } from './observation/integrity.js';
28
+ import { InstanceRegistry } from './registry/instance-registry.js';
28
29
  import {
29
30
  ATTR_MCP_METHOD_NAME,
30
31
  ATTR_GEN_AI_TOOL_NAME,
@@ -79,6 +80,82 @@ const SCHEMA_DRIFT_SCOPE = 'server';
79
80
  // (e.g. monorepos with dedup issues), not just within one module instance.
80
81
  const kInstrumented = Symbol.for('opentel-mcp/instrumented');
81
82
 
83
+ // ADR 012, Phase 2 (docs/adr/012-tracker-lifecycle-and-shared-state.md,
84
+ // Option C): one registry for the whole process, constructed once here at
85
+ // module load. This is the only architecture under which repeated
86
+ // instrumentMcpServer() calls that share an instanceKey can actually share
87
+ // tracker state — a per-call local variable couldn't be looked up again by
88
+ // a later, unrelated call, and there is no other persistent scope this
89
+ // library could reach for that doesn't require the host to hold and pass a
90
+ // reference itself (ADR 012's Option B, rejected). Confirmed with the ADR's
91
+ // author before implementing: the original Decision text never states this
92
+ // explicitly (only the REJECTED Option A is described as "module-level"),
93
+ // so this is a deliberate implementation choice filling a gap the ADR left
94
+ // open, not a restatement of something it already said.
95
+ //
96
+ // Constructing this costs one empty BoundedTtlMap — negligible, and
97
+ // unconditional regardless of whether any call ever supplies instanceKey,
98
+ // since the registry must already exist the first time one does. This is
99
+ // NOT the same thing as the "no allocation when instanceKey is unset"
100
+ // guarantee instrumentMcpServer() makes below — that guarantee is about
101
+ // PER-CALL allocation of registry entries/trackers, which getOrCreateTracker()
102
+ // (below) skips entirely when instanceKey is undefined; a one-time,
103
+ // process-lifetime empty Map is unrelated to and unaffected by that.
104
+ const instanceRegistry = new InstanceRegistry();
105
+
106
+ // Test-only: lets tests get a clean registry regardless of what earlier
107
+ // tests in the same file already populated it with (the singleton above is
108
+ // shared for the lifetime of this module instance — see its own comment).
109
+ // Not part of the public API.
110
+ export function __resetInstanceRegistryForTests() {
111
+ instanceRegistry.clear();
112
+ }
113
+
114
+ // Test-only: exposes the singleton's current size so tests can assert
115
+ // "instanceKey omitted -> the registry is never touched" directly, rather
116
+ // than only inferring it from tracker identity. Not part of the public API.
117
+ export function __getInstanceRegistrySizeForTests() {
118
+ return instanceRegistry.size;
119
+ }
120
+
121
+ /**
122
+ * Looks up (or constructs) one of the four ADR-012 trackers. When
123
+ * `instanceKey` is `undefined`, calls `factory()` directly and never
124
+ * touches `registry` at all — this is what keeps the default (instanceKey
125
+ * omitted) path byte-identical to pre-v0.9.0 behavior: no registry lookup,
126
+ * no registry write, no allocation beyond the tracker itself, exactly as
127
+ * before this phase existed.
128
+ *
129
+ * The key is namespaced per tracker type (`${instanceKey}:${trackerSuffix}`)
130
+ * rather than using the raw `instanceKey` directly for all four. ADR 012's
131
+ * Decision text never addresses this: it describes "instrument.js looks up
132
+ * or creates each of the four trackers in an internal, bounded registry
133
+ * keyed by that string" without saying whether that means one shared entry
134
+ * per key (bundling all four trackers into one cached value) or one entry
135
+ * per (key, tracker type) pair. Left as a genuine, unaddressed gap. Chosen
136
+ * here: per-tracker-type namespacing, on ONE shared InstanceRegistry
137
+ * instance — the four tracker types can never collide on the same registry
138
+ * entry (a `ThrashDetector` can never be handed back where a budget tracker
139
+ * was expected, or vice versa), at the cost of all four trackers, across
140
+ * every instanceKey a process uses, sharing one bounded cap/TTL rather than
141
+ * each tracker type getting its own independent bound. Flagged explicitly
142
+ * as a choice, not a rediscovery of something the ADR already decided —
143
+ * four separate InstanceRegistry instances (one per tracker type) would
144
+ * have achieved the same non-collision guarantee structurally, without
145
+ * relying on string-namespace hygiene, and remains a reasonable alternative
146
+ * if independent per-tracker-type bounds turn out to matter in practice.
147
+ *
148
+ * @template V
149
+ * @param {string | undefined} instanceKey
150
+ * @param {string} trackerSuffix - e.g. 'thrash', 'budget', 'tool-outcome', 'schema-drift'.
151
+ * @param {() => V} factory
152
+ * @returns {V}
153
+ */
154
+ function getOrCreateTracker(instanceKey, trackerSuffix, factory) {
155
+ if (instanceKey === undefined) return factory();
156
+ return instanceRegistry.getOrCreate(`${instanceKey}:${trackerSuffix}`, factory);
157
+ }
158
+
82
159
  const UNSUPPORTED_INPUT_ERROR =
83
160
  'opentel-mcp: instrumentMcpServer() expects either a low-level Server ' +
84
161
  'instance (from @modelcontextprotocol/sdk/server/index.js) or a ' +
@@ -191,13 +268,23 @@ export function instrumentMcpServer(input, options) {
191
268
  // One tracker per instrumented server, not per call — session/tool cost
192
269
  // must accumulate across the server's whole lifetime (see
193
270
  // src/cost/budget.js). A no-op tracker when costTracking.budget is unset.
194
- const budgetTracker = createBudgetTracker(resolved.costTracking.budget);
271
+ //
272
+ // ADR 012, Phase 2: when resolved.instanceKey is set, this is looked up
273
+ // from (or, on first use, created in) the process-wide instanceRegistry
274
+ // instead of constructed fresh — see getOrCreateTracker()'s own docblock.
275
+ // When instanceKey is undefined (the default), this line behaves exactly
276
+ // as it did before this phase existed: factory() runs unconditionally,
277
+ // the registry is never touched.
278
+ const budgetTracker = getOrCreateTracker(resolved.instanceKey, 'budget', () =>
279
+ createBudgetTracker(resolved.costTracking.budget),
280
+ );
195
281
  // Same one-per-server lifetime as budgetTracker above — thrash episodes
196
282
  // accumulate across calls, not within one (see src/thrash/detector.js).
197
283
  // Constructed unconditionally, same as budgetTracker: resolved.thrashDetection.enabled
198
284
  // gates per-call work (applyThrashDetection/applyThrashSuccessClear
199
- // below), not this one-time setup.
200
- const thrashDetector = new ThrashDetector(resolved.thrashDetection);
285
+ // below), not this one-time setup. Same ADR-012/instanceKey wiring as
286
+ // budgetTracker above.
287
+ const thrashDetector = getOrCreateTracker(resolved.instanceKey, 'thrash', () => new ThrashDetector(resolved.thrashDetection));
201
288
  const thrashEmitter = resolved.enableMetrics ? createThrashEmitter(PACKAGE_VERSION) : null;
202
289
  // MCP sessions have a transport-provided id (extra.sessionId below) for
203
290
  // session-oriented transports, but stdio has none — there's exactly one
@@ -241,8 +328,9 @@ export function instrumentMcpServer(input, options) {
241
328
  // optional sub-feature. Deliberately NOT gated on fingerprinting,
242
329
  // thrashDetection, or enableMetrics — see ToolOutcomeCounter's own
243
330
  // docblock for why (Finding 3: those flags gate OTHER bookkeeping this
244
- // counter must stay independent of).
245
- const toolOutcomeCounter = new ToolOutcomeCounter();
331
+ // counter must stay independent of). Same ADR-012/instanceKey wiring as
332
+ // budgetTracker/thrashDetector above.
333
+ const toolOutcomeCounter = getOrCreateTracker(resolved.instanceKey, 'tool-outcome', () => new ToolOutcomeCounter());
246
334
  // Additive to instrumentMcpServer()'s existing return contract, same
247
335
  // pattern as getThrashSummary above: a getObservationState() method,
248
336
  // unconditional (not gated on setupNodeSdk), omitted entirely when
@@ -271,9 +359,17 @@ export function instrumentMcpServer(input, options) {
271
359
  // per-call use — is what actually delivers "no allocation when
272
360
  // disabled." schemaDriftEmitter is additionally gated on enableMetrics,
273
361
  // mirroring thrashEmitter exactly: detection/state-tracking is
274
- // independent of metrics on/off, only emission is gated.
362
+ // independent of metrics on/off, only emission is gated. The
363
+ // instanceKey/registry lookup below only runs when schemaDrift.enabled —
364
+ // "no allocation when disabled" takes priority over the ADR-012 wiring,
365
+ // exactly as it already does over every other option here; there is
366
+ // nothing to share across calls for a feature that isn't running at all.
275
367
  const schemaDriftDetector = resolved.schemaDrift.enabled
276
- ? new SchemaDriftDetector({ maxTrackedTools: resolved.schemaDrift.maxTrackedTools })
368
+ ? getOrCreateTracker(
369
+ resolved.instanceKey,
370
+ 'schema-drift',
371
+ () => new SchemaDriftDetector({ maxTrackedTools: resolved.schemaDrift.maxTrackedTools }),
372
+ )
277
373
  : null;
278
374
  const schemaDriftEmitter =
279
375
  resolved.schemaDrift.enabled && resolved.enableMetrics ? createSchemaDriftEmitter(PACKAGE_VERSION) : null;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @module registry/instance-registry
3
+ *
4
+ * Phase 1 of the `instanceKey` design (ADR 012, Option C,
5
+ * docs/adr/012-tracker-lifecycle-and-shared-state.md — "Registry bounds:
6
+ * must be a BoundedTtlMap, not an unbounded global"). This module is the
7
+ * keyed lookup-or-create registry itself, in isolation: no wiring into
8
+ * `instrumentMcpServer()`, no `instanceKey` option, no config surface, no
9
+ * `diag.warn`. Those are later phases. What exists here is the generic
10
+ * mechanism a later phase will use to look up or construct the four
11
+ * ADR-012 trackers (`ThrashDetector`, the budget tracker,
12
+ * `ToolOutcomeCounter`, `SchemaDriftDetector`) by a host-supplied string
13
+ * key, instead of constructing them unconditionally as local variables —
14
+ * so repeated `instrumentMcpServer()` calls sharing a key can share their
15
+ * state.
16
+ *
17
+ * Deliberately generic over the cached value's shape (this module knows
18
+ * nothing about trackers) — same reasoning `src/thrash/store.js`'s own
19
+ * docblock already gives for keeping `BoundedTtlMap` itself free of any
20
+ * thrash-specific logic: this class exists purely to answer "have I
21
+ * already built something for this key, in this process?", which is a
22
+ * useful question independent of what "something" is.
23
+ *
24
+ * Built on `BoundedTtlMap` (`src/thrash/store.js`), reused as-is rather
25
+ * than duplicated — that module already is the bounded, lazily-expiring,
26
+ * no-timer store this needs, and ADR 012 explicitly rejected inventing a
27
+ * second one. Imported across the `thrash/` directory boundary the same
28
+ * way `src/thrash/emitter.js` already imports from `../fingerprint/attributes.js`
29
+ * and `src/schema-drift/emitter.js` imports from `../attributes.js` — this
30
+ * codebase already treats a specific, deliberately-generic module as
31
+ * reusable across feature directories, not as private to the directory it
32
+ * happens to live in.
33
+ *
34
+ * Defaults: `maxSize` 1000, `ttlMs` 86_400_000 (24h). ADR 012 proposes
35
+ * both explicitly, in the same range as `thrashDetection.maxTrackedKeys`/
36
+ * `schemaDrift.maxTrackedTools` (1000) for the cap, and a "day-scale
37
+ * default... a defensible starting point, explicitly a starting point
38
+ * pending real deployment feedback, not a value this design derives from
39
+ * first principles" for the TTL — carried over here verbatim, not
40
+ * re-decided. Both remain overridable via the constructor for whichever
41
+ * later phase adds a config surface.
42
+ */
43
+
44
+ import { BoundedTtlMap } from '../thrash/store.js';
45
+
46
+ /** @type {number} See module docblock — ADR 012's proposed default, same range as thrashDetection.maxTrackedKeys/schemaDrift.maxTrackedTools. */
47
+ export const DEFAULT_MAX_INSTANCES = 1000;
48
+
49
+ /** @type {number} 24 hours. See module docblock — ADR 012's proposed "day-scale... starting point" default. */
50
+ export const DEFAULT_TTL_MS = 86_400_000;
51
+
52
+ /**
53
+ * `V` must never be `undefined` — `getOrCreate()` uses `undefined` (the
54
+ * same convention `BoundedTtlMap.get()`/`Map.get()` already use) as its
55
+ * own "nothing here" signal, so a factory that legitimately produces
56
+ * `undefined` would be indistinguishable from a miss and get rebuilt on
57
+ * every call. Not enforced at runtime (matching this codebase's general
58
+ * preference for documented contracts over defensive runtime checks on
59
+ * internal-only modules); the four ADR-012 trackers a later phase will
60
+ * cache here are always plain objects/class instances, never `undefined`.
61
+ *
62
+ * @template V
63
+ */
64
+ export class InstanceRegistry {
65
+ /** @type {BoundedTtlMap<string, V>} */
66
+ #store;
67
+
68
+ /**
69
+ * @param {object} [options]
70
+ * @param {number} [options.maxSize] - @default DEFAULT_MAX_INSTANCES
71
+ * @param {number} [options.ttlMs] - @default DEFAULT_TTL_MS
72
+ * @param {() => number} [options.clock] - Injectable for deterministic tests; forwarded to BoundedTtlMap, defaults to Date.now there.
73
+ */
74
+ constructor({ maxSize = DEFAULT_MAX_INSTANCES, ttlMs = DEFAULT_TTL_MS, clock } = {}) {
75
+ this.#store = clock !== undefined ? new BoundedTtlMap(maxSize, ttlMs, clock) : new BoundedTtlMap(maxSize, ttlMs);
76
+ }
77
+
78
+ /**
79
+ * Removes every entry. Not part of normal operation (nothing in this
80
+ * module's own logic ever calls it) — added for host/test code that
81
+ * needs to explicitly reset a registry instance (e.g. a module-level
82
+ * singleton reused across many tests in one file). Implemented via
83
+ * `entries()` + `delete()` rather than reaching into `BoundedTtlMap`
84
+ * directly, so this stays a pure consumer of that module's existing
85
+ * public surface, same as every other method here.
86
+ */
87
+ clear() {
88
+ for (const [key] of this.#store.entries()) {
89
+ this.#store.delete(key);
90
+ }
91
+ }
92
+
93
+ /** Current entry count. Forwards BoundedTtlMap's own caveat: may include not-yet-swept expired entries. */
94
+ get size() {
95
+ return this.#store.size;
96
+ }
97
+
98
+ /**
99
+ * Looks up `key`. If a live (non-expired) entry exists, its TTL is
100
+ * renewed — `.set()` is called again with the SAME value — and the
101
+ * existing value is returned, without calling `factory`. If absent or
102
+ * expired, `factory()` is called to construct a fresh value, which is
103
+ * then stored under `key` and returned.
104
+ *
105
+ * This re-`set()`-on-hit behavior is the point ADR 012 calls out by
106
+ * name as "the subtle correctness point": `BoundedTtlMap`'s own
107
+ * documented semantics are TTL-from-`set()`-time, `get()` alone
108
+ * refreshes LRU order but NOT expiry. Reused as a plain get-then-set,
109
+ * a busy, continuously-used key would still expire on a fixed schedule
110
+ * from its first `set()`, regardless of how often it's subsequently
111
+ * hit — exactly undermining the point of giving it a stable key. This
112
+ * method is the wiring ADR 012 specifies to fix that: the hit branch
113
+ * below calls `.set()` unconditionally, not only the miss branch,
114
+ * producing sliding-window "used it, so keep it" expiry at the call
115
+ * site without needing to modify `BoundedTtlMap` itself.
116
+ *
117
+ * Never throws due to this method's own lookup/renewal/insert
118
+ * bookkeeping — a failure in any of those steps (e.g. a broken
119
+ * `clock`) is treated as a cache miss and falls through to `factory()`,
120
+ * matching this codebase's fail-open discipline for every other
121
+ * tracker (see `src/cost/budget.js`'s `recordAndCheck()`, which applies
122
+ * the same standard to its own bookkeeping). `factory()` itself is
123
+ * deliberately NOT caught here: it is the caller's own construction
124
+ * logic, there is no safe substitute value this registry could invent
125
+ * in its place, and silently swallowing a broken factory would hide a
126
+ * real bug behind a misleadingly-plain `undefined` return instead of
127
+ * surfacing it. If `factory()` throws, that exception propagates to
128
+ * the caller unchanged.
129
+ *
130
+ * Eviction (LRU cap pressure, or the renewed TTL genuinely elapsing) is
131
+ * NOT distinguished from "never seen this key before" — both are a
132
+ * miss, and both silently construct fresh via `factory()`. This is
133
+ * ADR 012's own decision, not an oversight: "Whether the registry
134
+ * should also distinguish 'brand-new key' from 'key seen before but
135
+ * since evicted' (and warn differently for each) was considered and is
136
+ * deliberately left open... a considered enhancement for whoever
137
+ * implements this, not a requirement." An evicted-then-re-requested key
138
+ * losing its accumulated state is the original ADR-012 bug, in
139
+ * miniature — a real, accepted reduction in blast radius (it now
140
+ * requires actual TTL-scale idleness or cap pressure to trigger,
141
+ * instead of firing on every call), not a closure of the failure mode.
142
+ *
143
+ * @param {string} key
144
+ * @param {() => V} factory
145
+ * @returns {V}
146
+ */
147
+ getOrCreate(key, factory) {
148
+ try {
149
+ const existing = this.#store.get(key);
150
+ if (existing !== undefined) {
151
+ this.#store.set(key, existing); // renew TTL on hit — see method docblock
152
+ return existing;
153
+ }
154
+ } catch {
155
+ // Never throw due to this registry's own bookkeeping — treat as a
156
+ // miss and fall through to factory(). See method docblock.
157
+ }
158
+
159
+ const created = factory();
160
+
161
+ try {
162
+ this.#store.set(key, created);
163
+ } catch {
164
+ // Caching failed, but factory() already succeeded — still hand the
165
+ // caller a usable value rather than losing it to a store-layer bug.
166
+ }
167
+
168
+ return created;
169
+ }
170
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared type definitions for the `instanceKey` registry (ADR 012, Phase
3
+ * 1 — docs/adr/012-tracker-lifecycle-and-shared-state.md).
4
+ *
5
+ * Hand-written, not a compiled build artifact — this project ships plain
6
+ * JS with no TypeScript build step (see CONTRIBUTING.md). Exists purely
7
+ * so TypeScript consumers (and editors) get accurate types for
8
+ * `src/registry/instance-registry.js`, the same pattern
9
+ * `src/thrash/types.d.ts` and `src/fingerprint/types.d.ts` already use.
10
+ *
11
+ * Internal — this module is not re-exported from `src/index.js`/
12
+ * `src/index.d.ts`. Phase 1 only: no `instanceKey` option exists yet on
13
+ * `instrumentMcpServer()`, so there is no public-facing type here either.
14
+ */
15
+
16
+ /**
17
+ * Constructor options for {@link InstanceRegistry}.
18
+ */
19
+ export interface InstanceRegistryOptions {
20
+ /** Hard cap on distinct keys. Least-recently-used keys are evicted past this. @default 1000 */
21
+ maxSize?: number;
22
+ /** Time-to-live (ms) from the moment a key's entry is last (re-)set — see `getOrCreate()`'s renew-on-hit behavior. @default 86400000 (24h) */
23
+ ttlMs?: number;
24
+ /** Returns "now" in epoch ms. Injectable for deterministic tests; defaults to `Date.now`. */
25
+ clock?: () => number;
26
+ }
27
+
28
+ /**
29
+ * A bounded, TTL-evicting, lookup-or-create registry keyed by a
30
+ * host-supplied string (the future `instanceKey` option). Generic over
31
+ * the cached value's shape — see `src/registry/instance-registry.js`'s
32
+ * own docblock for why this module knows nothing about trackers.
33
+ *
34
+ * @template V - Must never be `undefined` — see the `.js` module's docblock.
35
+ */
36
+ export declare class InstanceRegistry<V> {
37
+ constructor(options?: InstanceRegistryOptions);
38
+
39
+ /** Current entry count. May include not-yet-swept expired entries — same caveat as `BoundedTtlMap.size`. */
40
+ readonly size: number;
41
+
42
+ /**
43
+ * Looks up `key`; on a live hit, renews its TTL and returns the
44
+ * existing value without calling `factory`. On a miss (absent,
45
+ * expired, or evicted), calls `factory()`, stores the result under
46
+ * `key`, and returns it. Never throws due to this method's own
47
+ * lookup/renewal/insert bookkeeping; a throwing `factory` propagates
48
+ * unchanged. See the `.js` module's docblock for the full contract,
49
+ * including why evicted-vs-never-seen is deliberately undistinguished.
50
+ */
51
+ getOrCreate(key: string, factory: () => V): V;
52
+
53
+ /** Removes every entry. Not used by this module's own logic — for host/test code that needs an explicit reset. */
54
+ clear(): void;
55
+ }
@@ -2,14 +2,20 @@
2
2
  * @module thrash/config
3
3
  * Options parsing and defaults for Agent Thrash Detection (v0.6.0).
4
4
  *
5
- * No precedent for env-var-driven config exists elsewhere in this
6
- * codebase (src/config.js's resolveOptions() only reads from its
7
- * `options` argument) — this module introduces the pattern for the first
8
- * time rather than following an established one. Precedence, highest to
9
- * lowest: explicit field on the `partial` argument, then the field's
10
- * `OTEL_MCP_THRASH_*` env var, then the hardcoded default. An invalid or
11
- * unparseable env value is treated exactly like an absent one — silent
12
- * fallback to the next source, never a throw.
5
+ * This module introduced env-var-driven config to this codebase first —
6
+ * at the time, src/config.js's resolveOptions() read only from its
7
+ * `options` argument, with no env var fallback anywhere. That's since
8
+ * changed: ADR 012 Phase 2 (docs/adr/012-tracker-lifecycle-and-shared-state.md)
9
+ * added `OTEL_MCP_INSTANCE_KEY`, resolved directly in src/config.js's
10
+ * resolveOptions() itself rather than a nested feature config module,
11
+ * since `instanceKey` is a bare top-level option with no feature-specific
12
+ * sub-config to belong to the way `thrashDetection`/`schemaDrift` do. The
13
+ * pattern this module established is otherwise unchanged and still the
14
+ * one every feature-scoped config follows: explicit field on the
15
+ * `partial` argument, then the field's `OTEL_MCP_THRASH_*` env var, then
16
+ * the hardcoded default. An invalid or unparseable env value is treated
17
+ * exactly like an absent one — silent fallback to the next source, never
18
+ * a throw.
13
19
  */
14
20
 
15
21
  /**