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 +180 -0
- package/README.md +367 -16
- package/package.json +2 -1
- package/src/attributes.js +81 -1
- package/src/config.js +41 -0
- package/src/cost/budget.js +113 -0
- package/src/cost/calculator.js +55 -0
- package/src/cost/extractor.js +189 -0
- package/src/cost/pricing.js +56 -0
- package/src/cost/types.d.ts +85 -0
- package/src/index.d.ts +83 -2
- package/src/index.js +4 -0
- package/src/instrument.js +425 -5
- package/src/metrics.js +31 -0
- package/src/thrash/attributes.js +50 -0
- package/src/thrash/config.js +123 -0
- package/src/thrash/detector.js +316 -0
- package/src/thrash/emitter.js +122 -0
- package/src/thrash/store.js +129 -0
- package/src/thrash/types.d.ts +158 -0
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
|
[](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
|
|
7
7
|
[](https://www.npmjs.com/package/opentel-mcp)
|
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
[](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,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
server handles: which tool ran, how long it took, whether it worked, and —
|
|
13
|
+
when the tool result carries usage data — how 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
|
|
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
|
|
354
|
-
|
|
355
|
-
`
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
-
|
|
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:
|
|
374
|
-
-
|
|
375
|
-
|
|
376
|
-
|
|
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.
|
|
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"
|