opentel-mcp 0.7.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,305 @@
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
+
94
+ ## 0.8.0
95
+
96
+ Three features. **Tool schema drift detection**: a server that silently
97
+ changes a tool's `inputSchema` between deployments — a parameter renamed, a
98
+ type tightened, a `required` field added — currently breaks agents with no
99
+ signal pointing at the actual cause. Full investigation and design: ADR 010
100
+ (`docs/adr/010-schema-drift.md`). **Two-axis observation contract**:
101
+ prompted by external review (Massimiliano Brighindi), who first raised that
102
+ `instrumentMcpServer()` with no `TracerProvider`/`MeterProvider` registered
103
+ silently no-ops — a failed tool call in that state produces zero telemetry,
104
+ indistinguishable from one that never failed — and then supplied the reframe
105
+ that shaped what shipped: not "detect a broken pipeline," but "stop implying
106
+ health by omission." Full investigation and design: ADR 008
107
+ (`docs/adr/008-observation-liveness.md`, "Update (2026-08-05)" section).
108
+ **Cost-aware trace sampling**: investigated whether traces that were
109
+ expensive or thrashed could be kept regardless of the head sampler's
110
+ decision. Found that this cannot be an in-process library feature — a
111
+ `Sampler` decides at span start, cost/thrash are only known at span end, and
112
+ this package doesn't own the `Sampler`/`SpanProcessor` chain in its default
113
+ configuration anyway. Ships a marker attribute plus a documented Collector
114
+ recipe instead of a sampler. Full investigation and design: ADR 011
115
+ (`docs/adr/011-cost-aware-sampling.md`).
116
+
117
+ **⚠️ Read "Changed — behavior change on upgrade" immediately below before
118
+ updating.** One of the three features above changes when
119
+ `instrumentMcpServer()` throws, for a subset of low-level `Server` users,
120
+ purely from the new default-on config — no code change of your own
121
+ required to hit it.
122
+
123
+ ### Changed — behavior change on upgrade, read before updating
124
+
125
+ - **The instrument-first ordering requirement now also covers `tools/list`,
126
+ for low-level `Server` users specifically, because `schemaDrift.enabled`
127
+ defaults to `true`.** `instrumentMcpServer()` has always required being
128
+ called before any `tools/call` handler is registered; as of this release,
129
+ with schema drift enabled (the default), it also requires being called
130
+ before any `tools/list` handler is registered. If your low-level `Server`
131
+ code registers `server.setRequestHandler(ListToolsRequestSchema, ...)`
132
+ before calling `instrumentMcpServer()` — never previously an error, since
133
+ this library was blind to `tools/list` entirely before this release —
134
+ upgrading will make that throw `INSTRUMENT_FIRST_ERROR` where it didn't
135
+ before, with no other code changes on your part.
136
+ - **`McpServer` users are unaffected.** `McpServer` registers `tools/list`
137
+ and `tools/call` together, atomically, the first time `.tool()` or
138
+ `.registerTool()` is called — so anyone already following the
139
+ documented instrument-before-registration rule for `tools/call`
140
+ automatically satisfies it for `tools/list` too.
141
+ - **Migration**: either reorder your `tools/list` registration to after
142
+ `instrumentMcpServer()`, or pass `schemaDrift: { enabled: false }` to
143
+ opt out and keep your existing registration order — both fully
144
+ restore v0.7.0 behavior. There is no change if you don't use a
145
+ low-level `Server` with an independently-registered `tools/list`
146
+ handler.
147
+ - Only `schemaDrift` (schema drift detection, below) causes this — the
148
+ two-axis observation contract and cost-aware sampling features in this
149
+ same release are purely additive, with no effect on when
150
+ `instrumentMcpServer()` throws.
151
+
152
+ ### Added
153
+
154
+ - **Tool schema drift detection.** Every `tools/list` response is captured,
155
+ canonicalized, and hashed per tool (`inputSchema` only — `description` is
156
+ a deliberately separate, not-yet-built dimension; see ADR 010). When a
157
+ previously-observed tool's schema hash changes, this emits:
158
+ - An `mcp.tool.schema_drift.detected` counter, labeled `gen_ai.tool.name`
159
+ and `mcp.tool.schema_drift.type` (both bounded — see
160
+ `METRIC_SAFE_ATTRIBUTES`, `src/schema-drift/attributes.js`).
161
+ - An `mcp.tool.schema_drift.detected` span event on the new `tools/list`
162
+ span (see Changed, below), carrying the drift `type`, the previous/
163
+ current schema hash, and — only when non-empty, never set to `[]` —
164
+ which field names were added/removed/changed. Field names are
165
+ span-only, never a metric label (unbounded across tools/deployments,
166
+ same reasoning as `mcp.failure.validation_paths`, ADR 009).
167
+ - `type` is one of `field_added` \| `field_removed` \| `type_changed` \|
168
+ `required_changed` \| `multiple` (more than one at once, never guessed
169
+ down to a single answer) \| `unknown` (a change this differ can't
170
+ confidently characterize — e.g. a top-level `oneOf`/`anyOf`/`allOf`
171
+ composition, or a change hidden behind a `$ref`/`$defs` indirection
172
+ that doesn't touch the referencing property's own value — still
173
+ reported as drift, just not attributable to a specific field).
174
+ - The **first** observation of any given tool is never reported as
175
+ drift (nothing to compare against yet — cold start). A tool that
176
+ stops appearing in `tools/list` responses for a while and later
177
+ reappears is compared against its last-seen schema, not treated as a
178
+ fresh cold start — see the README's "Tool schema drift detection"
179
+ section for why this is the correct, and possibly counter-intuitive,
180
+ behavior.
181
+ - State is scoped **per instrumented server instance, not per session**
182
+ (ADR 010, Q4) — every client session sees the same tool registry, so
183
+ session-keyed state would produce false cold-starts per new session
184
+ and could silently swallow drift that happened between sessions.
185
+ - New `schemaDrift` option on `instrumentMcpServer()`, following the
186
+ exact `thrashDetection`/`costTracking` partial-overrides-individual-
187
+ defaults pattern: `enabled` (default `true`) and `maxTrackedTools`
188
+ (default `1000`, an LRU cap — defense-in-depth, not a response to an
189
+ expected failure mode; a server's own tool count is normally small).
190
+ Each independently overridable via `OTEL_MCP_SCHEMA_DRIFT_ENABLED` /
191
+ `OTEL_MCP_SCHEMA_DRIFT_MAX_TRACKED_TOOLS`, following the existing
192
+ `OTEL_MCP_THRASH_*` env-var convention.
193
+ **Unlike** `thrashDetection`/`costTracking`, `schemaDrift.enabled: false`
194
+ is a true no-op: `tools/list` is not wrapped at all (no span, no
195
+ capture, no detector/emitter construction) — the `tools/list` span
196
+ this feature introduces exists purely for schema drift, unlike the
197
+ `tools/call` span, which already serves other purposes regardless of
198
+ sub-feature flags.
199
+ - `SchemaDriftConfig`, `SchemaDriftKind`, `SchemaDriftEvent` types,
200
+ exported from the package root.
201
+
202
+ ### Added — Two-axis observation contract
203
+
204
+ - **`getObservationState()`**, a new accessor attached to the object
205
+ `instrumentMcpServer()` returns — unconditional (not gated behind
206
+ `setupNodeSdk`), same additive pattern as `shutdown()`/`getThrashSummary()`,
207
+ omitted entirely when `options.enabled` is `false`. Returns:
208
+ ```
209
+ {
210
+ toolOutcome: { success, failure, unknown },
211
+ observationIntegrity: 'DEGRADED' | 'UNKNOWN',
212
+ }
213
+ ```
214
+ No OTel emission — purely in-process, nothing sent anywhere, safe to
215
+ call from application code (a health-check endpoint, a periodic
216
+ `console.log`, a debugger). See the README's "Two-axis observation
217
+ contract" section for the full design rationale.
218
+ - **`toolOutcome`**: cumulative tool-call outcome counts since
219
+ instrumentation, from a **new counter that increments on every tool
220
+ call unconditionally** — independent of `fingerprinting`,
221
+ `thrashDetection`, and `enableMetrics`. Deliberately NOT read off
222
+ `ThrashDetector`/`getThrashSummary()`: that bookkeeping only runs
223
+ when a fingerprint was computed, so with `fingerprinting: false` (a
224
+ fully supported configuration) it would silently report zero
225
+ failures regardless of how many actually occurred — the exact
226
+ silent-success failure mode this feature exists to close. A
227
+ malformed, unrecognizable tool result (not a real `CallToolResult`
228
+ shape) increments `unknown` rather than silently defaulting to
229
+ `success`.
230
+ - **`observationIntegrity`**: `'DEGRADED' | 'UNKNOWN'` — note there is
231
+ no `'HEALTHY'` value, and this is not an oversight. Investigated and
232
+ found structurally unreachable in every configuration: the one lead
233
+ (OTel SDK self-observability metrics) is a write-only `Counter` with
234
+ no synchronous read-back API in `@opentelemetry/api`, so this
235
+ library's own code can never positively confirm telemetry is
236
+ flowing, no matter how it's wired up. `HEALTHY` is therefore absent
237
+ from the type entirely, not merely never returned — enforced by
238
+ TypeScript, not just documentation (see the new type-level tests in
239
+ `test/index.exports.test-d.ts`).
240
+ - `DEGRADED` is detected via a fragile `ProxyTracerProvider`
241
+ reference-equality check, and is reachable **only** under
242
+ `setupNodeSdk: false` (the default) — when no `TracerProvider` has
243
+ been registered globally at all.
244
+ - Under `setupNodeSdk: true`, this library registers the provider
245
+ itself, so absence can never be confirmed — `observationIntegrity`
246
+ is **always** `'UNKNOWN'` in that configuration, without even
247
+ attempting the check.
248
+ - Recomputed fresh on **every call** to `getObservationState()`,
249
+ never cached from instrument time — a host may register a
250
+ `TracerProvider` asynchronously after `instrumentMcpServer()`
251
+ already ran, and a value cached at startup would go stale the
252
+ moment that happens.
253
+ - `ToolOutcome`, `ToolOutcomeCounts`, `ObservationIntegrity`,
254
+ `ObservationState` types, exported from the package root.
255
+
256
+ ### Added — Cost-aware trace sampling (marker attribute + Collector recipe)
257
+
258
+ - **`mcp.tool.thrash_detected`, a new boolean span attribute**, set
259
+ alongside (never instead of) the existing `mcp.loop.detected` span
260
+ event, in the same `thrash/emitter.js` call site — set only when
261
+ `thrashDetection` is enabled and a loop was actually detected on this
262
+ call, same reachability as the existing event, no new failure mode.
263
+ Exists specifically so an OpenTelemetry Collector's
264
+ `tailsamplingprocessor` has an unambiguous, attribute-level signal to
265
+ key on: whether a `boolean_attribute` policy can also match span-*event*
266
+ data was investigated and left genuinely unverified (the processor is
267
+ Go source in a separate repository, not installed here), so this
268
+ attribute removes that uncertainty entirely rather than leaving tail
269
+ sampling dependent on an unconfirmed answer. **Named deliberately
270
+ differently** from the pre-existing `mcp.tool.loop.detected` **metric**
271
+ counter, not reusing its string as ADR 011 originally specified — see
272
+ that ADR's "Update" note. A metric name and a span attribute key are
273
+ unrelated OTel namespaces with no technical conflict, but reusing the
274
+ name left the one reader who most needs it to be unambiguous — someone
275
+ writing a Collector tail-sampling policy — unable to tell, from the
276
+ name alone, which of the two same-named signals they were keying on.
277
+ - **No new cost-threshold attribute or config.** `mcp.tool.cost.usd` and
278
+ `mcp.tool.cost.budget_exceeded` (both already shipped, v0.5.0) already
279
+ fully suffice for a Collector `numeric_attribute` / `boolean_attribute`
280
+ policy — the numeric threshold itself lives entirely in the
281
+ Collector's own policy config (the YAML), not in this package's
282
+ `InstrumentOptions`. No new env var, no new `instrumentMcpServer()`
283
+ option.
284
+ - **A documented, pasteable OpenTelemetry Collector `tailsamplingprocessor`
285
+ config** (README's "Cost-aware trace sampling" section) keeping any
286
+ trace with an expensive call, a budget-exceeded call, or a detected
287
+ thrash loop, alongside an ordinary probabilistic sample for everything
288
+ else.
289
+ - **No in-process sampler or buffering `SpanProcessor` was built, and none
290
+ is planned** — investigated and rejected on two independent grounds
291
+ (ADR 011): this package doesn't own the `Sampler`/`SpanProcessor` chain
292
+ in its default configuration (no public API to inject either into a
293
+ host-owned `TracerProvider`), and even where a custom processor could
294
+ theoretically be installed, an in-process decision can only ever rescue
295
+ the one span this package itself creates — never an already-finished
296
+ child span from other instrumentation, never an upstream span in a
297
+ different process. "Keep the trace" is not achievable in-process; at
298
+ best, "keep this one span" is, which is a materially smaller guarantee
299
+ than the stated goal. Real cross-span, cross-process trace buffering is
300
+ what the Collector's `tailsamplingprocessor` already does correctly —
301
+ not something to partially re-implement inside this package.
302
+
3
303
  ## 0.7.0
4
304
 
5
305
  Origin-aware failure classification for Agent Thrash Detection. Prompted by