opentel-mcp 0.6.1 → 0.8.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,351 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ Three features. **Tool schema drift detection**: a server that silently
6
+ changes a tool's `inputSchema` between deployments — a parameter renamed, a
7
+ type tightened, a `required` field added — currently breaks agents with no
8
+ signal pointing at the actual cause. Full investigation and design: ADR 010
9
+ (`docs/adr/010-schema-drift.md`). **Two-axis observation contract**:
10
+ prompted by external review (Massimiliano Brighindi), who first raised that
11
+ `instrumentMcpServer()` with no `TracerProvider`/`MeterProvider` registered
12
+ silently no-ops — a failed tool call in that state produces zero telemetry,
13
+ indistinguishable from one that never failed — and then supplied the reframe
14
+ that shaped what shipped: not "detect a broken pipeline," but "stop implying
15
+ health by omission." Full investigation and design: ADR 008
16
+ (`docs/adr/008-observation-liveness.md`, "Update (2026-08-05)" section).
17
+ **Cost-aware trace sampling**: investigated whether traces that were
18
+ expensive or thrashed could be kept regardless of the head sampler's
19
+ decision. Found that this cannot be an in-process library feature — a
20
+ `Sampler` decides at span start, cost/thrash are only known at span end, and
21
+ this package doesn't own the `Sampler`/`SpanProcessor` chain in its default
22
+ configuration anyway. Ships a marker attribute plus a documented Collector
23
+ recipe instead of a sampler. Full investigation and design: ADR 011
24
+ (`docs/adr/011-cost-aware-sampling.md`).
25
+
26
+ **⚠️ Read "Changed — behavior change on upgrade" immediately below before
27
+ updating.** One of the three features above changes when
28
+ `instrumentMcpServer()` throws, for a subset of low-level `Server` users,
29
+ purely from the new default-on config — no code change of your own
30
+ required to hit it.
31
+
32
+ ### Changed — behavior change on upgrade, read before updating
33
+
34
+ - **The instrument-first ordering requirement now also covers `tools/list`,
35
+ for low-level `Server` users specifically, because `schemaDrift.enabled`
36
+ defaults to `true`.** `instrumentMcpServer()` has always required being
37
+ called before any `tools/call` handler is registered; as of this release,
38
+ with schema drift enabled (the default), it also requires being called
39
+ before any `tools/list` handler is registered. If your low-level `Server`
40
+ code registers `server.setRequestHandler(ListToolsRequestSchema, ...)`
41
+ before calling `instrumentMcpServer()` — never previously an error, since
42
+ this library was blind to `tools/list` entirely before this release —
43
+ upgrading will make that throw `INSTRUMENT_FIRST_ERROR` where it didn't
44
+ before, with no other code changes on your part.
45
+ - **`McpServer` users are unaffected.** `McpServer` registers `tools/list`
46
+ and `tools/call` together, atomically, the first time `.tool()` or
47
+ `.registerTool()` is called — so anyone already following the
48
+ documented instrument-before-registration rule for `tools/call`
49
+ automatically satisfies it for `tools/list` too.
50
+ - **Migration**: either reorder your `tools/list` registration to after
51
+ `instrumentMcpServer()`, or pass `schemaDrift: { enabled: false }` to
52
+ opt out and keep your existing registration order — both fully
53
+ restore v0.7.0 behavior. There is no change if you don't use a
54
+ low-level `Server` with an independently-registered `tools/list`
55
+ handler.
56
+ - Only `schemaDrift` (schema drift detection, below) causes this — the
57
+ two-axis observation contract and cost-aware sampling features in this
58
+ same release are purely additive, with no effect on when
59
+ `instrumentMcpServer()` throws.
60
+
61
+ ### Added
62
+
63
+ - **Tool schema drift detection.** Every `tools/list` response is captured,
64
+ canonicalized, and hashed per tool (`inputSchema` only — `description` is
65
+ a deliberately separate, not-yet-built dimension; see ADR 010). When a
66
+ previously-observed tool's schema hash changes, this emits:
67
+ - An `mcp.tool.schema_drift.detected` counter, labeled `gen_ai.tool.name`
68
+ and `mcp.tool.schema_drift.type` (both bounded — see
69
+ `METRIC_SAFE_ATTRIBUTES`, `src/schema-drift/attributes.js`).
70
+ - An `mcp.tool.schema_drift.detected` span event on the new `tools/list`
71
+ span (see Changed, below), carrying the drift `type`, the previous/
72
+ current schema hash, and — only when non-empty, never set to `[]` —
73
+ which field names were added/removed/changed. Field names are
74
+ span-only, never a metric label (unbounded across tools/deployments,
75
+ same reasoning as `mcp.failure.validation_paths`, ADR 009).
76
+ - `type` is one of `field_added` \| `field_removed` \| `type_changed` \|
77
+ `required_changed` \| `multiple` (more than one at once, never guessed
78
+ down to a single answer) \| `unknown` (a change this differ can't
79
+ confidently characterize — e.g. a top-level `oneOf`/`anyOf`/`allOf`
80
+ composition, or a change hidden behind a `$ref`/`$defs` indirection
81
+ that doesn't touch the referencing property's own value — still
82
+ reported as drift, just not attributable to a specific field).
83
+ - The **first** observation of any given tool is never reported as
84
+ drift (nothing to compare against yet — cold start). A tool that
85
+ stops appearing in `tools/list` responses for a while and later
86
+ reappears is compared against its last-seen schema, not treated as a
87
+ fresh cold start — see the README's "Tool schema drift detection"
88
+ section for why this is the correct, and possibly counter-intuitive,
89
+ behavior.
90
+ - State is scoped **per instrumented server instance, not per session**
91
+ (ADR 010, Q4) — every client session sees the same tool registry, so
92
+ session-keyed state would produce false cold-starts per new session
93
+ and could silently swallow drift that happened between sessions.
94
+ - New `schemaDrift` option on `instrumentMcpServer()`, following the
95
+ exact `thrashDetection`/`costTracking` partial-overrides-individual-
96
+ defaults pattern: `enabled` (default `true`) and `maxTrackedTools`
97
+ (default `1000`, an LRU cap — defense-in-depth, not a response to an
98
+ expected failure mode; a server's own tool count is normally small).
99
+ Each independently overridable via `OTEL_MCP_SCHEMA_DRIFT_ENABLED` /
100
+ `OTEL_MCP_SCHEMA_DRIFT_MAX_TRACKED_TOOLS`, following the existing
101
+ `OTEL_MCP_THRASH_*` env-var convention.
102
+ **Unlike** `thrashDetection`/`costTracking`, `schemaDrift.enabled: false`
103
+ is a true no-op: `tools/list` is not wrapped at all (no span, no
104
+ capture, no detector/emitter construction) — the `tools/list` span
105
+ this feature introduces exists purely for schema drift, unlike the
106
+ `tools/call` span, which already serves other purposes regardless of
107
+ sub-feature flags.
108
+ - `SchemaDriftConfig`, `SchemaDriftKind`, `SchemaDriftEvent` types,
109
+ exported from the package root.
110
+
111
+ ### Added — Two-axis observation contract
112
+
113
+ - **`getObservationState()`**, a new accessor attached to the object
114
+ `instrumentMcpServer()` returns — unconditional (not gated behind
115
+ `setupNodeSdk`), same additive pattern as `shutdown()`/`getThrashSummary()`,
116
+ omitted entirely when `options.enabled` is `false`. Returns:
117
+ ```
118
+ {
119
+ toolOutcome: { success, failure, unknown },
120
+ observationIntegrity: 'DEGRADED' | 'UNKNOWN',
121
+ }
122
+ ```
123
+ No OTel emission — purely in-process, nothing sent anywhere, safe to
124
+ call from application code (a health-check endpoint, a periodic
125
+ `console.log`, a debugger). See the README's "Two-axis observation
126
+ contract" section for the full design rationale.
127
+ - **`toolOutcome`**: cumulative tool-call outcome counts since
128
+ instrumentation, from a **new counter that increments on every tool
129
+ call unconditionally** — independent of `fingerprinting`,
130
+ `thrashDetection`, and `enableMetrics`. Deliberately NOT read off
131
+ `ThrashDetector`/`getThrashSummary()`: that bookkeeping only runs
132
+ when a fingerprint was computed, so with `fingerprinting: false` (a
133
+ fully supported configuration) it would silently report zero
134
+ failures regardless of how many actually occurred — the exact
135
+ silent-success failure mode this feature exists to close. A
136
+ malformed, unrecognizable tool result (not a real `CallToolResult`
137
+ shape) increments `unknown` rather than silently defaulting to
138
+ `success`.
139
+ - **`observationIntegrity`**: `'DEGRADED' | 'UNKNOWN'` — note there is
140
+ no `'HEALTHY'` value, and this is not an oversight. Investigated and
141
+ found structurally unreachable in every configuration: the one lead
142
+ (OTel SDK self-observability metrics) is a write-only `Counter` with
143
+ no synchronous read-back API in `@opentelemetry/api`, so this
144
+ library's own code can never positively confirm telemetry is
145
+ flowing, no matter how it's wired up. `HEALTHY` is therefore absent
146
+ from the type entirely, not merely never returned — enforced by
147
+ TypeScript, not just documentation (see the new type-level tests in
148
+ `test/index.exports.test-d.ts`).
149
+ - `DEGRADED` is detected via a fragile `ProxyTracerProvider`
150
+ reference-equality check, and is reachable **only** under
151
+ `setupNodeSdk: false` (the default) — when no `TracerProvider` has
152
+ been registered globally at all.
153
+ - Under `setupNodeSdk: true`, this library registers the provider
154
+ itself, so absence can never be confirmed — `observationIntegrity`
155
+ is **always** `'UNKNOWN'` in that configuration, without even
156
+ attempting the check.
157
+ - Recomputed fresh on **every call** to `getObservationState()`,
158
+ never cached from instrument time — a host may register a
159
+ `TracerProvider` asynchronously after `instrumentMcpServer()`
160
+ already ran, and a value cached at startup would go stale the
161
+ moment that happens.
162
+ - `ToolOutcome`, `ToolOutcomeCounts`, `ObservationIntegrity`,
163
+ `ObservationState` types, exported from the package root.
164
+
165
+ ### Added — Cost-aware trace sampling (marker attribute + Collector recipe)
166
+
167
+ - **`mcp.tool.thrash_detected`, a new boolean span attribute**, set
168
+ alongside (never instead of) the existing `mcp.loop.detected` span
169
+ event, in the same `thrash/emitter.js` call site — set only when
170
+ `thrashDetection` is enabled and a loop was actually detected on this
171
+ call, same reachability as the existing event, no new failure mode.
172
+ Exists specifically so an OpenTelemetry Collector's
173
+ `tailsamplingprocessor` has an unambiguous, attribute-level signal to
174
+ key on: whether a `boolean_attribute` policy can also match span-*event*
175
+ data was investigated and left genuinely unverified (the processor is
176
+ Go source in a separate repository, not installed here), so this
177
+ attribute removes that uncertainty entirely rather than leaving tail
178
+ sampling dependent on an unconfirmed answer. **Named deliberately
179
+ differently** from the pre-existing `mcp.tool.loop.detected` **metric**
180
+ counter, not reusing its string as ADR 011 originally specified — see
181
+ that ADR's "Update" note. A metric name and a span attribute key are
182
+ unrelated OTel namespaces with no technical conflict, but reusing the
183
+ name left the one reader who most needs it to be unambiguous — someone
184
+ writing a Collector tail-sampling policy — unable to tell, from the
185
+ name alone, which of the two same-named signals they were keying on.
186
+ - **No new cost-threshold attribute or config.** `mcp.tool.cost.usd` and
187
+ `mcp.tool.cost.budget_exceeded` (both already shipped, v0.5.0) already
188
+ fully suffice for a Collector `numeric_attribute` / `boolean_attribute`
189
+ policy — the numeric threshold itself lives entirely in the
190
+ Collector's own policy config (the YAML), not in this package's
191
+ `InstrumentOptions`. No new env var, no new `instrumentMcpServer()`
192
+ option.
193
+ - **A documented, pasteable OpenTelemetry Collector `tailsamplingprocessor`
194
+ config** (README's "Cost-aware trace sampling" section) keeping any
195
+ trace with an expensive call, a budget-exceeded call, or a detected
196
+ thrash loop, alongside an ordinary probabilistic sample for everything
197
+ else.
198
+ - **No in-process sampler or buffering `SpanProcessor` was built, and none
199
+ is planned** — investigated and rejected on two independent grounds
200
+ (ADR 011): this package doesn't own the `Sampler`/`SpanProcessor` chain
201
+ in its default configuration (no public API to inject either into a
202
+ host-owned `TracerProvider`), and even where a custom processor could
203
+ theoretically be installed, an in-process decision can only ever rescue
204
+ the one span this package itself creates — never an already-finished
205
+ child span from other instrumentation, never an upstream span in a
206
+ different process. "Keep the trace" is not achievable in-process; at
207
+ best, "keep this one span" is, which is a materially smaller guarantee
208
+ than the stated goal. Real cross-span, cross-process trace buffering is
209
+ what the Collector's `tailsamplingprocessor` already does correctly —
210
+ not something to partially re-implement inside this package.
211
+
212
+ ## 0.7.0
213
+
214
+ Origin-aware failure classification for Agent Thrash Detection. Prompted by
215
+ external review (Reddit) pointing out that failure detection only read the
216
+ `isError` channel, missing JSON-RPC protocol-level failures. Verifying that
217
+ report surfaced a separate, more consequential finding: a real false
218
+ positive already live in every published version — output-validation
219
+ failures (a server-side bug) being counted as agent thrash. Full
220
+ investigation and design: ADR 007 (`docs/adr/007-protocol-error-channel.md`).
221
+
222
+ Also investigates a second external report (u/Pleasant-Ad192): whether a
223
+ different schema field failing validation each attempt (an agent
224
+ converging) can be told apart from the same field failing repeatedly (an
225
+ ambiguous tool schema). Finding: it mostly already can be, as a side
226
+ effect of how failures are fingerprinted — see ADR 009
227
+ (`docs/adr/009-field-level-convergence.md`) — now pinned down by
228
+ regression tests and a diagnostic span attribute, with one real gap
229
+ (partial convergence) still open pending a design decision.
230
+
231
+ Known gaps and open questions this release didn't close: `docs/known-gaps.md`.
232
+
233
+ ### Fixed
234
+
235
+ - **Output-validation failures were miscategorized and incorrectly counted
236
+ toward Agent Thrash Detection.** When a tool's own handler returned output
237
+ that didn't match its declared output schema, the resulting failure
238
+ (surfaced as `isError: true`, whether thrown directly or converted by the
239
+ high-level `McpServer`) landed in fingerprint category `validation` or
240
+ `internal` depending on the exact wording, and — since Agent Thrash
241
+ Detection shipped in v0.6.0 — was tracked exactly like a normal
242
+ business-logic failure. An agent retrying such a tool would eventually
243
+ cross the default threshold and get flagged as "thrashing," even though
244
+ the failure is entirely the tool author's bug: no argument the agent
245
+ supplies can ever fix a server that never returns valid structured
246
+ content. **Affected range: the miscategorization itself has been present
247
+ since v0.4.0 (deep-failure fingerprinting); the false-positive thrash
248
+ count has been present since v0.6.0 (Agent Thrash Detection), through the
249
+ last published release, v0.6.1.** Fixed by classifying which *channel* a
250
+ failure arrived on (see Added, below) and excluding the `protocol.output`
251
+ channel from thrash detection entirely — not merely relabeling it.
252
+
253
+ ### Added
254
+
255
+ - **`mcp.failure.channel` span attribute** — one of `execution` |
256
+ `protocol.not_found` | `protocol.input` | `protocol.output` |
257
+ `protocol.other` | `unknown`, classifying which channel a tools/call
258
+ failure arrived on (`classifyFailureChannel()`,
259
+ `src/fingerprint/classify/channel.js`). Additive: never part of
260
+ `computeFingerprint()`'s hash input (see Unchanged, below) — deliberately
261
+ a separate attribute from the pre-existing `mcp.failure.origin`, which
262
+ means something different (`tool_error` \| `thrown` \| `transport`) and
263
+ has been hashed since v0.4.0.
264
+ - **Per-channel Agent Thrash Detection thresholds**: `inputThreshold`
265
+ (default `5`, higher than the base `threshold`) for the `protocol.input`
266
+ channel — an agent retrying with different arguments after an
267
+ input-validation failure may be genuinely converging, not thrashing —
268
+ and `notFoundThreshold` (default `1`, an immediate flag) for
269
+ `protocol.not_found` — retrying a tool name that doesn't exist is never
270
+ convergence. Each independently overridable via its own env var
271
+ (`OTEL_MCP_THRASH_INPUT_THRESHOLD` / `OTEL_MCP_THRASH_NOT_FOUND_THRESHOLD`),
272
+ following the exact existing `OTEL_MCP_THRASH_*` pattern.
273
+ - `FailureChannel` type, exported from the package root alongside the
274
+ existing `FailureCategory` / `FailureOrigin` types.
275
+ - For high-level `McpServer` users specifically: since `McpServer` converts
276
+ most protocol-shaped failures (tool not found, disabled, input/output
277
+ validation) to `isError: true` before this library ever sees a thrown
278
+ error, `classifyFailureChannel()` also recovers the real channel from
279
+ that disguised form by reading the `MCP error {code}: ` wrapper
280
+ `McpError`'s constructor always applies, which `McpServer` preserves
281
+ verbatim. Without this, `protocol.output`'s exclusion (the fix above)
282
+ would only have applied to hand-rolled low-level `Server` apps, not to
283
+ `McpServer` — see the README's "Agent Thrash Detection" section and ADR
284
+ 007's addendum for the full reachability picture and its limits.
285
+ - **`mcp.failure.validation_paths` span attribute** — which schema
286
+ field(s) a Zod validation failure named, one dot-joined path per
287
+ failing issue (e.g. `["email", "user.profile.age"]`), best-effort
288
+ extracted from the same message text `classifyFailureChannel()` already
289
+ reads (`extractValidationPaths()`,
290
+ `src/fingerprint/classify/validation-paths.js`). Omitted entirely —
291
+ never set to an empty array — when nothing confidently parseable was
292
+ found. Span-only, permanently excluded from
293
+ `METRIC_SAFE_ATTRIBUTES`: field/path names are bounded per tool but
294
+ unbounded across every tool anyone registers, the same reasoning that
295
+ already keeps `mcp.failure.fingerprint`/`signature`/`error_class` off
296
+ metric labels. Full investigation and design: ADR 009
297
+ (`docs/adr/009-field-level-convergence.md`).
298
+
299
+ ### Unchanged
300
+
301
+ - **Fingerprints (`mcp.failure.fingerprint` and every other
302
+ `FingerprintInputs` field) are byte-identical to v0.6.1 for the same
303
+ inputs.** The new `channel` dimension is deliberately kept out of
304
+ `computeFingerprint()`'s hash input (ADR 007) specifically so this
305
+ release cannot change any consumer's existing `mcp.failure.fingerprint`
306
+ values — a change there would silently break any alert or dashboard
307
+ built on fingerprint identity. Verified, not just asserted: by extracting
308
+ the actual, published `v0.6.1` git tag's `src/fingerprint/` tree via `git
309
+ archive` into an isolated directory and running its `computeFingerprint()`
310
+ directly, independent of this working tree, against six fixture inputs —
311
+ see `test/fingerprint/compose.fixtures.test.js`. If you have alerts or
312
+ dashboards keyed on `mcp.failure.fingerprint`, they keep working exactly
313
+ as they did on v0.6.1, with no changes required on your end.
314
+ - **Field-level discrimination in Agent Thrash Detection is not a new
315
+ capability — it already worked, as a side effect of fingerprinting the
316
+ full Zod issues JSON, and was simply incidental until now.** A
317
+ validation failure repeating on the *same* schema field across attempts
318
+ already hashed to the *same* fingerprint (accumulating correctly toward
319
+ `inputThreshold`), and a *different* field failing each attempt already
320
+ hashed to a *different* fingerprint each time (never accumulating,
321
+ matching a converging agent). Investigated and confirmed in ADR 009; now
322
+ pinned down by regression tests
323
+ (`test/fingerprint/field-level-convergence.test.js`) so a future Zod or
324
+ SDK change that silently breaks it gets caught, rather than discovered
325
+ as a production regression. One real gap remains open and is *not*
326
+ fixed by this: partial convergence (fixing one of several failing
327
+ fields changes the issues array's shape and breaks fingerprint
328
+ continuity) — tracked in `docs/known-gaps.md`, pending a design ADR 009
329
+ did not settle on.
330
+
331
+ ### Documentation
332
+
333
+ - `docs/known-gaps.md` (new): five tracked gaps this release didn't close,
334
+ each written as a ready-to-paste GitHub issue — field-level convergence
335
+ tracking, partial convergence in field-level validation, the
336
+ observation-liveness contract, the pre-handler parse-failure gap, and
337
+ how client-side retry caps interact with
338
+ detection thresholds.
339
+ - README's "Agent Thrash Detection" section now covers channel-aware
340
+ thresholds, the `McpServer`-vs-low-level-`Server` reachability
341
+ difference (with ADR 007's full table), the pre-handler parse-failure
342
+ gap (deferred, not solved — closing it means revisiting ADR 001), and
343
+ the forwarded-error collision risk as a named known limitation.
344
+ - README's "Failure Fingerprinting" section now documents
345
+ `mcp.failure.validation_paths` and states plainly that field-level
346
+ discrimination is a property of the fingerprint, not a separate
347
+ detector — see ADR 009.
348
+
3
349
  ## 0.6.1
4
350
 
5
351
  ### Fixed