opentel-mcp 0.4.0 → 0.6.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,185 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Added — Agent Thrash Detection
6
+
7
+ Detects when an agent retries the same tool with the same v0.4 failure
8
+ fingerprint repeatedly, and attributes the wasted v0.5 tokens/cost to that
9
+ loop — see the README's new "Agent Thrash Detection" section for the full
10
+ picture, including the sessionId resolution rules below (the one thing
11
+ most likely to be misconfigured).
12
+
13
+ - Metrics, via the same `@opentelemetry/api`-only pattern as the existing
14
+ `mcp.tool.*` instruments: `mcp.tool.loop.detected` (counter),
15
+ `mcp.tool.loop.length` (histogram), `mcp.tool.loop.wasted_tokens`
16
+ (histogram, unit `tokens`), `mcp.tool.loop.wasted_cost_usd` (histogram,
17
+ unit `USD`), `mcp.tool.loop.duration` (histogram, unit `ms`). All five
18
+ carry only `gen_ai.tool.name` as a metric label — `mcp.failure.fingerprint`
19
+ and the new `mcp.loop.session_id` are deliberately excluded from every
20
+ metric (both are unbounded, per-caller values; see
21
+ `METRIC_SAFE_ATTRIBUTES`'s docblock in `src/fingerprint/attributes.js`).
22
+ - One `mcp.loop.detected` span event on the currently active span (never a
23
+ new span), carrying full detail including `mcp.failure.fingerprint` and
24
+ `mcp.loop.session_id` — span events can carry unbounded attributes
25
+ safely, unlike metric labels.
26
+ - `thrashDetection` option on `instrumentMcpServer()` (see `src/config.js`
27
+ and `src/thrash/config.js`): `{ enabled?, threshold?, windowMs?,
28
+ maxTrackedKeys?, entryTtlMs?, reEmitAfter?, assumeSingleSession? }`.
29
+ Every field independently overridable via an `OTEL_MCP_THRASH_*` env var
30
+ (first documented env-var-driven config pattern in this codebase).
31
+ Defaults: enabled, 3 consecutive same-fingerprint failures within 60s,
32
+ 1000 max tracked keys (bounded LRU+TTL — see `src/thrash/store.js`), a
33
+ 15-minute idle TTL, re-emit every 3 further failures past threshold,
34
+ `assumeSingleSession: false`.
35
+ - **Requires `fingerprinting: true`** (the default): thrash detection keys
36
+ off the same `mcp.failure.fingerprint` fingerprinting computes, so with
37
+ fingerprinting disabled, detection silently never fires regardless of
38
+ `thrashDetection`'s own settings.
39
+ - Safe-by-default session resolution. A generated per-connection fallback
40
+ session id is used only when a transport is structurally confirmed
41
+ single-connection (no `sessionId` property on `server.transport` — e.g.
42
+ stdio) or explicitly opted into via `thrashDetection.assumeSingleSession`
43
+ — **never** merging concurrent HTTP/SSE clients into one shared key,
44
+ which would otherwise fabricate false-positive loops out of unrelated
45
+ clients' failures. Once a server has been observed handing out a real
46
+ session id, a later call with none is skipped entirely rather than
47
+ falling back. A one-time `diag.warn` fires the first time the fallback
48
+ is actually used, naming which of the three conditions triggered it.
49
+ - `bench/thrash-data-benchmark.js`: a runnable data benchmark (distinct
50
+ from the CPU/memory performance benchmark in
51
+ `test/thrash/benchmark.test.js`) measuring detection rate and wasted
52
+ cost against a real in-process MCP `Client`/`Server` pair, with a
53
+ `--sweep` mode across configured broken-tool rates and a closed-form
54
+ sanity check that aborts rather than shipping a row that deviates
55
+ beyond sampling noise. Two published, reproducible runs committed under
56
+ `bench/results/` — see the README for how to read them (they model
57
+ sensitivity to a reader-supplied failure rate, not a measurement of any
58
+ real deployment).
59
+ - In-process summary: `instrumentMcpServer()`'s returned object gets a
60
+ `getThrashSummary()` method (`ThrashDetector.getSummary()` underneath)
61
+ — a zero-infrastructure way to check "is anything thrashing right now"
62
+ without a metrics backend or trace viewer. Returns `activeLoops` (loops
63
+ currently in the bounded store that have crossed `threshold` — bounded
64
+ by `maxTrackedKeys`, not a complete history) and `topOffenders` (up to 5
65
+ by default, configurable via `getThrashSummary({ topOffendersLimit })`,
66
+ sorted by `wastedCostUsd` descending), alongside `totalLoopsDetected` /
67
+ `totalWastedCostUsd` / `totalWastedTokensIn` / `totalWastedTokensOut` —
68
+ cumulative counters that, unlike the two above, survive LRU eviction
69
+ and TTL expiry (incremented at emit time, with delta-based accounting
70
+ so a loop that re-emits multiple times doesn't have its earlier calls'
71
+ cost double-counted). Never throws; an all-zero summary if
72
+ `thrashDetection` is disabled. Pure read — no effect on the hot path.
73
+ `src/thrash/store.js`'s `BoundedTtlMap` gained an `entries()` iterator
74
+ to support this (a minimal, necessary extension of its Phase 2 surface,
75
+ which was previously get/set/delete/size only).
76
+
77
+ ### Public API additions
78
+
79
+ - Re-exported from the package root (`src/index.d.ts`, types only —
80
+ `ThrashDetector`/`createThrashEmitter` stay internal): `ThrashConfig`,
81
+ `ThrashDetectedEvent`, `ThrashSummary`, `ThrashOffender` (new
82
+ `src/thrash/types.d.ts`, mirrors the `src/cost/types.d.ts` /
83
+ `src/fingerprint/types.d.ts` pattern).
84
+ - `instrumentMcpServer()`'s return type gained `getThrashSummary?: (options?: { topOffendersLimit?: number }) => ThrashSummary`,
85
+ alongside the existing `shutdown?`.
86
+ - `fingerprinting` and `thrashDetection` (with all of `ThrashConfig`,
87
+ including `assumeSingleSession`) are now declared on `InstrumentOptions`
88
+ in `src/index.d.ts` — both worked at runtime since introduction but were
89
+ missing from the public type declarations until now. Also added:
90
+ `FingerprintContext`, `Classifier`, `ComputeFingerprintOptions` (the
91
+ types needed to type a custom `computeFingerprint()` classifier per the
92
+ README's "Extending it" section — same kind of gap, found in the same
93
+ audit).
94
+ - `npm run typecheck` (`tsc --noEmit`, new `tsconfig.json`): type-checks
95
+ `src/index.d.ts` and the sibling `src/*/types.d.ts` files, plus a new
96
+ type-level test (`test/index.exports.test-d.ts`, using `expectTypeOf`)
97
+ asserting a consumer can construct `InstrumentOptions` with a partial
98
+ `thrashDetection` and a partial `fingerprinting` config. Does not
99
+ type-check the `.js` source itself (`checkJs: false`) — still no build
100
+ step, this only guards the hand-written public type declarations.
101
+
102
+ ### Changed (additive, non-breaking)
103
+
104
+ - `applyCostAttribution()` (internal, `src/instrument.js`) now returns
105
+ `{ tokensIn, tokensOut, costUsd } | null` instead of `void`, so thrash
106
+ detection can reuse one call's already-computed cost figures instead of
107
+ re-running the extractor. Not part of the public API and not observable
108
+ from outside `instrument.js`; nothing in v0.5.0 read the previous
109
+ `undefined` return value.
110
+
111
+ ## 0.5.0
112
+
113
+ ### Added — Cost & Token Attribution
114
+
115
+ - Span attributes, added on any tool call whose result carries recognizable
116
+ usage data: `mcp.tool.tokens.input`, `mcp.tool.tokens.output`,
117
+ `mcp.tool.tokens.total`, `mcp.tool.model`, `gen_ai.response.model`
118
+ (co-emitted alongside `mcp.tool.model` for GenAI-dashboard compatibility —
119
+ see README's "Cost & Token Attribution" section and the
120
+ `ATTR_GEN_AI_RESPONSE_MODEL` docblock in `src/attributes.js` for why this
121
+ is a pragmatic compatibility choice, not a spec-pure emission),
122
+ `mcp.tool.cost.usd`, and `mcp.tool.cost.currency`. All added by
123
+ `applyCostAttribution()` in `src/instrument.js`, wrapped in its own
124
+ try/catch — cost tracking can never break a span.
125
+ - Two new metric instruments, via the same `@opentelemetry/api`-only
126
+ pattern as the four existing `mcp.tool.*` metrics: `mcp.tool.tokens.total`
127
+ (counter, unit `tokens`) and `mcp.tool.cost.total` (counter, unit `USD`),
128
+ both attributed by `gen_ai.tool.name` + `mcp.tool.model` (model only
129
+ added when detected, same optional-attribute pattern
130
+ `mcp.failure.category` already uses).
131
+ - Per-session and per-tool budget guardrails (`costTracking.budget`,
132
+ `src/cost/budget.js`): in-memory cumulative-cost tracking, flags
133
+ `mcp.tool.cost.budget_exceeded` / `mcp.tool.cost.budget_scope`
134
+ (`"session"` | `"tool"`, session wins if both trip on the same call) on
135
+ the span once a configured `perSessionUsd`/`perToolUsd` limit is
136
+ crossed. **Observability only — never blocks or throws.** Calls with no
137
+ MCP session id (e.g. stdio transport) are skipped for session tracking
138
+ rather than lumped under a fallback key.
139
+ - `DEFAULT_PRICING` (`src/cost/pricing.js`): a default pricing table
140
+ covering 15+ models across five providers — Anthropic, OpenAI, Google,
141
+ AWS Bedrock, and DeepSeek. **Last verified 2026-07-29 — provider pricing
142
+ changes frequently and this table is not guaranteed to stay current;
143
+ override `costTracking.pricingTable` for production accuracy.**
144
+ - `defaultExtractor` (`src/cost/extractor.js`): recognizes Anthropic
145
+ (`usage.input_tokens`/`usage.output_tokens`), OpenAI
146
+ (`usage.prompt_tokens`/`usage.completion_tokens`), and Bedrock
147
+ (`usage.inputTokens`/`usage.outputTokens`) usage shapes, the MCP
148
+ `_meta.usage` extension point, and JSON-in-text inside
149
+ `content[0].text`. Never throws — unrecognized shapes resolve to `null`.
150
+ Pluggable via `costTracking.extractor` (type `UsageExtractor`) for
151
+ custom tool result formats.
152
+ - `calculateCost()` (`src/cost/calculator.js`): normalizes a model name
153
+ (lowercase, strips a `provider/` prefix) and prices it against a
154
+ `PricingTable`. Returns `null` — never throws — for an unrecognized
155
+ model or invalid token counts.
156
+ - `costTracking` option on `instrumentMcpServer()` (see `src/config.js`):
157
+ `{ enabled?: boolean; pricingTable?: PricingTable; extractor?:
158
+ UsageExtractor; budget?: { perSessionUsd?: number; perToolUsd?: number
159
+ } }`. Defaults to enabled, `DEFAULT_PRICING`, `defaultExtractor`, budget
160
+ tracking off. Any field can be overridden independently.
161
+
162
+ ### Public API additions
163
+
164
+ Re-exported from the package root (`src/index.js` / `src/index.d.ts`):
165
+ `DEFAULT_PRICING`, `defaultExtractor`, `calculateCost` (values), and
166
+ `ModelPricing`, `PricingTable`, `UsageExtractor`, `TokenUsage`,
167
+ `CostTrackingOptions` (types, from the new `src/cost/types.d.ts` —
168
+ mirrors the `src/fingerprint/types.d.ts` pattern).
169
+
170
+ ### Docs
171
+
172
+ - `gen_ai.tool.name`'s comment in `src/attributes.js` now explicitly notes
173
+ it's sourced from the OTel GenAI semantic conventions, not a custom
174
+ addition — the file already documented this at the module/section level,
175
+ but not on the constant itself, which read ambiguously next to the
176
+ custom attributes below it that do say so explicitly.
177
+ - README: new "Cost & Token Attribution (v0.5.0)" section (motivation,
178
+ zero-config quick-start, advanced config example, span-attribute and
179
+ metric tables, pricing-accuracy note, extension points); intro tagline
180
+ and "Configuration"/"Semantic conventions"/"Roadmap"/"Compatibility"
181
+ sections updated to match.
182
+
3
183
  ## 0.3.0
4
184
 
5
185
  ### Added
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # opentel-mcp
2
2
 
3
3
  > Turn every MCP tool call into an OpenTelemetry trace — including the
4
- > failures your logs won't show you.
4
+ > failures your logs won't show you, and what it cost in LLM tokens.
5
5
 
6
6
  [![CI](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
7
7
  [![npm version](https://img.shields.io/npm/v/opentel-mcp.svg)](https://www.npmjs.com/package/opentel-mcp)
@@ -9,10 +9,14 @@
9
9
  [![license](https://img.shields.io/npm/l/opentel-mcp.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/LICENSE)
10
10
 
11
11
  opentel-mcp watches every tool call your MCP (Model Context Protocol)
12
- server handles: which tool ran, how long it took, and whether it worked.
13
- It reports that as OpenTelemetry (OTel) tracesthe standard most
14
- dashboards already read. One function call; no changes to your tools'
15
- code.
12
+ server handles: which tool ran, how long it took, whether it worked, and —
13
+ when the tool result carries usage datahow many tokens it burned and
14
+ what that cost. It reports all of that as OpenTelemetry (OTel) traces —
15
+ the standard most dashboards already read. One function call; no changes
16
+ to your tools' code.
17
+
18
+ opentel-mcp is the only Node.js MCP instrumentation library that ties
19
+ tool calls to LLM cost.
16
20
 
17
21
  ## The problem
18
22
 
@@ -288,6 +292,337 @@ examples/fingerprint-demo.js`). Not yet wired through
288
292
  `computeFingerprint` directly rather than configuring the automatic
289
293
  per-call-site wrapping; tracked in the roadmap below.
290
294
 
295
+ ## Cost & Token Attribution (v0.5.0)
296
+
297
+ MCP tools increasingly wrap LLM calls themselves — a tool that
298
+ summarizes a document, drafts a reply, or classifies a ticket usually
299
+ does it by calling out to a model, and that call has a real dollar cost.
300
+ Standard MCP/OTel instrumentation has no opinion on any of this: a trace
301
+ shows a tool ran in 800ms and succeeded, with nothing about which model
302
+ it used, how many tokens it burned, or what that cost. That's the AI
303
+ FinOps gap in MCP observability today — cost and usage data exists
304
+ inside the tool call, but nothing carries it out to your traces. opentel-mcp
305
+ closes it: when a tool result carries recognizable usage data, the same
306
+ span your other instrumentation already reads also gets token counts, the
307
+ detected model, and an estimated USD cost.
308
+
309
+ ### Zero-config quick-start
310
+
311
+ ```js
312
+ import { instrumentMcpServer } from 'opentel-mcp';
313
+
314
+ instrumentMcpServer(server, {
315
+ serviceName: 'my-mcp-server',
316
+ setupNodeSdk: true, // dev mode: prints traces to your terminal
317
+ });
318
+ ```
319
+
320
+ That's it — `costTracking` defaults to enabled. Any tool result whose
321
+ usage data matches one of `defaultExtractor`'s recognized conventions
322
+ (Anthropic's `usage.input_tokens`/`usage.output_tokens`, OpenAI's
323
+ `usage.prompt_tokens`/`usage.completion_tokens`, Bedrock's
324
+ `usage.inputTokens`/`usage.outputTokens`, the MCP `_meta.usage` extension
325
+ point, or JSON-in-text inside `content[0].text`) automatically gets
326
+ `mcp.tool.tokens.*` / `mcp.tool.model` / `mcp.tool.cost.*` span
327
+ attributes, priced against `DEFAULT_PRICING`.
328
+
329
+ ### Advanced: custom pricing, a custom extractor, and a budget guardrail
330
+
331
+ ```js
332
+ import { instrumentMcpServer, DEFAULT_PRICING } from 'opentel-mcp';
333
+
334
+ instrumentMcpServer(server, {
335
+ serviceName: 'my-mcp-server',
336
+ costTracking: {
337
+ // Extend or override DEFAULT_PRICING — e.g. price an internal model
338
+ // it doesn't know about, or correct stale numbers.
339
+ pricingTable: {
340
+ ...DEFAULT_PRICING,
341
+ 'my-internal-model': { inputPer1M: 1.0, outputPer1M: 2.0, currency: 'USD' },
342
+ },
343
+ // Recognize your own tool result shape. Return null for anything you
344
+ // don't recognize — never throw (see src/cost/extractor.js).
345
+ extractor: (toolResult) => {
346
+ if (!toolResult?.tokenStats) return null;
347
+ const { in: inputTokens, out: outputTokens, modelId } = toolResult.tokenStats;
348
+ return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, model: modelId };
349
+ },
350
+ // Observability guardrail, NOT enforcement — opentel-mcp never blocks
351
+ // or throws on a budget overrun, it just flags the span.
352
+ budget: {
353
+ perSessionUsd: 5, // flag once one MCP session's calls total > $5
354
+ perToolUsd: 1, // ...or once any single tool's calls total > $1
355
+ },
356
+ },
357
+ });
358
+ ```
359
+
360
+ ### Span attributes
361
+
362
+ | Attribute | Standard OTel? | Description | Example |
363
+ |---|---|---|---|
364
+ | `mcp.tool.tokens.input` | Custom | Input tokens consumed | 1000 |
365
+ | `mcp.tool.tokens.output` | Custom | Output tokens produced | 500 |
366
+ | `mcp.tool.tokens.total` | Custom | input + output | 1500 |
367
+ | `mcp.tool.model` | Custom | Detected model name | "claude-sonnet-5" |
368
+ | `gen_ai.response.model` | Standard (GenAI semconv)[^5] | Same value as `mcp.tool.model`, co-emitted for dashboard compatibility | "claude-sonnet-5" |
369
+ | `mcp.tool.cost.usd` | Custom | Estimated cost, from `calculateCost()` | 0.0105 |
370
+ | `mcp.tool.cost.currency` | Custom | Always `"USD"` today | "USD" |
371
+ | `mcp.tool.cost.budget_exceeded` | Custom | `true` once a configured `costTracking.budget` limit is crossed | true |
372
+ | `mcp.tool.cost.budget_scope` | Custom | Which budget scope tripped: `"session"` \| `"tool"` (session wins if both did) | "session" |
373
+
374
+ [^5]: `gen_ai.response.model` is a real OTel GenAI semantic convention attribute ("the name of the model that generated the response") — but this span is an MCP tool-call span (`gen_ai.operation.name: execute_tool`), not a dedicated LLM request/response span, so co-emitting it here is a **pragmatic dashboard-compatibility choice, not a spec-pure emission**. It's set purely so off-the-shelf GenAI dashboards (Grafana, SigNoz, Honeycomb) that filter/group by `gen_ai.response.model` pick these spans up without any opentel-mcp-specific configuration. Full reasoning in `src/attributes.js`'s `ATTR_GEN_AI_RESPONSE_MODEL` docblock.
375
+
376
+ The four token/model attributes are set together or not at all; the two
377
+ cost attributes only appear when a model was detected *and* it resolves
378
+ in the configured `pricingTable`; the two budget attributes only appear
379
+ when a cost was calculated *and* a configured limit was crossed. Source
380
+ of truth: `src/attributes.js` and `src/instrument.js`'s
381
+ `applyCostAttribution()`.
382
+
383
+ ### Metrics
384
+
385
+ Two more `mcp.tool.*` metrics, via the same API-only pattern as the four
386
+ in "Metrics" above — nothing recorded until a `MeterProvider` is
387
+ registered, `enableMetrics: false` opts out of these too.
388
+
389
+ | Metric | Type | Unit | Attributes | Emitted when |
390
+ |---|---|---|---|---|
391
+ | `mcp.tool.tokens.total` | Counter | tokens | `gen_ai.tool.name`, `mcp.tool.model`[^6] | Usage detected in the tool result |
392
+ | `mcp.tool.cost.total` | Counter | USD | `gen_ai.tool.name`, `mcp.tool.model`[^6] | Cost calculated (model resolved in `pricingTable`) |
393
+
394
+ [^6]: `mcp.tool.model` is only added when a model was detected — the same optional-attribute cardinality pattern `mcp.failure.category` already uses on the other four metrics.
395
+
396
+ ### Pricing accuracy
397
+
398
+ > **Pricing table last verified 2026-07-29.** Users **MUST** override
399
+ > `pricingTable` for production accuracy — provider pricing changes
400
+ > frequently and opentel-mcp does not guarantee `DEFAULT_PRICING` stays
401
+ > current.
402
+
403
+ `DEFAULT_PRICING` (`src/cost/pricing.js`) covers 15+ models across five
404
+ providers — Anthropic, OpenAI, Google, AWS Bedrock, and DeepSeek — as a
405
+ convenience default, not a maintained price list.
406
+
407
+ ### Extending it
408
+
409
+ - `defaultExtractor` (also exported) recognizes the five conventions
410
+ listed under "Zero-config quick-start" above; pass your own
411
+ `costTracking.extractor` (a `UsageExtractor`: `(toolResult) =>
412
+ TokenUsage | null`, never throwing) to recognize anything else.
413
+ - `calculateCost(inputTokens, outputTokens, model, pricingTable)` is also
414
+ exported directly, for recomputing cost outside the instrumentation
415
+ hot path (e.g. over historical spans).
416
+ - Disable everything in this section with `costTracking: { enabled:
417
+ false }`; tracing, metrics, and fingerprinting are all unaffected.
418
+ - Budget tracking (`costTracking.budget`) is in-memory and per
419
+ `instrumentMcpServer()` call — it resets on process restart, and
420
+ session-scoped limits are skipped gracefully (not enforced against a
421
+ fallback key) for transports with no session id, like stdio.
422
+
423
+ ## Agent Thrash Detection (v0.6.0)
424
+
425
+ Watches for the same tool failing with the same v0.4 failure fingerprint
426
+ several times in a row inside one session — the pattern an LLM agent
427
+ produces when it keeps retrying a call that can't succeed. When that
428
+ crosses a threshold, it attributes the tokens and cost (v0.5) burned by
429
+ the whole retry loop to one event, instead of leaving it scattered across
430
+ N indistinguishable failed-tool-call spans.
431
+
432
+ ### Zero-config quick-start
433
+
434
+ ```js
435
+ import { instrumentMcpServer } from 'opentel-mcp';
436
+
437
+ instrumentMcpServer(server, {
438
+ serviceName: 'my-mcp-server',
439
+ setupNodeSdk: true,
440
+ });
441
+ ```
442
+
443
+ That's it — `thrashDetection` defaults to enabled, same as `fingerprinting`
444
+ and `costTracking`. Any tool that fails 3 times in a row with the same
445
+ fingerprint inside 60 seconds gets flagged automatically. **Requires
446
+ `fingerprinting: true`** (also the default): detection keys off the same
447
+ `mcp.failure.fingerprint` fingerprinting computes, so with fingerprinting
448
+ disabled, thrash detection silently never fires, regardless of
449
+ `thrashDetection`'s own settings.
450
+
451
+ ### Metrics
452
+
453
+ Same API-only pattern as every other metric in this README — nothing
454
+ recorded until a `MeterProvider` is registered.
455
+
456
+ | Metric | Type | Unit | Attributes | Emitted when |
457
+ |---|---|---|---|---|
458
+ | `mcp.tool.loop.detected` | Counter | — | `gen_ai.tool.name` | A loop crosses `threshold`, and again every `reEmitAfter` failures past it |
459
+ | `mcp.tool.loop.length` | Histogram | — | `gen_ai.tool.name` | Same |
460
+ | `mcp.tool.loop.wasted_tokens` | Histogram | tokens | `gen_ai.tool.name` | Same |
461
+ | `mcp.tool.loop.wasted_cost_usd` | Histogram | USD | `gen_ai.tool.name` | Same |
462
+ | `mcp.tool.loop.duration` | Histogram | ms | `gen_ai.tool.name` | Same |
463
+
464
+ Every metric above carries **only** `gen_ai.tool.name`. `mcp.failure.fingerprint`
465
+ and `mcp.loop.session_id` are deliberately excluded from every one of them
466
+ — both are unbounded, per-caller values (a new bug is a new fingerprint,
467
+ forever; a new session is a new session id, forever), so putting either on
468
+ a metric label would turn every distinct bug or session into its own
469
+ permanent time series. See `METRIC_SAFE_ATTRIBUTES`'s docblock in
470
+ `src/fingerprint/attributes.js`. Full detail is still available — on the
471
+ span event below, where high-cardinality attributes are safe.
472
+
473
+ ### Span event: `mcp.loop.detected`
474
+
475
+ Added to the **currently active span** (never a new one) each time a loop
476
+ metric above fires.
477
+
478
+ | Attribute | Description |
479
+ |---|---|
480
+ | `mcp.loop.length` | Consecutive same-fingerprint failures in the loop, at the moment of this emission |
481
+ | `mcp.loop.wasted_tokens_in` | Cumulative input tokens burned by the loop so far |
482
+ | `mcp.loop.wasted_tokens_out` | Cumulative output tokens burned by the loop so far |
483
+ | `mcp.loop.wasted_cost_usd` | Cumulative estimated USD cost burned by the loop so far |
484
+ | `mcp.loop.duration_ms` | Elapsed ms between the loop's first and most recent failure |
485
+ | `mcp.loop.first_span_id` | Span id of the loop's first failure |
486
+ | `mcp.loop.first_trace_id` | Trace id of the loop's first failure |
487
+ | `mcp.loop.session_id` | The session this loop belongs to |
488
+ | `mcp.failure.fingerprint` | The shared fingerprint (see "Failure Fingerprinting" above) |
489
+
490
+ ### Configuration
491
+
492
+ All fields of `thrashDetection`, each independently overridable by its own
493
+ `OTEL_MCP_THRASH_*` env var (first env-var-driven config in this
494
+ codebase) — precedence is explicit option field, then env var, then
495
+ default; an invalid/unparseable env value falls back to the default
496
+ silently, never throws. Source of truth: `src/thrash/config.js`.
497
+
498
+ | Option | Env var | Type | Default | Description |
499
+ |---|---|---|---|---|
500
+ | `enabled` | `OTEL_MCP_THRASH_ENABLED` | boolean | `true` | `false` disables thrash detection entirely |
501
+ | `threshold` | `OTEL_MCP_THRASH_THRESHOLD` | number | `3` | Consecutive same-fingerprint failures required to trigger detection |
502
+ | `windowMs` | `OTEL_MCP_THRASH_WINDOW_MS` | number | `60000` | Failures must fall inside this rolling window to count toward the same loop |
503
+ | `maxTrackedKeys` | `OTEL_MCP_THRASH_MAX_TRACKED_KEYS` | number | `1000` | LRU cap on the bounded store (`src/thrash/store.js`) |
504
+ | `entryTtlMs` | `OTEL_MCP_THRASH_ENTRY_TTL_MS` | number | `900000` | How long an idle tracked key survives before expiry |
505
+ | `reEmitAfter` | `OTEL_MCP_THRASH_RE_EMIT_AFTER` | number | `3` | Re-emit every N further failures past `threshold` (e.g. 3, 6, 9, ...) instead of once |
506
+ | `assumeSingleSession` | `OTEL_MCP_THRASH_ASSUME_SINGLE_SESSION` | boolean | `false` | Force-permits the fallback session id even when the transport can't be determined — see below |
507
+
508
+ ### Session id resolution — read this before setting `assumeSingleSession`
509
+
510
+ **This is the one setting most likely to get misconfigured, so this
511
+ section is deliberately explicit.** Thrash detection needs a session
512
+ boundary to group repeated failures under — merge two different clients'
513
+ failures into one bucket and you get a false-positive loop that never
514
+ happened to either client individually.
515
+
516
+ MCP sessions have a transport-provided id (`extra.sessionId`) on
517
+ session-oriented transports, but stdio has none — there's exactly one
518
+ connection for the process's whole lifetime instead. The resolution rules,
519
+ in order:
520
+
521
+ 1. **A real `extra.sessionId` always wins**, and permanently marks the
522
+ server as session-aware.
523
+ 2. **Once a server has been observed handing out a real session id, a
524
+ later call with none is skipped entirely** — never merged into a
525
+ shared fallback key, even if `assumeSingleSession` is set. A server
526
+ that has proven it hands out real session ids doesn't get to fall back
527
+ just because one particular call lacked one.
528
+ 3. **Before any real session id has ever been observed**, a generated
529
+ per-connection fallback id is used only when:
530
+ - the transport is **structurally confirmed single-connection** — no
531
+ `sessionId` property on `server.transport` at all (e.g. stdio's
532
+ `StdioServerTransport`, which has no session concept whatsoever), or
533
+ - **you set `assumeSingleSession: true`** — an explicit opt-in for
534
+ transports the auto-detection can't see (e.g. a custom `Transport`
535
+ implementation), where you already know every connection is 1:1.
536
+
537
+ Otherwise — an undetermined, potentially multi-client transport, with
538
+ `assumeSingleSession` left at its default `false` — detection is
539
+ **skipped silently** for that call rather than guessing.
540
+
541
+ **The risk of getting this wrong:** if you set `assumeSingleSession: true`
542
+ on a transport that's actually serving multiple concurrent clients (a
543
+ typical HTTP/SSE deployment behind a load balancer, for instance), their
544
+ failures get merged into one shared session key. Three unrelated clients
545
+ each failing once looks identical to one client failing three times in a
546
+ row — a false-positive `mcp.loop.detected` event that never happened to
547
+ any real session. Only set `assumeSingleSession: true` when you have
548
+ independent knowledge that the transport is genuinely 1:1 (a custom
549
+ in-process transport, a dedicated single-tenant connection, etc.) — never
550
+ as a blanket "make the warning go away" setting. A one-time `diag.warn`
551
+ fires the first time the fallback is actually used on a given server,
552
+ naming exactly which of the three conditions above triggered it, so you
553
+ have a chance to catch a wrong assumption before it produces bad data.
554
+
555
+ ### In-process summary
556
+
557
+ For a zero-infrastructure quick check — no metrics backend, no trace
558
+ viewer, just "is anything thrashing right now" — the object
559
+ `instrumentMcpServer()` returns gets a `getThrashSummary()` method:
560
+
561
+ ```js
562
+ const server = instrumentMcpServer(new Server(...), { serviceName: 'my-mcp-server' });
563
+
564
+ // ...later, e.g. in a health-check handler or just to eyeball it:
565
+ console.log(server.getThrashSummary());
566
+ // {
567
+ // activeLoops: 1,
568
+ // totalLoopsDetected: 4,
569
+ // totalWastedCostUsd: 0.09,
570
+ // totalWastedTokensIn: 3600,
571
+ // totalWastedTokensOut: 900,
572
+ // topOffenders: [
573
+ // { toolName: 'lookup_customer', fingerprint: 'a3f4c8e2b1d09f77', loops: 3, wastedCostUsd: 0.03, wastedTokensIn: 900, wastedTokensOut: 225 },
574
+ // ],
575
+ // }
576
+ ```
577
+
578
+ No OTel involved — nothing sent anywhere, safe to call from application
579
+ code. Never throws; returns an all-zero summary if `thrashDetection` is
580
+ disabled, or if instrumentation is disabled entirely (in which case
581
+ `getThrashSummary` isn't attached at all — check for its presence, same
582
+ as `shutdown`).
583
+
584
+ **`activeLoops` and `topOffenders` are bounded by `maxTrackedKeys`, and
585
+ are not a complete history.** They reflect only what's currently sitting
586
+ in the bounded LRU+TTL store this instant — a loop that got evicted (past
587
+ `maxTrackedKeys`) or expired (past `entryTtlMs`) since it was last
588
+ detected won't appear in either, even though it really happened.
589
+ `totalLoopsDetected` and `totalWasted*`, by contrast, are cumulative
590
+ counters that survive both eviction and expiry — they answer "how much
591
+ has this process wasted since it started" (or since the last call to an
592
+ internal `reset()`), not "what's currently active." Don't read
593
+ `topOffenders` as an audit log; read the cumulative totals for that.
594
+
595
+ ### Benchmarks
596
+
597
+ Two kinds, both under `bench/` and `test/thrash/`:
598
+
599
+ - **Performance** (`test/thrash/benchmark.test.js`, runs as part of
600
+ `npm test`): CPU/memory overhead of the detection code itself.
601
+ - **Data** (`bench/thrash-data-benchmark.js`, a standalone script — its
602
+ own header docblock documents every `--flag`, including `--sweep`):
603
+ how often a *configured* mix of healthy/broken tool calls results in a
604
+ detected loop, and what it would cost. Two runs are committed under
605
+ `bench/results/` as a reproducibility reference —
606
+ [`published-seed-42.json`](bench/results/published-seed-42.json)
607
+ / [`.txt`](bench/results/published-seed-42.txt) (a single run) and
608
+ [`published-sweep-seed-42.json`](bench/results/published-sweep-seed-42.json)
609
+ / [`.md`](bench/results/published-sweep-seed-42.md) (a sweep across
610
+ `brokenToolRate` values 0.02–0.25).
611
+
612
+ **Read the sweep table as a model you parameterize with your own
613
+ observed failure rate, not as a measurement of real-world deployments.**
614
+ `brokenToolRate` is an input the table's reader supplies — every row is
615
+ a configured assumption, not something measured from production
616
+ traffic. There is no single headline percentage here to quote as "how
617
+ often agents thrash" — the whole point of the sweep is that the answer
618
+ depends entirely on your own failure rate, which this benchmark cannot
619
+ know. Both committed files carry a full methodology block (how sessions
620
+ were isolated, that retries are scripted rather than driven by a real
621
+ LLM agent loop, the exact retry/token-growth assumptions, the model and
622
+ price used, and every known limitation that could inflate the numbers)
623
+ and an exact `node bench/thrash-data-benchmark.js ...` command to
624
+ reproduce them byte-for-byte.
625
+
291
626
  ## Configuration
292
627
 
293
628
  All options passed to `instrumentMcpServer(server, options)`. Source of
@@ -301,7 +636,12 @@ truth: `src/config.js`.
301
636
  | `enabled` | boolean | `true` | `false` disables all instrumentation |
302
637
  | `enableMetrics` | boolean | `true` | `false` disables `mcp.tool.*` metrics only |
303
638
  | `fingerprinting` | boolean | `true` | `false` disables `mcp.failure.*` attributes |
639
+ | `costTracking` | object | see below | Controls cost/token attribution[^9] — see "Cost & Token Attribution" above |
640
+ | `thrashDetection` | object | see below | Controls Agent Thrash Detection[^10] — see "Agent Thrash Detection" above. Requires `fingerprinting: true` |
641
+
642
+ [^10]: `{ enabled?, threshold?, windowMs?, maxTrackedKeys?, entryTtlMs?, reEmitAfter?, assumeSingleSession? }`, all fields optional, individually defaulted, and individually overridable via an `OTEL_MCP_THRASH_*` env var — see "Agent Thrash Detection" → "Configuration" above for the full table.
304
643
 
644
+ [^9]: `{ enabled?: boolean; pricingTable?: PricingTable; extractor?: UsageExtractor; budget?: { perSessionUsd?: number; perToolUsd?: number } }`, all fields optional and individually defaulted — `{ enabled: true, pricingTable: DEFAULT_PRICING, extractor: defaultExtractor }` with budget tracking off.
305
645
  [^2]: Required only when `setupNodeSdk` is `true`. Has no effect otherwise — the host app's registered `TracerProvider` owns the resource; passing it anyway logs a one-time `diag.warn`.
306
646
  [^3]: Creates and registers a `NodeTracerProvider` that always prints to stderr (safe alongside stdio-transport servers — ADR 003), additionally exporting via OTLP/HTTP if `exporterUrl` is set.
307
647
  [^4]: Only takes effect when `setupNodeSdk` is `true`.
@@ -348,13 +688,21 @@ until `1.0`, tracked in release notes rather than silently shipped.
348
688
 
349
689
  opentel-mcp follows those conventions (published by the OTel GenAI SIG,
350
690
  moved there from the main `semantic-conventions` repo, where the MCP
351
- conventions are now deprecated) for everything they define, and adds two
691
+ conventions are now deprecated) for everything they define, and adds
352
692
  namespaces of its own where they don't yet: `mcp.tool.*` (call-count and
353
- duration metrics) and `mcp.failure.*` (failure fingerprinting). Both are
354
- documented as non-spec at every attribute (`src/attributes.js`,
355
- `src/fingerprint/attributes.js`), and are candidates to fold into the
356
- spec's own metrics/error vocabulary if it grows an equivalent. Full
357
- reasoning: ADR 004 in `docs/adr/`.
693
+ duration metrics, and as of v0.5.0 token/cost attribution and budget
694
+ attributes), `mcp.failure.*` (failure fingerprinting), and as of
695
+ v0.6.0 — `mcp.tool.loop.*` / `mcp.loop.*` (Agent Thrash Detection). All
696
+ are documented as non-spec at every attribute (`src/attributes.js`,
697
+ `src/fingerprint/attributes.js`, `src/thrash/attributes.js`), and are
698
+ candidates to fold into the spec's own metrics/error vocabulary if it
699
+ grows an equivalent. Full reasoning: ADR 004 in `docs/adr/`.
700
+
701
+ One exception, also in `src/attributes.js`: `gen_ai.response.model` *is*
702
+ a real spec attribute, co-emitted alongside the custom `mcp.tool.model`
703
+ purely for compatibility with GenAI dashboards that already query it —
704
+ see the "Cost & Token Attribution" section above for why that's a
705
+ pragmatic choice rather than a spec-pure one.
358
706
 
359
707
  ## Compatibility
360
708
 
@@ -364,16 +712,19 @@ reasoning: ADR 004 in `docs/adr/`.
364
712
  - Supports both low-level `Server` and high-level `McpServer` APIs
365
713
  - @modelcontextprotocol/sdk ^1.0.0
366
714
  - @opentelemetry/api ^1.9.0
367
- - 127 tests (`npm test`) — see `test/`
715
+ - 369 tests (`npm test`) — see `test/`
716
+ - `npm run typecheck` (`tsc --noEmit`) type-checks the public `.d.ts`
717
+ surface (`src/index.d.ts` and friends) — see CONTRIBUTING.md
368
718
 
369
719
  ## Roadmap
370
720
 
371
721
  - v0.4: Deep Failure Fingerprinting ✓ — see "Failure Fingerprinting" above
372
722
  and ADR 006.
373
- - v0.5: Failure clustering + regression detection
374
- - Future: recovery hints, root-cause chaining across parent spans,
375
- alignment with the OTel GenAI SIG's MCP semantic conventions when
376
- published
723
+ - v0.5: Cost & Token Attribution ✓ — see "Cost & Token Attribution" above.
724
+ - v0.6: Agent Thrash Detection see "Agent Thrash Detection" above.
725
+ - Future: failure clustering + regression detection; recovery hints;
726
+ root-cause chaining across parent spans; alignment with the OTel GenAI
727
+ SIG's MCP semantic conventions when published
377
728
  - Also still tracked, not silently dropped: exposing `computeFingerprint`'s
378
729
  `classifiers`/`stackFrames` options through `instrumentMcpServer()`
379
730
  itself; opt-in `gen_ai.tool.call.arguments` support with a redaction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -24,6 +24,7 @@
24
24
  "test": "vitest run",
25
25
  "test:watch": "vitest",
26
26
  "test:coverage": "vitest run --coverage",
27
+ "typecheck": "tsc --noEmit",
27
28
  "bench": "vitest bench --run",
28
29
  "prepack": "node scripts/strip-workspaces.js",
29
30
  "postpack": "node scripts/restore-workspaces.js"