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 +209 -0
- package/README.md +655 -10
- package/package.json +1 -1
- package/src/attributes.js +3 -0
- package/src/config.js +12 -0
- package/src/cost/budget.js +13 -4
- package/src/index.d.ts +67 -0
- package/src/instrument.js +226 -8
- package/src/observation/integrity.js +108 -0
- package/src/observation/tool-outcome-counter.js +131 -0
- package/src/observation/types.d.ts +78 -0
- package/src/schema-drift/attributes.js +68 -0
- package/src/schema-drift/canonicalize.js +80 -0
- package/src/schema-drift/compose.js +94 -0
- package/src/schema-drift/config.js +111 -0
- package/src/schema-drift/detector.js +135 -0
- package/src/schema-drift/diff.js +176 -0
- package/src/schema-drift/emitter.js +106 -0
- package/src/schema-drift/store.js +75 -0
- package/src/schema-drift/types.d.ts +134 -0
- package/src/thrash/attributes.js +38 -0
- package/src/thrash/detector.js +17 -6
- package/src/thrash/emitter.js +15 -6
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
|