opentel-mcp 0.7.0 → 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,214 @@
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
+
3
212
  ## 0.7.0
4
213
 
5
214
  Origin-aware failure classification for Agent Thrash Detection. Prompted by