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 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
  [![CI](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
7
7
  [![npm version](https://img.shields.io/npm/v/opentel-mcp.svg)](https://www.npmjs.com/package/opentel-mcp)
@@ -9,10 +9,14 @@
9
9
  [![license](https://img.shields.io/npm/l/opentel-mcp.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/LICENSE)
10
10
 
11
11
  opentel-mcp watches every tool call your MCP (Model Context Protocol)
12
- server handles: which tool ran, how long it took, and whether it worked.
13
- It reports that as OpenTelemetry (OTel) tracesthe standard most
14
- dashboards already read. One function call; no changes to your tools'
15
- code.
12
+ server handles: which tool ran, how long it took, whether it worked, and —
13
+ when the tool result carries usage datahow many tokens it burned and
14
+ what that cost. It reports all of that as OpenTelemetry (OTel) traces —
15
+ the standard most dashboards already read. One function call; no changes
16
+ to your tools' code.
17
+
18
+ opentel-mcp is the only Node.js MCP instrumentation library that ties
19
+ tool calls to LLM cost.
16
20
 
17
21
  ## The problem
18
22
 
@@ -288,6 +292,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 two
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) and `mcp.failure.*` (failure fingerprinting). Both are
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
- - 127 tests (`npm test`) — see `test/`
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: Failure clustering + regression detection
374
- - Future: recovery hints, root-cause chaining across parent spans,
375
- alignment with the OTel GenAI SIG's MCP semantic conventions when
376
- published
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- /** Conditionally Required (when the operation targets a specific tool). */
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(handler, tracer, metricsRecorder, resolved.fingerprinting);
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
  }