opentel-mcp 0.4.0 → 0.5.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 +72 -0
- package/README.md +153 -12
- package/package.json +1 -1
- package/src/attributes.js +81 -1
- package/src/config.js +31 -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 +27 -0
- package/src/index.js +4 -0
- package/src/instrument.js +111 -3
- package/src/metrics.js +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Added — Cost & Token Attribution
|
|
6
|
+
|
|
7
|
+
- Span attributes, added on any tool call whose result carries recognizable
|
|
8
|
+
usage data: `mcp.tool.tokens.input`, `mcp.tool.tokens.output`,
|
|
9
|
+
`mcp.tool.tokens.total`, `mcp.tool.model`, `gen_ai.response.model`
|
|
10
|
+
(co-emitted alongside `mcp.tool.model` for GenAI-dashboard compatibility —
|
|
11
|
+
see README's "Cost & Token Attribution" section and the
|
|
12
|
+
`ATTR_GEN_AI_RESPONSE_MODEL` docblock in `src/attributes.js` for why this
|
|
13
|
+
is a pragmatic compatibility choice, not a spec-pure emission),
|
|
14
|
+
`mcp.tool.cost.usd`, and `mcp.tool.cost.currency`. All added by
|
|
15
|
+
`applyCostAttribution()` in `src/instrument.js`, wrapped in its own
|
|
16
|
+
try/catch — cost tracking can never break a span.
|
|
17
|
+
- Two new metric instruments, via the same `@opentelemetry/api`-only
|
|
18
|
+
pattern as the four existing `mcp.tool.*` metrics: `mcp.tool.tokens.total`
|
|
19
|
+
(counter, unit `tokens`) and `mcp.tool.cost.total` (counter, unit `USD`),
|
|
20
|
+
both attributed by `gen_ai.tool.name` + `mcp.tool.model` (model only
|
|
21
|
+
added when detected, same optional-attribute pattern
|
|
22
|
+
`mcp.failure.category` already uses).
|
|
23
|
+
- Per-session and per-tool budget guardrails (`costTracking.budget`,
|
|
24
|
+
`src/cost/budget.js`): in-memory cumulative-cost tracking, flags
|
|
25
|
+
`mcp.tool.cost.budget_exceeded` / `mcp.tool.cost.budget_scope`
|
|
26
|
+
(`"session"` | `"tool"`, session wins if both trip on the same call) on
|
|
27
|
+
the span once a configured `perSessionUsd`/`perToolUsd` limit is
|
|
28
|
+
crossed. **Observability only — never blocks or throws.** Calls with no
|
|
29
|
+
MCP session id (e.g. stdio transport) are skipped for session tracking
|
|
30
|
+
rather than lumped under a fallback key.
|
|
31
|
+
- `DEFAULT_PRICING` (`src/cost/pricing.js`): a default pricing table
|
|
32
|
+
covering 15+ models across five providers — Anthropic, OpenAI, Google,
|
|
33
|
+
AWS Bedrock, and DeepSeek. **Last verified 2026-07-29 — provider pricing
|
|
34
|
+
changes frequently and this table is not guaranteed to stay current;
|
|
35
|
+
override `costTracking.pricingTable` for production accuracy.**
|
|
36
|
+
- `defaultExtractor` (`src/cost/extractor.js`): recognizes Anthropic
|
|
37
|
+
(`usage.input_tokens`/`usage.output_tokens`), OpenAI
|
|
38
|
+
(`usage.prompt_tokens`/`usage.completion_tokens`), and Bedrock
|
|
39
|
+
(`usage.inputTokens`/`usage.outputTokens`) usage shapes, the MCP
|
|
40
|
+
`_meta.usage` extension point, and JSON-in-text inside
|
|
41
|
+
`content[0].text`. Never throws — unrecognized shapes resolve to `null`.
|
|
42
|
+
Pluggable via `costTracking.extractor` (type `UsageExtractor`) for
|
|
43
|
+
custom tool result formats.
|
|
44
|
+
- `calculateCost()` (`src/cost/calculator.js`): normalizes a model name
|
|
45
|
+
(lowercase, strips a `provider/` prefix) and prices it against a
|
|
46
|
+
`PricingTable`. Returns `null` — never throws — for an unrecognized
|
|
47
|
+
model or invalid token counts.
|
|
48
|
+
- `costTracking` option on `instrumentMcpServer()` (see `src/config.js`):
|
|
49
|
+
`{ enabled?: boolean; pricingTable?: PricingTable; extractor?:
|
|
50
|
+
UsageExtractor; budget?: { perSessionUsd?: number; perToolUsd?: number
|
|
51
|
+
} }`. Defaults to enabled, `DEFAULT_PRICING`, `defaultExtractor`, budget
|
|
52
|
+
tracking off. Any field can be overridden independently.
|
|
53
|
+
|
|
54
|
+
### Public API additions
|
|
55
|
+
|
|
56
|
+
Re-exported from the package root (`src/index.js` / `src/index.d.ts`):
|
|
57
|
+
`DEFAULT_PRICING`, `defaultExtractor`, `calculateCost` (values), and
|
|
58
|
+
`ModelPricing`, `PricingTable`, `UsageExtractor`, `TokenUsage`,
|
|
59
|
+
`CostTrackingOptions` (types, from the new `src/cost/types.d.ts` —
|
|
60
|
+
mirrors the `src/fingerprint/types.d.ts` pattern).
|
|
61
|
+
|
|
62
|
+
### Docs
|
|
63
|
+
|
|
64
|
+
- `gen_ai.tool.name`'s comment in `src/attributes.js` now explicitly notes
|
|
65
|
+
it's sourced from the OTel GenAI semantic conventions, not a custom
|
|
66
|
+
addition — the file already documented this at the module/section level,
|
|
67
|
+
but not on the constant itself, which read ambiguously next to the
|
|
68
|
+
custom attributes below it that do say so explicitly.
|
|
69
|
+
- README: new "Cost & Token Attribution (v0.5.0)" section (motivation,
|
|
70
|
+
zero-config quick-start, advanced config example, span-attribute and
|
|
71
|
+
metric tables, pricing-accuracy note, extension points); intro tagline
|
|
72
|
+
and "Configuration"/"Semantic conventions"/"Roadmap"/"Compatibility"
|
|
73
|
+
sections updated to match.
|
|
74
|
+
|
|
3
75
|
## 0.3.0
|
|
4
76
|
|
|
5
77
|
### 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,134 @@ 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
|
+
|
|
291
423
|
## Configuration
|
|
292
424
|
|
|
293
425
|
All options passed to `instrumentMcpServer(server, options)`. Source of
|
|
@@ -301,7 +433,9 @@ truth: `src/config.js`.
|
|
|
301
433
|
| `enabled` | boolean | `true` | `false` disables all instrumentation |
|
|
302
434
|
| `enableMetrics` | boolean | `true` | `false` disables `mcp.tool.*` metrics only |
|
|
303
435
|
| `fingerprinting` | boolean | `true` | `false` disables `mcp.failure.*` attributes |
|
|
436
|
+
| `costTracking` | object | see below | Controls cost/token attribution[^9] — see "Cost & Token Attribution" above |
|
|
304
437
|
|
|
438
|
+
[^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
439
|
[^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
440
|
[^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
441
|
[^4]: Only takes effect when `setupNodeSdk` is `true`.
|
|
@@ -348,14 +482,21 @@ until `1.0`, tracked in release notes rather than silently shipped.
|
|
|
348
482
|
|
|
349
483
|
opentel-mcp follows those conventions (published by the OTel GenAI SIG,
|
|
350
484
|
moved there from the main `semantic-conventions` repo, where the MCP
|
|
351
|
-
conventions are now deprecated) for everything they define, and adds
|
|
485
|
+
conventions are now deprecated) for everything they define, and adds
|
|
352
486
|
namespaces of its own where they don't yet: `mcp.tool.*` (call-count and
|
|
353
|
-
duration metrics
|
|
487
|
+
duration metrics, and — as of v0.5.0 — token/cost attribution and budget
|
|
488
|
+
attributes) and `mcp.failure.*` (failure fingerprinting). Both are
|
|
354
489
|
documented as non-spec at every attribute (`src/attributes.js`,
|
|
355
490
|
`src/fingerprint/attributes.js`), and are candidates to fold into the
|
|
356
491
|
spec's own metrics/error vocabulary if it grows an equivalent. Full
|
|
357
492
|
reasoning: ADR 004 in `docs/adr/`.
|
|
358
493
|
|
|
494
|
+
One exception, also in `src/attributes.js`: `gen_ai.response.model` *is*
|
|
495
|
+
a real spec attribute, co-emitted alongside the custom `mcp.tool.model`
|
|
496
|
+
purely for compatibility with GenAI dashboards that already query it —
|
|
497
|
+
see the "Cost & Token Attribution" section above for why that's a
|
|
498
|
+
pragmatic choice rather than a spec-pure one.
|
|
499
|
+
|
|
359
500
|
## Compatibility
|
|
360
501
|
|
|
361
502
|
- Node.js 20+
|
|
@@ -364,16 +505,16 @@ reasoning: ADR 004 in `docs/adr/`.
|
|
|
364
505
|
- Supports both low-level `Server` and high-level `McpServer` APIs
|
|
365
506
|
- @modelcontextprotocol/sdk ^1.0.0
|
|
366
507
|
- @opentelemetry/api ^1.9.0
|
|
367
|
-
-
|
|
508
|
+
- 288 tests (`npm test`) — see `test/`
|
|
368
509
|
|
|
369
510
|
## Roadmap
|
|
370
511
|
|
|
371
512
|
- v0.4: Deep Failure Fingerprinting ✓ — see "Failure Fingerprinting" above
|
|
372
513
|
and ADR 006.
|
|
373
|
-
- v0.5:
|
|
374
|
-
- Future:
|
|
375
|
-
alignment with the OTel GenAI
|
|
376
|
-
published
|
|
514
|
+
- v0.5: Cost & Token Attribution ✓ — see "Cost & Token Attribution" above.
|
|
515
|
+
- Future: failure clustering + regression detection; recovery hints;
|
|
516
|
+
root-cause chaining across parent spans; alignment with the OTel GenAI
|
|
517
|
+
SIG's MCP semantic conventions when published
|
|
377
518
|
- Also still tracked, not silently dropped: exposing `computeFingerprint`'s
|
|
378
519
|
`classifiers`/`stackFrames` options through `instrumentMcpServer()`
|
|
379
520
|
itself; opt-in `gen_ai.tool.call.arguments` support with a redaction
|
package/package.json
CHANGED
package/src/attributes.js
CHANGED
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
/** Required. The JSON-RPC method name, e.g. "tools/call". */
|
|
16
16
|
export const ATTR_MCP_METHOD_NAME = 'mcp.method.name';
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Conditionally Required (when the operation targets a specific tool).
|
|
20
|
+
* Sourced from the OTel GenAI semantic conventions (`gen_ai.tool.name`),
|
|
21
|
+
* not invented by this package — MCP tool calls are GenAI `execute_tool`
|
|
22
|
+
* calls under the hood, so the MCP server span conventions reuse the
|
|
23
|
+
* existing `gen_ai.*` namespace rather than defining their own tool-name
|
|
24
|
+
* attribute. Contrast with the "Custom (non-spec) attributes" section
|
|
25
|
+
* below, where every entry explicitly says it's NOT part of the spec.
|
|
26
|
+
*/
|
|
19
27
|
export const ATTR_GEN_AI_TOOL_NAME = 'gen_ai.tool.name';
|
|
20
28
|
|
|
21
29
|
/** Conditionally Required (when the client executes a request with a non-null id). */
|
|
@@ -35,6 +43,23 @@ export const ATTR_ERROR_TYPE = 'error.type';
|
|
|
35
43
|
*/
|
|
36
44
|
export const ATTR_GEN_AI_OPERATION_NAME = 'gen_ai.operation.name';
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* A real OTel GenAI semantic convention attribute ("the name of the model
|
|
48
|
+
* that generated the response") — but co-emitted here as a *pragmatic
|
|
49
|
+
* dashboard-compatibility* choice, not a spec-pure one. This span is an
|
|
50
|
+
* MCP tool-call span (`gen_ai.operation.name: execute_tool`), not a
|
|
51
|
+
* dedicated GenAI request/response span, so strictly speaking this
|
|
52
|
+
* attribute describes a model used *inside* the tool's implementation
|
|
53
|
+
* rather than the model that produced *this* span's own response. It's
|
|
54
|
+
* set to the same value as ATTR_MCP_TOOL_MODEL below, whenever
|
|
55
|
+
* applyCostAttribution() (instrument.js) detects one, purely so
|
|
56
|
+
* off-the-shelf GenAI dashboards (Grafana, SigNoz, Honeycomb) that filter
|
|
57
|
+
* or group by `gen_ai.response.model` pick these spans up without any
|
|
58
|
+
* opentel-mcp-specific configuration. See README's "Cost & Token
|
|
59
|
+
* Attribution" section for the full rationale.
|
|
60
|
+
*/
|
|
61
|
+
export const ATTR_GEN_AI_RESPONSE_MODEL = 'gen_ai.response.model';
|
|
62
|
+
|
|
38
63
|
/**
|
|
39
64
|
* Well-known error.type value for a JSON-RPC call that succeeded but whose
|
|
40
65
|
* CallToolResult has isError: true — a tool-level failure, not a transport
|
|
@@ -85,3 +110,58 @@ export const MCP_TOOL_OUTCOME_ERROR = 'error';
|
|
|
85
110
|
|
|
86
111
|
/** Well-known mcp.tool.outcome value: isError: true (see ERROR_TYPE_TOOL_ERROR above). */
|
|
87
112
|
export const MCP_TOOL_OUTCOME_SILENT_FAILURE = 'silent_failure';
|
|
113
|
+
|
|
114
|
+
// --- Cost & token attribution attributes (v0.5.0, non-spec) ---
|
|
115
|
+
//
|
|
116
|
+
// NOT part of the MCP semantic conventions — there is no spec-defined way
|
|
117
|
+
// to report LLM token usage or cost on an MCP tool-call span. These are
|
|
118
|
+
// opentel-mcp's own addition, populated by src/cost/extractor.js and
|
|
119
|
+
// src/cost/calculator.js when a tool result carries recognizable usage
|
|
120
|
+
// data (see instrument.js's applyCostAttribution()). All four token/model
|
|
121
|
+
// attributes are set together or not at all; the two cost attributes are
|
|
122
|
+
// only set when a model was detected *and* it resolves in the configured
|
|
123
|
+
// pricing table (see calculateCost() in src/cost/calculator.js).
|
|
124
|
+
|
|
125
|
+
/** Input tokens consumed by the tool call, as reported by the underlying model/provider. */
|
|
126
|
+
export const ATTR_MCP_TOOL_TOKENS_INPUT = 'mcp.tool.tokens.input';
|
|
127
|
+
|
|
128
|
+
/** Output tokens produced by the tool call. */
|
|
129
|
+
export const ATTR_MCP_TOOL_TOKENS_OUTPUT = 'mcp.tool.tokens.output';
|
|
130
|
+
|
|
131
|
+
/** input + output tokens for the tool call. */
|
|
132
|
+
export const ATTR_MCP_TOOL_TOKENS_TOTAL = 'mcp.tool.tokens.total';
|
|
133
|
+
|
|
134
|
+
/** Model name detected for the tool call (e.g. "claude-sonnet-5"), when the extractor found one. */
|
|
135
|
+
export const ATTR_MCP_TOOL_MODEL = 'mcp.tool.model';
|
|
136
|
+
|
|
137
|
+
/** Estimated cost of the tool call in ATTR_MCP_TOOL_COST_CURRENCY, from calculateCost(). */
|
|
138
|
+
export const ATTR_MCP_TOOL_COST_USD = 'mcp.tool.cost.usd';
|
|
139
|
+
|
|
140
|
+
/** Currency of ATTR_MCP_TOOL_COST_USD. Always MCP_TOOL_COST_CURRENCY_USD today. */
|
|
141
|
+
export const ATTR_MCP_TOOL_COST_CURRENCY = 'mcp.tool.cost.currency';
|
|
142
|
+
|
|
143
|
+
/** Well-known mcp.tool.cost.currency value — the only currency DEFAULT_PRICING and calculateCost() support. */
|
|
144
|
+
export const MCP_TOOL_COST_CURRENCY_USD = 'USD';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Set to true when this call's cost pushed a configured budget
|
|
148
|
+
* (costTracking.budget — see src/cost/budget.js) over its limit. Only set
|
|
149
|
+
* on the call that crosses the threshold and every call after — not
|
|
150
|
+
* retroactively on earlier, under-budget calls. Observability only: this
|
|
151
|
+
* package never blocks or throws on a budget overrun.
|
|
152
|
+
*/
|
|
153
|
+
export const ATTR_MCP_TOOL_COST_BUDGET_EXCEEDED = 'mcp.tool.cost.budget_exceeded';
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Which budget scope tripped: MCP_TOOL_COST_BUDGET_SCOPE_SESSION or
|
|
157
|
+
* MCP_TOOL_COST_BUDGET_SCOPE_TOOL. Only present alongside
|
|
158
|
+
* ATTR_MCP_TOOL_COST_BUDGET_EXCEEDED === true. When both scopes are over
|
|
159
|
+
* budget on the same call, session wins (see src/cost/budget.js).
|
|
160
|
+
*/
|
|
161
|
+
export const ATTR_MCP_TOOL_COST_BUDGET_SCOPE = 'mcp.tool.cost.budget_scope';
|
|
162
|
+
|
|
163
|
+
/** Well-known mcp.tool.cost.budget_scope value: costTracking.budget.perSessionUsd was exceeded. */
|
|
164
|
+
export const MCP_TOOL_COST_BUDGET_SCOPE_SESSION = 'session';
|
|
165
|
+
|
|
166
|
+
/** Well-known mcp.tool.cost.budget_scope value: costTracking.budget.perToolUsd was exceeded. */
|
|
167
|
+
export const MCP_TOOL_COST_BUDGET_SCOPE_TOOL = 'tool';
|
package/src/config.js
CHANGED
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { diag } from '@opentelemetry/api';
|
|
7
|
+
import { DEFAULT_PRICING } from './cost/pricing.js';
|
|
8
|
+
import { defaultExtractor } from './cost/extractor.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} CostTrackingOptions
|
|
12
|
+
* @property {boolean} [enabled=true] - Set to false to disable cost/token span attributes and the
|
|
13
|
+
* mcp.tool.tokens.total / mcp.tool.cost.total metrics entirely.
|
|
14
|
+
* @property {import('./cost/pricing.js').PricingTable} [pricingTable] - Overrides DEFAULT_PRICING
|
|
15
|
+
* (src/cost/pricing.js). Supply your own table to price models DEFAULT_PRICING doesn't know about, or to
|
|
16
|
+
* correct stale pricing — see that module's docblock.
|
|
17
|
+
* @property {import('./cost/extractor.js').UsageExtractor} [extractor] - Overrides defaultExtractor
|
|
18
|
+
* (src/cost/extractor.js). Supply your own to recognize a tool result shape defaultExtractor doesn't.
|
|
19
|
+
* @property {import('./cost/budget.js').BudgetConfig} [budget] - Per-session and per-tool cumulative-cost
|
|
20
|
+
* guardrails (src/cost/budget.js). Observability only — crossing a limit adds
|
|
21
|
+
* mcp.tool.cost.budget_exceeded / mcp.tool.cost.budget_scope span attributes; it never blocks or throws.
|
|
22
|
+
* Omit to disable budget tracking (the default).
|
|
23
|
+
*/
|
|
7
24
|
|
|
8
25
|
/**
|
|
9
26
|
* @typedef {object} InstrumentOptions
|
|
@@ -33,6 +50,12 @@ import { diag } from '@opentelemetry/api';
|
|
|
33
50
|
* metrics (see src/fingerprint/attributes.js). computeFingerprint() never throws, so this only trades a
|
|
34
51
|
* small amount of per-failure CPU (see the p99 < 200µs budget in test/fingerprint/benchmark.test.js) for
|
|
35
52
|
* fingerprinting.
|
|
53
|
+
* @property {CostTrackingOptions} [costTracking] - Controls the mcp.tool.tokens.* / mcp.tool.model /
|
|
54
|
+
* mcp.tool.cost.* span attributes, the mcp.tool.tokens.total / mcp.tool.cost.total metrics, and the
|
|
55
|
+
* optional per-session/per-tool budget guardrail (see instrument.js's applyCostAttribution(),
|
|
56
|
+
* src/metrics.js, and src/cost/budget.js). Defaults to `{ enabled: true, pricingTable: DEFAULT_PRICING,
|
|
57
|
+
* extractor: defaultExtractor }` with budget tracking off; any fields you omit from a partial object fall
|
|
58
|
+
* back to those defaults individually, so `{ enabled: false }` alone works.
|
|
36
59
|
*/
|
|
37
60
|
|
|
38
61
|
// Guards the "serviceName has no effect" diagnostic below so it fires once
|
|
@@ -73,6 +96,8 @@ export function resolveOptions(options) {
|
|
|
73
96
|
);
|
|
74
97
|
}
|
|
75
98
|
|
|
99
|
+
const rawCostTracking = opts.costTracking ?? {};
|
|
100
|
+
|
|
76
101
|
return {
|
|
77
102
|
serviceName: opts.serviceName,
|
|
78
103
|
exporterUrl: opts.exporterUrl,
|
|
@@ -80,5 +105,11 @@ export function resolveOptions(options) {
|
|
|
80
105
|
enableMetrics: opts.enableMetrics ?? true,
|
|
81
106
|
setupNodeSdk,
|
|
82
107
|
fingerprinting: opts.fingerprinting ?? true,
|
|
108
|
+
costTracking: {
|
|
109
|
+
enabled: rawCostTracking.enabled ?? true,
|
|
110
|
+
pricingTable: rawCostTracking.pricingTable ?? DEFAULT_PRICING,
|
|
111
|
+
extractor: rawCostTracking.extractor ?? defaultExtractor,
|
|
112
|
+
budget: rawCostTracking.budget,
|
|
113
|
+
},
|
|
83
114
|
};
|
|
84
115
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cost/budget
|
|
3
|
+
* In-memory, best-effort budget guardrails for cost attribution.
|
|
4
|
+
*
|
|
5
|
+
* Observability only — this module never blocks a tool call and never
|
|
6
|
+
* throws. It exists so `applyCostAttribution()` (src/instrument.js) can
|
|
7
|
+
* flag a span when cumulative spend crosses a configured limit; enforcing
|
|
8
|
+
* that limit (denying the call, alerting, etc.) is left entirely to
|
|
9
|
+
* whatever consumes the resulting `mcp.tool.cost.budget_exceeded` span
|
|
10
|
+
* attribute (see src/attributes.js).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} BudgetConfig
|
|
15
|
+
* @property {number} [perSessionUsd] - Cumulative-cost limit per MCP session id. Calls with no session id
|
|
16
|
+
* (e.g. stdio transport, which has none) are never tracked against this limit — see accumulate() below.
|
|
17
|
+
* @property {number} [perToolUsd] - Cumulative-cost limit per tool name.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} BudgetCheckResult
|
|
22
|
+
* @property {boolean} exceeded
|
|
23
|
+
* @property {'session' | 'tool' | null} scope - Which limit tripped on *this* call. null when `exceeded` is
|
|
24
|
+
* false. When both scopes are over budget on the same call, "session" wins — matches
|
|
25
|
+
* ATTR_MCP_TOOL_COST_BUDGET_SCOPE's documented precedence in src/attributes.js.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
* @returns {value is string}
|
|
31
|
+
*/
|
|
32
|
+
function isNonEmptyString(value) {
|
|
33
|
+
return typeof value === 'string' && value !== '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @returns {value is number}
|
|
39
|
+
*/
|
|
40
|
+
function isFiniteNumber(value) {
|
|
41
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Adds `amount` to `map.get(key)` (defaulting the prior total to 0) and
|
|
46
|
+
* returns the new running total. No-op — returns null — when `key` isn't
|
|
47
|
+
* a non-empty string or `amount` isn't a finite number, so a bad call
|
|
48
|
+
* neither throws nor silently poisons the map with NaN.
|
|
49
|
+
*
|
|
50
|
+
* @param {Map<string, number>} map
|
|
51
|
+
* @param {unknown} key
|
|
52
|
+
* @param {number} amount
|
|
53
|
+
* @returns {number | null}
|
|
54
|
+
*/
|
|
55
|
+
function accumulate(map, key, amount) {
|
|
56
|
+
if (!isNonEmptyString(key) || !isFiniteNumber(amount)) return null;
|
|
57
|
+
const total = (map.get(key) ?? 0) + amount;
|
|
58
|
+
map.set(key, total);
|
|
59
|
+
return total;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a budget tracker scoped to one instrumented server (one call per
|
|
64
|
+
* `instrumentMcpServer()` invocation — see src/instrument.js). Holds two
|
|
65
|
+
* independent, unbounded-lifetime in-memory Maps: one keyed by MCP session
|
|
66
|
+
* id, one keyed by tool name. Neither is ever cleared — this mirrors the
|
|
67
|
+
* process-lifetime accumulation the "per session" / "per tool" limits are
|
|
68
|
+
* meant to describe; restart the process (or, for long-lived servers,
|
|
69
|
+
* build your own eviction on top) to reset.
|
|
70
|
+
*
|
|
71
|
+
* @param {BudgetConfig | undefined} budget - costTracking.budget from config.js. Omitted/undefined limits
|
|
72
|
+
* mean that scope is never tracked or checked — recordAndCheck() becomes a pure no-op for it.
|
|
73
|
+
* @returns {{ recordAndCheck: (sessionId: unknown, toolName: unknown, costUsd: number) => BudgetCheckResult }}
|
|
74
|
+
*/
|
|
75
|
+
export function createBudgetTracker(budget) {
|
|
76
|
+
const perSessionUsd = isFiniteNumber(budget?.perSessionUsd) ? budget.perSessionUsd : undefined;
|
|
77
|
+
const perToolUsd = isFiniteNumber(budget?.perToolUsd) ? budget.perToolUsd : undefined;
|
|
78
|
+
|
|
79
|
+
/** @type {Map<string, number>} */
|
|
80
|
+
const sessionCostMap = new Map();
|
|
81
|
+
/** @type {Map<string, number>} */
|
|
82
|
+
const toolCostMap = new Map();
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
recordAndCheck(sessionId, toolName, costUsd) {
|
|
86
|
+
try {
|
|
87
|
+
let sessionExceeded = false;
|
|
88
|
+
let toolExceeded = false;
|
|
89
|
+
|
|
90
|
+
if (perSessionUsd !== undefined) {
|
|
91
|
+
const total = accumulate(sessionCostMap, sessionId, costUsd);
|
|
92
|
+
// total is null when sessionId isn't usable (e.g. a stdio-transport
|
|
93
|
+
// call with no session id at all) — skip that scope silently
|
|
94
|
+
// rather than tracking against a made-up key.
|
|
95
|
+
if (total !== null) sessionExceeded = total > perSessionUsd;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (perToolUsd !== undefined) {
|
|
99
|
+
const total = accumulate(toolCostMap, toolName, costUsd);
|
|
100
|
+
if (total !== null) toolExceeded = total > perToolUsd;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (sessionExceeded) return { exceeded: true, scope: 'session' };
|
|
104
|
+
if (toolExceeded) return { exceeded: true, scope: 'tool' };
|
|
105
|
+
return { exceeded: false, scope: null };
|
|
106
|
+
} catch {
|
|
107
|
+
// Never throw — see module docblock. A tracking failure here must
|
|
108
|
+
// read as "no budget signal this call", not break the span.
|
|
109
|
+
return { exceeded: false, scope: null };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cost/calculator
|
|
3
|
+
* Cost calculation for MCP tool-call token usage against a {@link
|
|
4
|
+
* import('./pricing.js').PricingTable}.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes a model name for pricing-table lookup: lowercases it and
|
|
9
|
+
* strips a leading "provider/" prefix (e.g. "Anthropic/Claude-Opus-4-7"
|
|
10
|
+
* -> "claude-opus-4-7").
|
|
11
|
+
*
|
|
12
|
+
* @param {string} model
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function normalizeModelName(model) {
|
|
16
|
+
const lower = model.toLowerCase();
|
|
17
|
+
const slashIndex = lower.indexOf('/');
|
|
18
|
+
return slashIndex === -1 ? lower : lower.slice(slashIndex + 1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculates the USD cost of a request from its input/output token counts,
|
|
23
|
+
* a model name, and a pricing table. Never throws: an unrecognized model or
|
|
24
|
+
* an invalid token count resolves to `null` rather than an exception,
|
|
25
|
+
* matching this library's fail-open philosophy (see instrument.js).
|
|
26
|
+
*
|
|
27
|
+
* @param {number} inputTokens
|
|
28
|
+
* @param {number} outputTokens
|
|
29
|
+
* @param {string} model - Matched against `pricingTable` after
|
|
30
|
+
* lowercasing and stripping a "provider/" prefix.
|
|
31
|
+
* @param {import('./pricing.js').PricingTable} pricingTable
|
|
32
|
+
* @returns {number | null} Cost in USD rounded to 6 decimals, or `null` if
|
|
33
|
+
* `model` isn't in `pricingTable` or a token count is negative,
|
|
34
|
+
* non-numeric, or non-finite.
|
|
35
|
+
*/
|
|
36
|
+
export function calculateCost(inputTokens, outputTokens, model, pricingTable) {
|
|
37
|
+
if (
|
|
38
|
+
typeof inputTokens !== 'number' ||
|
|
39
|
+
typeof outputTokens !== 'number' ||
|
|
40
|
+
!Number.isFinite(inputTokens) ||
|
|
41
|
+
!Number.isFinite(outputTokens) ||
|
|
42
|
+
inputTokens < 0 ||
|
|
43
|
+
outputTokens < 0
|
|
44
|
+
) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pricing = pricingTable[normalizeModelName(model)];
|
|
49
|
+
if (!pricing) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cost = (inputTokens / 1_000_000) * pricing.inputPer1M + (outputTokens / 1_000_000) * pricing.outputPer1M;
|
|
54
|
+
return Math.round(cost * 1e6) / 1e6;
|
|
55
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cost/extractor
|
|
3
|
+
* Best-effort token-usage extraction from MCP tool call results.
|
|
4
|
+
*
|
|
5
|
+
* MCP has no standard field for token usage — providers and tool authors
|
|
6
|
+
* report it differently, or not at all. This module recognizes several
|
|
7
|
+
* conventions seen in the wild and never throws: any result shape it
|
|
8
|
+
* doesn't recognize resolves to `null`, matching this library's
|
|
9
|
+
* instrumentation-must-never-break-the-host-app philosophy (see
|
|
10
|
+
* instrument.js and src/fingerprint/compose.js's computeFingerprint()).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { diag } from '@opentelemetry/api';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} TokenUsage
|
|
17
|
+
* @property {number} inputTokens
|
|
18
|
+
* @property {number} outputTokens
|
|
19
|
+
* @property {number} totalTokens
|
|
20
|
+
* @property {string} [model]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {(toolResult: unknown) => TokenUsage | null} UsageExtractor
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Field-name pairs tried, in order, against a "usage-shaped" object:
|
|
29
|
+
* Anthropic (input_tokens/output_tokens), OpenAI (prompt_tokens/
|
|
30
|
+
* completion_tokens), then Bedrock (inputTokens/outputTokens).
|
|
31
|
+
*/
|
|
32
|
+
const TOKEN_FIELD_PAIRS = [
|
|
33
|
+
['input_tokens', 'output_tokens'],
|
|
34
|
+
['prompt_tokens', 'completion_tokens'],
|
|
35
|
+
['inputTokens', 'outputTokens'],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {unknown} value
|
|
40
|
+
* @returns {value is Record<string, unknown>}
|
|
41
|
+
*/
|
|
42
|
+
function isPlainObject(value) {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {unknown} value
|
|
48
|
+
* @returns {value is number}
|
|
49
|
+
*/
|
|
50
|
+
function isFiniteNumber(value) {
|
|
51
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads `{inputTokens, outputTokens}` off a usage-shaped object, trying
|
|
56
|
+
* each known provider's field names in turn. Returns null if none match or
|
|
57
|
+
* the matched fields aren't finite numbers.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} usage
|
|
60
|
+
* @returns {{ inputTokens: number, outputTokens: number } | null}
|
|
61
|
+
*/
|
|
62
|
+
function readTokenCounts(usage) {
|
|
63
|
+
if (!isPlainObject(usage)) return null;
|
|
64
|
+
|
|
65
|
+
for (const [inputKey, outputKey] of TOKEN_FIELD_PAIRS) {
|
|
66
|
+
const inputTokens = usage[inputKey];
|
|
67
|
+
const outputTokens = usage[outputKey];
|
|
68
|
+
if (isFiniteNumber(inputTokens) && isFiniteNumber(outputTokens)) {
|
|
69
|
+
return { inputTokens, outputTokens };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Picks the first string `model` found across the three conventional
|
|
78
|
+
* locations: `root.model`, `root.usage.model`, `root._meta.model`.
|
|
79
|
+
*
|
|
80
|
+
* @param {Record<string, unknown>} root
|
|
81
|
+
* @returns {string | undefined}
|
|
82
|
+
*/
|
|
83
|
+
function readModel(root) {
|
|
84
|
+
const candidates = [
|
|
85
|
+
root.model,
|
|
86
|
+
isPlainObject(root.usage) ? root.usage.model : undefined,
|
|
87
|
+
isPlainObject(root._meta) ? root._meta.model : undefined,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
|
92
|
+
return candidate;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Builds a {@link TokenUsage} from a usage-shaped object plus the broader
|
|
101
|
+
* result it was found on (used only for model lookup). Returns null when
|
|
102
|
+
* `usage` doesn't contain a recognized token-count pair.
|
|
103
|
+
*
|
|
104
|
+
* @param {unknown} usage
|
|
105
|
+
* @param {Record<string, unknown>} modelRoot
|
|
106
|
+
* @returns {TokenUsage | null}
|
|
107
|
+
*/
|
|
108
|
+
function buildUsage(usage, modelRoot) {
|
|
109
|
+
const counts = readTokenCounts(usage);
|
|
110
|
+
if (!counts) return null;
|
|
111
|
+
|
|
112
|
+
const model = readModel(modelRoot);
|
|
113
|
+
return {
|
|
114
|
+
inputTokens: counts.inputTokens,
|
|
115
|
+
outputTokens: counts.outputTokens,
|
|
116
|
+
totalTokens: counts.inputTokens + counts.outputTokens,
|
|
117
|
+
...(model !== undefined ? { model } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Safely parses the first text content block's `text` field as JSON.
|
|
123
|
+
* Returns null on any failure — missing/empty content array, non-text
|
|
124
|
+
* block, non-string text, or invalid JSON — rather than throwing.
|
|
125
|
+
*
|
|
126
|
+
* @param {Record<string, unknown>} result
|
|
127
|
+
* @returns {Record<string, unknown> | null}
|
|
128
|
+
*/
|
|
129
|
+
function parseTextContent(result) {
|
|
130
|
+
try {
|
|
131
|
+
const content = result.content;
|
|
132
|
+
if (!Array.isArray(content) || content.length === 0) return null;
|
|
133
|
+
|
|
134
|
+
const first = content[0];
|
|
135
|
+
if (!isPlainObject(first) || typeof first.text !== 'string') return null;
|
|
136
|
+
|
|
137
|
+
const parsed = JSON.parse(first.text);
|
|
138
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Default {@link UsageExtractor}. Recognizes, in priority order:
|
|
146
|
+
*
|
|
147
|
+
* 1. `result.usage` in Anthropic, OpenAI, or Bedrock field-naming
|
|
148
|
+
* conventions.
|
|
149
|
+
* 2. JSON-in-text: `result.content[0].text` parsed as JSON, then read
|
|
150
|
+
* the same way — either a nested `usage` object, or the parsed
|
|
151
|
+
* object itself treated as the usage object.
|
|
152
|
+
* 3. `result._meta.usage` — the MCP spec's `_meta` extension point.
|
|
153
|
+
*
|
|
154
|
+
* Model name is read from `result.model`, `result.usage.model`, or
|
|
155
|
+
* `result._meta.model` (first one found wins), resolved against whichever
|
|
156
|
+
* root produced the matched usage.
|
|
157
|
+
*
|
|
158
|
+
* Never throws: any unexpected shape — including one that throws on
|
|
159
|
+
* property access — is caught and logged at debug level, returning null.
|
|
160
|
+
*
|
|
161
|
+
* @type {UsageExtractor}
|
|
162
|
+
*/
|
|
163
|
+
export function defaultExtractor(toolResult) {
|
|
164
|
+
try {
|
|
165
|
+
if (!isPlainObject(toolResult)) return null;
|
|
166
|
+
|
|
167
|
+
const direct = buildUsage(toolResult.usage, toolResult);
|
|
168
|
+
if (direct) return direct;
|
|
169
|
+
|
|
170
|
+
const parsed = parseTextContent(toolResult);
|
|
171
|
+
if (parsed) {
|
|
172
|
+
const fromParsedUsage = buildUsage(parsed.usage, parsed);
|
|
173
|
+
if (fromParsedUsage) return fromParsedUsage;
|
|
174
|
+
|
|
175
|
+
const fromParsedRoot = buildUsage(parsed, parsed);
|
|
176
|
+
if (fromParsedRoot) return fromParsedRoot;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (isPlainObject(toolResult._meta)) {
|
|
180
|
+
const fromMeta = buildUsage(toolResult._meta.usage, toolResult);
|
|
181
|
+
if (fromMeta) return fromMeta;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
diag.debug('opentel-mcp: defaultExtractor failed to extract token usage, returning null', error);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cost/pricing
|
|
3
|
+
* Static per-model token pricing used to estimate MCP tool-call cost.
|
|
4
|
+
*
|
|
5
|
+
* This module is data plus two shared type shapes — no network calls, no
|
|
6
|
+
* provider SDK dependency. `DEFAULT_PRICING` is a best-effort snapshot;
|
|
7
|
+
* pricing changes frequently and varies by region/contract, so callers who
|
|
8
|
+
* need accurate cost attribution should supply their own `PricingTable`
|
|
9
|
+
* rather than rely on this one being current.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} ModelPricing
|
|
14
|
+
* @property {number} inputPer1M - USD cost per 1,000,000 input tokens.
|
|
15
|
+
* @property {number} outputPer1M - USD cost per 1,000,000 output tokens.
|
|
16
|
+
* @property {'USD'} currency
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Record<string, ModelPricing>} PricingTable
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Prices in USD per 1M tokens. Last verified: 2026-07-29. Users should
|
|
24
|
+
// override via config for accuracy — Anthropic does not guarantee this
|
|
25
|
+
// table stays current.
|
|
26
|
+
/** @type {PricingTable} */
|
|
27
|
+
export const DEFAULT_PRICING = {
|
|
28
|
+
// Anthropic
|
|
29
|
+
'claude-opus-4-7': { inputPer1M: 5.0, outputPer1M: 25.0, currency: 'USD' },
|
|
30
|
+
'claude-opus-4-6': { inputPer1M: 5.0, outputPer1M: 25.0, currency: 'USD' },
|
|
31
|
+
'claude-sonnet-5': { inputPer1M: 3.0, outputPer1M: 15.0, currency: 'USD' },
|
|
32
|
+
'claude-haiku-4-5': { inputPer1M: 1.0, outputPer1M: 5.0, currency: 'USD' },
|
|
33
|
+
'claude-sonnet-4-6': { inputPer1M: 3.0, outputPer1M: 15.0, currency: 'USD' },
|
|
34
|
+
'claude-opus-4-5': { inputPer1M: 5.0, outputPer1M: 25.0, currency: 'USD' },
|
|
35
|
+
|
|
36
|
+
// OpenAI
|
|
37
|
+
'gpt-4o': { inputPer1M: 2.5, outputPer1M: 10.0, currency: 'USD' },
|
|
38
|
+
'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.6, currency: 'USD' },
|
|
39
|
+
'gpt-5': { inputPer1M: 1.25, outputPer1M: 10.0, currency: 'USD' },
|
|
40
|
+
'gpt-5-mini': { inputPer1M: 0.25, outputPer1M: 2.0, currency: 'USD' },
|
|
41
|
+
o3: { inputPer1M: 2.0, outputPer1M: 8.0, currency: 'USD' },
|
|
42
|
+
'o3-mini': { inputPer1M: 1.1, outputPer1M: 4.4, currency: 'USD' },
|
|
43
|
+
|
|
44
|
+
// Google
|
|
45
|
+
'gemini-2-5-pro': { inputPer1M: 1.25, outputPer1M: 10.0, currency: 'USD' },
|
|
46
|
+
'gemini-2-5-flash': { inputPer1M: 0.3, outputPer1M: 2.5, currency: 'USD' },
|
|
47
|
+
|
|
48
|
+
// AWS Bedrock (Amazon Nova)
|
|
49
|
+
'amazon-nova-pro': { inputPer1M: 0.8, outputPer1M: 3.2, currency: 'USD' },
|
|
50
|
+
'amazon-nova-lite': { inputPer1M: 0.06, outputPer1M: 0.24, currency: 'USD' },
|
|
51
|
+
'amazon-nova-micro': { inputPer1M: 0.035, outputPer1M: 0.14, currency: 'USD' },
|
|
52
|
+
|
|
53
|
+
// DeepSeek
|
|
54
|
+
'deepseek-v3': { inputPer1M: 0.27, outputPer1M: 1.1, currency: 'USD' },
|
|
55
|
+
'deepseek-r1': { inputPer1M: 0.55, outputPer1M: 2.19, currency: 'USD' },
|
|
56
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions for the cost & token attribution feature.
|
|
3
|
+
*
|
|
4
|
+
* This is a hand-written declaration file, not a compiled build artifact —
|
|
5
|
+
* this project ships plain JS with no TypeScript build step (see
|
|
6
|
+
* CONTRIBUTING.md). It exists purely so TypeScript consumers (and editors)
|
|
7
|
+
* get accurate types; the `.js` files in this directory carry their own
|
|
8
|
+
* JSDoc `@typedef {import('./types.d.ts').Foo}` references back into this
|
|
9
|
+
* file, the same pattern `src/fingerprint/types.d.ts` and `src/index.d.ts`
|
|
10
|
+
* already use.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One model's per-million-token pricing. See src/cost/pricing.js. */
|
|
14
|
+
export interface ModelPricing {
|
|
15
|
+
/** USD cost per 1,000,000 input tokens. */
|
|
16
|
+
readonly inputPer1M: number;
|
|
17
|
+
/** USD cost per 1,000,000 output tokens. */
|
|
18
|
+
readonly outputPer1M: number;
|
|
19
|
+
readonly currency: 'USD';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Maps a normalized model name (see calculateCost() in src/cost/calculator.js) to its pricing. */
|
|
23
|
+
export type PricingTable = Record<string, ModelPricing>;
|
|
24
|
+
|
|
25
|
+
/** Result of a successful {@link UsageExtractor} call — see src/cost/extractor.js. */
|
|
26
|
+
export interface TokenUsage {
|
|
27
|
+
readonly inputTokens: number;
|
|
28
|
+
readonly outputTokens: number;
|
|
29
|
+
/** inputTokens + outputTokens. */
|
|
30
|
+
readonly totalTokens: number;
|
|
31
|
+
/** Present only when the extractor found a model name (result.model / result.usage.model / result._meta.model). */
|
|
32
|
+
readonly model?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pulls token usage out of an MCP tool result. Must never throw — return
|
|
37
|
+
* `null` for any result shape you don't recognize. See
|
|
38
|
+
* `defaultExtractor` in src/cost/extractor.js for the reference
|
|
39
|
+
* implementation and the conventions it already recognizes.
|
|
40
|
+
*/
|
|
41
|
+
export type UsageExtractor = (toolResult: unknown) => TokenUsage | null;
|
|
42
|
+
|
|
43
|
+
/** costTracking.budget — see src/cost/budget.js. Both limits are independent and both optional. */
|
|
44
|
+
export interface BudgetConfig {
|
|
45
|
+
/** Cumulative-cost limit (USD) per MCP session id. Calls with no session id are never tracked. */
|
|
46
|
+
readonly perSessionUsd?: number;
|
|
47
|
+
/** Cumulative-cost limit (USD) per tool name. */
|
|
48
|
+
readonly perToolUsd?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Options for {@link instrumentMcpServer}'s `costTracking` field. */
|
|
52
|
+
export interface CostTrackingOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Set to `false` to disable cost/token span attributes and the
|
|
55
|
+
* `mcp.tool.tokens.total` / `mcp.tool.cost.total` metrics entirely.
|
|
56
|
+
*
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
enabled?: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Overrides `DEFAULT_PRICING`. Supply your own table to price models it
|
|
63
|
+
* doesn't know about, or to correct stale pricing — see
|
|
64
|
+
* src/cost/pricing.js's docblock.
|
|
65
|
+
*
|
|
66
|
+
* @default DEFAULT_PRICING
|
|
67
|
+
*/
|
|
68
|
+
pricingTable?: PricingTable;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Overrides `defaultExtractor`. Supply your own to recognize a tool
|
|
72
|
+
* result shape it doesn't.
|
|
73
|
+
*
|
|
74
|
+
* @default defaultExtractor
|
|
75
|
+
*/
|
|
76
|
+
extractor?: UsageExtractor;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-session and per-tool cumulative-cost guardrails. Observability
|
|
80
|
+
* only — crossing a limit adds `mcp.tool.cost.budget_exceeded` /
|
|
81
|
+
* `mcp.tool.cost.budget_scope` span attributes; it never blocks or
|
|
82
|
+
* throws. Omit to disable budget tracking (the default).
|
|
83
|
+
*/
|
|
84
|
+
budget?: BudgetConfig;
|
|
85
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import type { CostTrackingOptions } from './cost/types.d.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Options for {@link instrumentMcpServer}.
|
|
@@ -59,6 +60,14 @@ export interface InstrumentOptions {
|
|
|
59
60
|
* @default false
|
|
60
61
|
*/
|
|
61
62
|
setupNodeSdk?: boolean;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Controls the `mcp.tool.tokens.*` / `mcp.tool.model` / `mcp.tool.cost.*` span attributes, the
|
|
66
|
+
* `mcp.tool.tokens.total` / `mcp.tool.cost.total` metrics, and the optional per-session/per-tool budget
|
|
67
|
+
* guardrail. Any fields you omit from a partial object fall back to their defaults individually —
|
|
68
|
+
* `{ enabled: false }` alone works. See {@link CostTrackingOptions} (`src/cost/types.d.ts`).
|
|
69
|
+
*/
|
|
70
|
+
costTracking?: CostTrackingOptions;
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
/**
|
|
@@ -125,3 +134,21 @@ export type {
|
|
|
125
134
|
export { computeFingerprint } from './fingerprint/compose.js';
|
|
126
135
|
export { toSpanAttributes, ATTRIBUTE_KEYS, METRIC_SAFE_ATTRIBUTES } from './fingerprint/attributes.js';
|
|
127
136
|
export { DEFAULT_CLASSIFIERS } from './fingerprint/classify/index.js';
|
|
137
|
+
|
|
138
|
+
// --- Cost & token attribution (src/cost/) ---
|
|
139
|
+
//
|
|
140
|
+
// Re-exported here so TypeScript consumers get these types/values from the
|
|
141
|
+
// package root instead of reaching into src/cost/* directly. See
|
|
142
|
+
// src/cost/types.d.ts for the full shape documentation.
|
|
143
|
+
|
|
144
|
+
export type {
|
|
145
|
+
ModelPricing,
|
|
146
|
+
PricingTable,
|
|
147
|
+
UsageExtractor,
|
|
148
|
+
TokenUsage,
|
|
149
|
+
CostTrackingOptions,
|
|
150
|
+
} from './cost/types.d.ts';
|
|
151
|
+
|
|
152
|
+
export { DEFAULT_PRICING } from './cost/pricing.js';
|
|
153
|
+
export { defaultExtractor } from './cost/extractor.js';
|
|
154
|
+
export { calculateCost } from './cost/calculator.js';
|
package/src/index.js
CHANGED
|
@@ -3,3 +3,7 @@ export { instrumentMcpServer } from './instrument.js';
|
|
|
3
3
|
export { computeFingerprint } from './fingerprint/compose.js';
|
|
4
4
|
export { toSpanAttributes, ATTRIBUTE_KEYS, METRIC_SAFE_ATTRIBUTES } from './fingerprint/attributes.js';
|
|
5
5
|
export { DEFAULT_CLASSIFIERS } from './fingerprint/classify/index.js';
|
|
6
|
+
|
|
7
|
+
export { DEFAULT_PRICING } from './cost/pricing.js';
|
|
8
|
+
export { defaultExtractor } from './cost/extractor.js';
|
|
9
|
+
export { calculateCost } from './cost/calculator.js';
|
package/src/instrument.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
-
import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
|
6
|
+
import { trace, diag, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
|
7
7
|
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
8
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
9
|
import { NodeTracerProvider, SimpleSpanProcessor, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
|
|
@@ -14,13 +14,25 @@ import { StderrSpanExporter } from './exporters/stderr.js';
|
|
|
14
14
|
import { setupMeter } from './metrics.js';
|
|
15
15
|
import { computeFingerprint } from './fingerprint/compose.js';
|
|
16
16
|
import { toSpanAttributes } from './fingerprint/attributes.js';
|
|
17
|
+
import { calculateCost } from './cost/calculator.js';
|
|
18
|
+
import { createBudgetTracker } from './cost/budget.js';
|
|
17
19
|
import {
|
|
18
20
|
ATTR_MCP_METHOD_NAME,
|
|
19
21
|
ATTR_GEN_AI_TOOL_NAME,
|
|
20
22
|
ATTR_GEN_AI_OPERATION_NAME,
|
|
23
|
+
ATTR_GEN_AI_RESPONSE_MODEL,
|
|
21
24
|
ATTR_JSONRPC_REQUEST_ID,
|
|
22
25
|
ATTR_MCP_TOOL_ARGUMENT_COUNT,
|
|
23
26
|
ATTR_ERROR_TYPE,
|
|
27
|
+
ATTR_MCP_TOOL_TOKENS_INPUT,
|
|
28
|
+
ATTR_MCP_TOOL_TOKENS_OUTPUT,
|
|
29
|
+
ATTR_MCP_TOOL_TOKENS_TOTAL,
|
|
30
|
+
ATTR_MCP_TOOL_MODEL,
|
|
31
|
+
ATTR_MCP_TOOL_COST_USD,
|
|
32
|
+
ATTR_MCP_TOOL_COST_CURRENCY,
|
|
33
|
+
ATTR_MCP_TOOL_COST_BUDGET_EXCEEDED,
|
|
34
|
+
ATTR_MCP_TOOL_COST_BUDGET_SCOPE,
|
|
35
|
+
MCP_TOOL_COST_CURRENCY_USD,
|
|
24
36
|
ERROR_TYPE_TOOL_ERROR,
|
|
25
37
|
GEN_AI_OPERATION_NAME_EXECUTE_TOOL,
|
|
26
38
|
MCP_METHOD_NAME_TOOLS_CALL,
|
|
@@ -137,6 +149,10 @@ export function instrumentMcpServer(input, options) {
|
|
|
137
149
|
|
|
138
150
|
const tracer = setupTracer(server, resolved);
|
|
139
151
|
const metricsRecorder = resolved.enableMetrics ? setupMeter(PACKAGE_VERSION) : null;
|
|
152
|
+
// One tracker per instrumented server, not per call — session/tool cost
|
|
153
|
+
// must accumulate across the server's whole lifetime (see
|
|
154
|
+
// src/cost/budget.js). A no-op tracker when costTracking.budget is unset.
|
|
155
|
+
const budgetTracker = createBudgetTracker(resolved.costTracking.budget);
|
|
140
156
|
if (outer && server.shutdown) {
|
|
141
157
|
outer.shutdown = server.shutdown;
|
|
142
158
|
}
|
|
@@ -144,7 +160,14 @@ export function instrumentMcpServer(input, options) {
|
|
|
144
160
|
const originalSetRequestHandler = server.setRequestHandler.bind(server);
|
|
145
161
|
server.setRequestHandler = (schema, handler) => {
|
|
146
162
|
if (schema === CallToolRequestSchema) {
|
|
147
|
-
handler = wrapToolCallHandler(
|
|
163
|
+
handler = wrapToolCallHandler(
|
|
164
|
+
handler,
|
|
165
|
+
tracer,
|
|
166
|
+
metricsRecorder,
|
|
167
|
+
resolved.fingerprinting,
|
|
168
|
+
resolved.costTracking,
|
|
169
|
+
budgetTracker,
|
|
170
|
+
);
|
|
148
171
|
}
|
|
149
172
|
return originalSetRequestHandler(schema, handler);
|
|
150
173
|
};
|
|
@@ -229,6 +252,76 @@ function isToolResultError(result) {
|
|
|
229
252
|
return result?.isError === true;
|
|
230
253
|
}
|
|
231
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Best-effort cost/token attribution for one tool call's result, added on
|
|
257
|
+
* top of the span and metrics that always fire (see wrapToolCallHandler
|
|
258
|
+
* below). Runs `costTracking.extractor` (defaultExtractor by default — see
|
|
259
|
+
* src/cost/extractor.js) against `result`; if it finds recognizable usage,
|
|
260
|
+
* sets the three mcp.tool.tokens.* span attributes (+ mcp.tool.model and,
|
|
261
|
+
* for ecosystem-dashboard compatibility, gen_ai.response.model — see that
|
|
262
|
+
* constant's docblock in attributes.js for why both are set — when a model
|
|
263
|
+
* was detected) and records mcp.tool.tokens.total. If a model was
|
|
264
|
+
* detected, additionally runs calculateCost() (src/cost/calculator.js)
|
|
265
|
+
* against `costTracking.pricingTable` and, when it resolves (the model is
|
|
266
|
+
* in the table), sets mcp.tool.cost.usd / mcp.tool.cost.currency, records
|
|
267
|
+
* mcp.tool.cost.total, and runs `budgetTracker.recordAndCheck()`
|
|
268
|
+
* (src/cost/budget.js) — if that reports the call pushed a configured
|
|
269
|
+
* budget over its limit, sets mcp.tool.cost.budget_exceeded /
|
|
270
|
+
* mcp.tool.cost.budget_scope. An unrecognized model silently skips cost
|
|
271
|
+
* *and* budget attribution — the token attributes still land.
|
|
272
|
+
*
|
|
273
|
+
* No-op when `costTracking.enabled` is false. Called from both the
|
|
274
|
+
* isToolResultError and success branches below (there's a result to read
|
|
275
|
+
* usage from in either case); never from the thrown-exception catch block,
|
|
276
|
+
* since a thrown/rejected call never produced a result. The whole body is
|
|
277
|
+
* one try/catch — extractor.js, calculator.js, and budget.js already
|
|
278
|
+
* document themselves as never-throw, but span.setAttribute/
|
|
279
|
+
* metricsRecorder calls are outside their control, and this must never be
|
|
280
|
+
* why a tool call span fails to complete.
|
|
281
|
+
*
|
|
282
|
+
* @param {import('@opentelemetry/api').Span} span
|
|
283
|
+
* @param {ReturnType<import('./metrics.js').setupMeter> | null} metricsRecorder
|
|
284
|
+
* @param {string | undefined} toolName
|
|
285
|
+
* @param {string | undefined} sessionId
|
|
286
|
+
* @param {*} result
|
|
287
|
+
* @param {import('./config.js').CostTrackingOptions} costTracking
|
|
288
|
+
* @param {ReturnType<import('./cost/budget.js').createBudgetTracker>} budgetTracker
|
|
289
|
+
*/
|
|
290
|
+
function applyCostAttribution(span, metricsRecorder, toolName, sessionId, result, costTracking, budgetTracker) {
|
|
291
|
+
if (!costTracking.enabled) return;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const usage = costTracking.extractor(result);
|
|
295
|
+
if (!usage) return;
|
|
296
|
+
|
|
297
|
+
span.setAttribute(ATTR_MCP_TOOL_TOKENS_INPUT, usage.inputTokens);
|
|
298
|
+
span.setAttribute(ATTR_MCP_TOOL_TOKENS_OUTPUT, usage.outputTokens);
|
|
299
|
+
span.setAttribute(ATTR_MCP_TOOL_TOKENS_TOTAL, usage.totalTokens);
|
|
300
|
+
if (usage.model) {
|
|
301
|
+
span.setAttribute(ATTR_MCP_TOOL_MODEL, usage.model);
|
|
302
|
+
span.setAttribute(ATTR_GEN_AI_RESPONSE_MODEL, usage.model);
|
|
303
|
+
}
|
|
304
|
+
metricsRecorder?.recordTokens(toolName, usage.model, usage.totalTokens);
|
|
305
|
+
|
|
306
|
+
if (usage.model) {
|
|
307
|
+
const costUsd = calculateCost(usage.inputTokens, usage.outputTokens, usage.model, costTracking.pricingTable);
|
|
308
|
+
if (costUsd !== null) {
|
|
309
|
+
span.setAttribute(ATTR_MCP_TOOL_COST_USD, costUsd);
|
|
310
|
+
span.setAttribute(ATTR_MCP_TOOL_COST_CURRENCY, MCP_TOOL_COST_CURRENCY_USD);
|
|
311
|
+
metricsRecorder?.recordCost(toolName, usage.model, costUsd);
|
|
312
|
+
|
|
313
|
+
const budgetResult = budgetTracker.recordAndCheck(sessionId, toolName, costUsd);
|
|
314
|
+
if (budgetResult.exceeded) {
|
|
315
|
+
span.setAttribute(ATTR_MCP_TOOL_COST_BUDGET_EXCEEDED, true);
|
|
316
|
+
span.setAttribute(ATTR_MCP_TOOL_COST_BUDGET_SCOPE, budgetResult.scope);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
diag.debug('opentel-mcp: cost attribution failed, skipping mcp.tool.tokens.*/mcp.tool.cost.* attributes', err);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
232
325
|
/**
|
|
233
326
|
* Wraps a tools/call handler in a span covering its execution, plus the
|
|
234
327
|
* mcp.tool.* metrics (see src/metrics.js). This sits as the innermost layer
|
|
@@ -253,18 +346,31 @@ function isToolResultError(result) {
|
|
|
253
346
|
* threading the resulting failure category into the mcp.tool.errors /
|
|
254
347
|
* mcp.tool.silent_failures / mcp.tool.duration metrics.
|
|
255
348
|
*
|
|
349
|
+
* Cost/token attribution (see applyCostAttribution above and config.js's
|
|
350
|
+
* `costTracking` option) runs in both the isError and success branches,
|
|
351
|
+
* independently of fingerprinting — a tool call can carry token usage
|
|
352
|
+
* whether or not it ultimately succeeded.
|
|
353
|
+
*
|
|
256
354
|
* @param {Function} handler
|
|
257
355
|
* @param {import('@opentelemetry/api').Tracer} tracer
|
|
258
356
|
* @param {ReturnType<import('./metrics.js').setupMeter> | null} metricsRecorder
|
|
259
357
|
* @param {boolean} fingerprintingEnabled
|
|
358
|
+
* @param {import('./config.js').CostTrackingOptions} costTracking
|
|
359
|
+
* @param {ReturnType<import('./cost/budget.js').createBudgetTracker>} budgetTracker
|
|
260
360
|
*/
|
|
261
|
-
function wrapToolCallHandler(handler, tracer, metricsRecorder, fingerprintingEnabled) {
|
|
361
|
+
function wrapToolCallHandler(handler, tracer, metricsRecorder, fingerprintingEnabled, costTracking, budgetTracker) {
|
|
262
362
|
return (request, extra) => {
|
|
263
363
|
const toolName = request?.params?.name;
|
|
264
364
|
const spanName = toolName ? `${TOOLS_CALL_METHOD} ${toolName}` : TOOLS_CALL_METHOD;
|
|
265
365
|
|
|
266
366
|
return tracer.startActiveSpan(spanName, { kind: SpanKind.SERVER }, async (span) => {
|
|
267
367
|
const argumentCount = Object.keys(request?.params?.arguments ?? {}).length;
|
|
368
|
+
// Transport-provided session id (undefined for stdio, which has no
|
|
369
|
+
// notion of a session) — see the sessionId param on the SDK's
|
|
370
|
+
// RequestHandlerExtra type. Only consumed by applyCostAttribution's
|
|
371
|
+
// per-session budget tracking (src/cost/budget.js); everything else
|
|
372
|
+
// in this function already worked without it.
|
|
373
|
+
const sessionId = extra?.sessionId;
|
|
268
374
|
|
|
269
375
|
span.setAttribute(ATTR_MCP_METHOD_NAME, TOOLS_CALL_METHOD);
|
|
270
376
|
span.setAttribute(ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_EXECUTE_TOOL);
|
|
@@ -290,6 +396,7 @@ function wrapToolCallHandler(handler, tracer, metricsRecorder, fingerprintingEna
|
|
|
290
396
|
span.setAttributes(toSpanAttributes(failure));
|
|
291
397
|
failureCategory = failure.category;
|
|
292
398
|
}
|
|
399
|
+
applyCostAttribution(span, metricsRecorder, toolName, sessionId, result, costTracking, budgetTracker);
|
|
293
400
|
metricsRecorder?.recordSilentFailure(toolName, failureCategory);
|
|
294
401
|
metricsRecorder?.recordDuration(
|
|
295
402
|
toolName,
|
|
@@ -299,6 +406,7 @@ function wrapToolCallHandler(handler, tracer, metricsRecorder, fingerprintingEna
|
|
|
299
406
|
);
|
|
300
407
|
} else {
|
|
301
408
|
span.setStatus({ code: SpanStatusCode.OK });
|
|
409
|
+
applyCostAttribution(span, metricsRecorder, toolName, sessionId, result, costTracking, budgetTracker);
|
|
302
410
|
metricsRecorder?.recordDuration(toolName, performance.now() - startTime, MCP_TOOL_OUTCOME_SUCCESS);
|
|
303
411
|
}
|
|
304
412
|
return result;
|
package/src/metrics.js
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
ATTR_GEN_AI_TOOL_NAME,
|
|
36
36
|
ATTR_ERROR_TYPE,
|
|
37
37
|
ATTR_MCP_TOOL_OUTCOME,
|
|
38
|
+
ATTR_MCP_TOOL_MODEL,
|
|
38
39
|
MCP_METHOD_NAME_TOOLS_CALL,
|
|
39
40
|
} from './attributes.js';
|
|
40
41
|
import { ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
|
|
@@ -51,12 +52,22 @@ import { ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
|
|
|
51
52
|
* enough to be metric-safe (see METRIC_SAFE_ATTRIBUTES); callers omit it
|
|
52
53
|
* (or pass '') when fingerprinting is disabled or produced no category.
|
|
53
54
|
*
|
|
55
|
+
* `model` (recordTokens, recordCost) follows the same optional-attribute
|
|
56
|
+
* pattern: added to the bag only when the cost extractor actually found a
|
|
57
|
+
* model name (see instrument.js's applyCostAttribution()), so a run with
|
|
58
|
+
* no model detection doesn't create a spurious `mcp.tool.model: undefined`
|
|
59
|
+
* time series. Model names are bounded in practice by how many distinct
|
|
60
|
+
* models a deployment actually calls, the same cardinality argument this
|
|
61
|
+
* package already relies on for `gen_ai.tool.name` on every other counter.
|
|
62
|
+
*
|
|
54
63
|
* @param {string} packageVersion
|
|
55
64
|
* @returns {{
|
|
56
65
|
* recordCall: (toolName: string | undefined) => void,
|
|
57
66
|
* recordError: (toolName: string | undefined, errorType: string, failureCategory?: string) => void,
|
|
58
67
|
* recordSilentFailure: (toolName: string | undefined, failureCategory?: string) => void,
|
|
59
68
|
* recordDuration: (toolName: string | undefined, durationMs: number, outcome: string, failureCategory?: string) => void,
|
|
69
|
+
* recordTokens: (toolName: string | undefined, model: string | undefined, totalTokens: number) => void,
|
|
70
|
+
* recordCost: (toolName: string | undefined, model: string | undefined, costUsd: number) => void,
|
|
60
71
|
* }}
|
|
61
72
|
*/
|
|
62
73
|
export function setupMeter(packageVersion) {
|
|
@@ -76,6 +87,14 @@ export function setupMeter(packageVersion) {
|
|
|
76
87
|
description: 'Duration of MCP tool call execution.',
|
|
77
88
|
unit: 'ms',
|
|
78
89
|
});
|
|
90
|
+
const tokensTotal = meter.createCounter('mcp.tool.tokens.total', {
|
|
91
|
+
description: 'Total input + output tokens attributed to MCP tool calls (see src/cost/extractor.js).',
|
|
92
|
+
unit: 'tokens',
|
|
93
|
+
});
|
|
94
|
+
const costTotal = meter.createCounter('mcp.tool.cost.total', {
|
|
95
|
+
description: 'Total estimated cost of MCP tool calls (see src/cost/calculator.js).',
|
|
96
|
+
unit: 'USD',
|
|
97
|
+
});
|
|
79
98
|
|
|
80
99
|
return {
|
|
81
100
|
recordCall(toolName) {
|
|
@@ -104,5 +123,17 @@ export function setupMeter(packageVersion) {
|
|
|
104
123
|
...(failureCategory ? { [ATTRIBUTE_KEYS.CATEGORY]: failureCategory } : {}),
|
|
105
124
|
});
|
|
106
125
|
},
|
|
126
|
+
recordTokens(toolName, model, totalTokens) {
|
|
127
|
+
tokensTotal.add(totalTokens, {
|
|
128
|
+
[ATTR_GEN_AI_TOOL_NAME]: toolName,
|
|
129
|
+
...(model ? { [ATTR_MCP_TOOL_MODEL]: model } : {}),
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
recordCost(toolName, model, costUsd) {
|
|
133
|
+
costTotal.add(costUsd, {
|
|
134
|
+
[ATTR_GEN_AI_TOOL_NAME]: toolName,
|
|
135
|
+
...(model ? { [ATTR_MCP_TOOL_MODEL]: model } : {}),
|
|
136
|
+
});
|
|
137
|
+
},
|
|
107
138
|
};
|
|
108
139
|
}
|