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 +91 -0
- package/README.md +196 -15
- package/package.json +4 -2
- package/src/config.js +55 -0
- package/src/fingerprint/classify/auth.js +41 -3
- package/src/fingerprint/classify/validation-paths.js +135 -18
- package/src/index.d.ts +28 -0
- package/src/instrument.js +103 -7
- package/src/registry/instance-registry.js +170 -0
- package/src/registry/types.d.ts +55 -0
- package/src/thrash/config.js +14 -8
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
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
`
|
|
1424
|
-
|
|
1425
|
-
|
|
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
|
|
1430
|
-
|
|
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.
|
|
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,
|
|
6
|
-
*
|
|
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
|
|
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)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
|
274
|
+
if (jsonSubstring !== null) {
|
|
275
|
+
const jsonPaths = parseZodIssuesArray(jsonSubstring);
|
|
276
|
+
if (jsonPaths !== null) return jsonPaths;
|
|
277
|
+
}
|
|
161
278
|
|
|
162
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
+
}
|
package/src/thrash/config.js
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `options` argument
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
/**
|