opentel-mcp 0.14.0 → 0.15.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,103 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ "Make it visible" — no instrumentation changes, no `tools/call` hot-path
6
+ changes. Phase 1 (ADR 022, `docs/adr/022-publish-ui.md`): publishes
7
+ `opentel-mcp-ui`, previously a working but `"private": true`, unpublished
8
+ scaffold with zero mentions in this README. See
9
+ [`opentel-mcp-ui`'s own CHANGELOG](https://www.npmjs.com/package/opentel-mcp-ui)
10
+ for what changed in that package. Phase 2 (ADR 023,
11
+ `docs/adr/023-dev-mode-metrics.md`): dev-mode metrics under
12
+ `setupNodeSdk: true` — see "Added" below.
13
+
14
+ ### Added
15
+
16
+ - README: a "See it locally" section near the top, pointing at
17
+ `npx opentel-mcp-ui --demo` — this library had no documented way for a
18
+ new reader to see its own output before installing anything.
19
+ - `setupNodeSdk: true` now also stands up an owned `MeterProvider` (ADR
20
+ 023): a `PeriodicExportingMetricReader` exporting all 12 `mcp.tool.*`/
21
+ `mcp.tool.loop.*`/`mcp.tool.schema_drift.*` instruments to stderr every
22
+ 5 seconds, via a new `StderrMetricExporter` (compact
23
+ `name{attrs} = value` lines, never a stdout-writing JSON dump — same
24
+ ADR 003 reasoning `StderrSpanExporter` already follows). Gated
25
+ additionally by `enableMetrics` (default `true`, unchanged option).
26
+ Closes the gap where the documented 30-second quickstart emitted
27
+ stderr spans but none of its 12 metrics. `setupNodeSdk: false` (the
28
+ default, and the production posture) is completely unaffected — every
29
+ new code path is gated behind the same existing `setupNodeSdk === true`
30
+ check `StderrSpanExporter` already uses.
31
+ - If a `MeterProvider` is already registered globally when this runs
32
+ (your own, or anything else's), this is a complete no-op: nothing is
33
+ overridden, no exception is thrown, and a single `diag.warn` (once
34
+ per process, not once per `instrumentMcpServer()` call) names the
35
+ situation. Detected via `@opentelemetry/api`'s own documented
36
+ `boolean` return from `setGlobalMeterProvider()` — not by inspecting
37
+ internals. The abandoned candidate provider built before that check
38
+ is shut down immediately (fire-and-forget), closing a leak where its
39
+ own periodic reader would otherwise tick forever, unused, in the
40
+ background.
41
+ - `server.shutdown()` (already attached under `setupNodeSdk: true`) now
42
+ also flushes and shuts down this `MeterProvider`, alongside the
43
+ existing `TracerProvider` shutdown it already performed.
44
+ - `StderrMetricExporter` only reprints a metric when its value has
45
+ actually changed since the last export. Every instrument here is
46
+ CUMULATIVE, so without this a single early tool call would otherwise
47
+ reprint two unchanged lines every 5 seconds for the rest of the
48
+ process's life — confirmed empirically before this fix shipped, not
49
+ assumed.
50
+
51
+ ### Changed
52
+
53
+ - **`@opentelemetry/sdk-metrics` is now a runtime (`dependencies`, not
54
+ `devDependencies`) dependency of `opentel-mcp`, installed for every
55
+ user — including under `setupNodeSdk: false`, which never executes any
56
+ code from it.** Needed at import time for the feature above; moving it
57
+ to `dependencies` means `npm install opentel-mcp` always pulls it in,
58
+ regardless of whether a given deployment ever sets `setupNodeSdk: true`.
59
+ Adds no new transitive dependency: its own two dependencies
60
+ (`@opentelemetry/core`, `@opentelemetry/resources`) were already in
61
+ this package's tree via `@opentelemetry/sdk-trace-node`/the existing
62
+ `@opentelemetry/resources` dependency. See ADR 023's "Dependency
63
+ strategy" section for the full reasoning, including why an optional-peer
64
+ + dynamic-import approach was considered and rejected.
65
+
66
+ ### Fixed
67
+
68
+ - The example servers (`examples/hello-server`, `examples/hello-mcpserver`)
69
+ never showed a metrics line in their own READMEs' "What you'll see": a
70
+ single piped `tools/call` request exits the process before the 5-second
71
+ export interval. Both now flush once via `process.on('beforeExit', ...)`
72
+ right before the process would otherwise exit — confirmed
73
+ `StdioServerTransport` does not call its own `close()`/`onclose` on
74
+ stdin EOF, so `beforeExit` is the one hook that still allows the async
75
+ flush to complete first.
76
+ - Documentation only, no code/behavior changes:
77
+ - README claimed "Four `mcp.tool.*` metrics" in a way that read as the
78
+ library's total metric count. Corrected: four is this section's own
79
+ base call/duration metrics; twelve exist in total across cost/token
80
+ attribution (2), Agent Thrash Detection (5), and schema drift (1),
81
+ each already documented in their own sections.
82
+ - README's test count was stale at every point it was checked this
83
+ release; now 1017 tests, 1013 passing + 4 intentionally skipped.
84
+ - README's SigNoz metrics-wiring example described
85
+ `@opentelemetry/sdk-metrics` as a host-app dependency opentel-mcp
86
+ "doesn't bundle" — true before this release, no longer true now that
87
+ it's a real dependency (see "Changed" above).
88
+
89
+ ### CI
90
+
91
+ - `dependency-audit` (`.github/workflows/ci.yml`) no longer audits this
92
+ monorepo's own shared, hoisted `node_modules` — `npm audit`, even with
93
+ `--omit=dev` and run from inside `packages/core/`, scans the entire
94
+ installed tree regardless of cwd or `--workspace` (confirmed
95
+ empirically), so it was reporting findings from `examples/*`'s real
96
+ `@modelcontextprotocol/sdk` dependency (express/hono/ajv transitives)
97
+ as if they were this library's own. Now packs and installs each of
98
+ `packages/core`/`packages/ui` into its own clean, throwaway project —
99
+ the exact tree a real `npm install` produces — and audits that instead.
100
+
3
101
  ## 0.14.0
4
102
 
5
103
  Adds `errorRecording.redactor`: a host-supplied hook for content
package/README.md CHANGED
@@ -18,6 +18,26 @@ to your tools' code.
18
18
  opentel-mcp is the only Node.js MCP instrumentation library that ties
19
19
  tool calls to LLM cost.
20
20
 
21
+ ## See it locally
22
+
23
+ ```bash
24
+ npx opentel-mcp-ui --demo
25
+ ```
26
+
27
+ Opens a dashboard at `http://127.0.0.1:4319` — no MCP server, no
28
+ Prometheus, no Collector to stand up first. It's seeded with realistic
29
+ fixture data showing exactly what this README is about: tool calls that
30
+ returned `isError: true` inside a successful response, rendered as the
31
+ clean spans a standard OTel setup would show, side by side with what
32
+ opentel-mcp actually caught.
33
+
34
+ <!-- TODO: screenshot/GIF of the dashboard here -->
35
+
36
+ Drop `--demo` and point your own instrumented server's `exporterUrl` at
37
+ it to see real traffic instead — see
38
+ [`opentel-mcp-ui`'s README](https://www.npmjs.com/package/opentel-mcp-ui)
39
+ for the two-minute walkthrough.
40
+
21
41
  ## The problem
22
42
 
23
43
  Your AI agent calls 15 MCP tools across 3 servers this turn. One tool
@@ -38,6 +58,15 @@ npm install opentel-mcp @opentelemetry/api
38
58
 
39
59
  opentel-mcp is an ES module — add `"type": "module"` to package.json.
40
60
 
61
+ If you're starting from a blank project to try the quickstart below
62
+ (rather than adding opentel-mcp to an MCP server you already have), you
63
+ also need the MCP SDK and `zod` — the quickstart's own server and tool
64
+ definitions, not opentel-mcp's dependencies:
65
+
66
+ ```bash
67
+ npm install @modelcontextprotocol/sdk zod
68
+ ```
69
+
41
70
  ## 30-second quickstart
42
71
 
43
72
  ```js
@@ -50,7 +79,7 @@ const server = new McpServer({ name: 'my-server', version: '1.0.0' });
50
79
 
51
80
  // Wraps every tool registered below. Must run BEFORE server.tool() —
52
81
  // see "Ordering constraint" below for why.
53
- instrumentMcpServer(server, {
82
+ const instrumented = instrumentMcpServer(server, {
54
83
  serviceName: 'my-mcp-server', // shows up on your traces
55
84
  setupNodeSdk: true, // dev mode: prints traces to your terminal
56
85
  });
@@ -62,6 +91,17 @@ server.tool('echo', { text: z.string() }, async ({ text }) => ({
62
91
 
63
92
  const transport = new StdioServerTransport();
64
93
  await server.connect(transport);
94
+
95
+ // Only needed to see metrics (below) immediately when piping ONE request
96
+ // in and exiting right after, like the "See it working" example does —
97
+ // a real, long-running server sees the same lines on their own, every 5
98
+ // seconds, with no extra code.
99
+ let flushed = false;
100
+ process.on('beforeExit', () => {
101
+ if (flushed) return;
102
+ flushed = true;
103
+ instrumented.shutdown();
104
+ });
65
105
  ```
66
106
 
67
107
  That's it. Every tool call now emits a trace. Wire an exporter to see them
@@ -87,6 +127,21 @@ attributes: {
87
127
  No dashboard needed — `setupNodeSdk: true`'s dev exporter printed this
88
128
  directly. Point it at a real backend later; see "Two modes" below.
89
129
 
130
+ **Metrics print too (v0.15.0+)** — within 5 seconds (not the default
131
+ 60-second export interval), a compact one-line-per-metric summary follows
132
+ on stderr, reusing the exact `mcp.tool.*` instruments documented in
133
+ "Metrics" below:
134
+
135
+ ```
136
+ [opentel-mcp metrics] mcp.tool.calls{gen_ai.tool.name=echo,mcp.method.name=tools/call} = 1
137
+ [opentel-mcp metrics] mcp.tool.duration{gen_ai.tool.name=echo,mcp.tool.outcome=success} count=1 avg=0.4ms
138
+ ```
139
+
140
+ No extra configuration, no `@opentelemetry/sdk-metrics` setup of your
141
+ own — `setupNodeSdk: true` now does for metrics what it's always done for
142
+ traces. See "Dev-mode metrics" below for exactly what this does and
143
+ doesn't change.
144
+
90
145
  ---
91
146
 
92
147
  The rest of this README goes deeper: both server APIs, every attribute
@@ -240,7 +295,17 @@ Runnable end-to-end coverage lives in `test/instrument.v2.test.js` and
240
295
 
241
296
  An MCP tool can fail two ways: it can throw, or it can return
242
297
  `isError: true` on an otherwise-successful response (the case from "The
243
- problem" above). opentel-mcp treats both the same way — span marked
298
+ problem" above) — exactly like the quickstart's `echo` tool, but with
299
+ `isError: true` added and no `throw` involved:
300
+
301
+ ```js
302
+ server.tool('fetch_weather', { city: z.string() }, async ({ city }) => ({
303
+ isError: true,
304
+ content: [{ type: 'text', text: `couldn't reach the weather service for ${city}` }],
305
+ }));
306
+ ```
307
+
308
+ opentel-mcp treats both failure shapes the same way — span marked
244
309
  `ERROR`, nothing thrown, the result returned to the caller unchanged:
245
310
 
246
311
  ```
@@ -273,11 +338,17 @@ success/failure through span status, not an attribute. Source of truth:
273
338
 
274
339
  ### Metrics
275
340
 
276
- Four `mcp.tool.*` metrics via `@opentelemetry/api`'s Metrics API — same
277
- API-only pattern as tracing (see "Two modes" below): nothing is recorded
278
- until a `MeterProvider` is registered. Set `enableMetrics: false` to opt
279
- out even when one is; tracing is unaffected either way. Source of truth:
280
- `src/metrics.js`.
341
+ The four base `mcp.tool.*` call/duration metrics below, via
342
+ `@opentelemetry/api`'s Metrics API — same API-only pattern as tracing
343
+ (see "Two modes" below): nothing is recorded until a `MeterProvider` is
344
+ registered — **except under `setupNodeSdk: true`, which registers one for
345
+ you (v0.15.0+); see "Dev-mode metrics" below.** Set `enableMetrics: false`
346
+ to opt out even when one is (dev-mode included); tracing is unaffected
347
+ either way. Source of truth: `src/metrics.js`. Twelve metrics exist
348
+ across the library in total — this section's four, plus two cost/token
349
+ metrics ("Cost & Token Attribution" below), five Agent Thrash Detection
350
+ metrics ("Agent Thrash Detection" below), and one schema-drift metric
351
+ ("Tool schema drift detection" below).
281
352
 
282
353
  | Metric | Type | Unit | Attributes | Emitted when |
283
354
  |---|---|---|---|---|
@@ -332,12 +403,61 @@ instrumentMcpServer(server, {});
332
403
 
333
404
  `http://localhost:4318` is SigNoz's default local OTLP/HTTP (OpenTelemetry
334
405
  Protocol — the wire format traces/metrics travel over) endpoint; point it
335
- at your own collector in production. `@opentelemetry/sdk-metrics` and
336
- `@opentelemetry/exporter-metrics-otlp-http` are host-app dependencies —
337
- opentel-mcp doesn't bundle them (see `package.json`'s `peerDependencies`).
338
- `@opentelemetry/sdk-trace-node` and `@opentelemetry/exporter-trace-otlp-http`
339
- are already runtime dependencies of opentel-mcp itself (its `setupNodeSdk:
340
- true` dev path uses them), so no extra install is needed for those two.
406
+ at your own collector in production. `@opentelemetry/exporter-metrics-otlp-http`
407
+ is a host-app dependency — opentel-mcp doesn't bundle it. `@opentelemetry/sdk-metrics`,
408
+ `@opentelemetry/sdk-trace-node`, and `@opentelemetry/exporter-trace-otlp-http`
409
+ are already runtime dependencies of opentel-mcp itself (`@opentelemetry/sdk-metrics`
410
+ as of v0.15.0, for the dev-mode metrics path below; the other two for
411
+ `setupNodeSdk: true`'s dev tracing path, same as before), so no extra
412
+ install is needed for those three — only the OTLP metrics exporter above
413
+ needs installing yourself.
414
+
415
+ #### Dev-mode metrics (v0.15.0+)
416
+
417
+ `setupNodeSdk: true` now also stands up an owned `MeterProvider` — the
418
+ same dev-friendly, zero-config posture tracing has always had under this
419
+ flag, extended to metrics (ADR 023, `docs/adr/023-dev-mode-metrics.md`).
420
+ This is a dev-mode-only exception to the API-only framing above, the same
421
+ way `StderrSpanExporter` (ADR 003) is a dev-mode-only exception for
422
+ tracing — `setupNodeSdk: false` (the default, and the production posture)
423
+ is completely unaffected; metrics stay exactly as API-only as they are
424
+ today.
425
+
426
+ What it does, under `setupNodeSdk: true` and `enableMetrics` (default
427
+ `true`, same flag that already opts out of metrics generally):
428
+
429
+ - Builds a `MeterProvider` with a `PeriodicExportingMetricReader` exporting
430
+ to stderr every 5 seconds (not the SDK's own 60-second default — a
431
+ quickstart user making a few tool calls should see output in a few
432
+ seconds, not up to a minute later), via a small custom exporter
433
+ (`src/exporters/stderr-metrics.js`) — same "stderr, never stdout"
434
+ reasoning as `StderrSpanExporter` (ADR 003): a `StdioServerTransport`
435
+ server's JSON-RPC stream lives on stdout, so diagnostic output of any
436
+ kind must go to stderr instead.
437
+ - Prints one line per instrument × attribute-set, not a JSON dump:
438
+ counters as `name{attrs} = <value>`, histograms as
439
+ `name{attrs} count=<n> avg=<sum/count><unit>` — see the quickstart's
440
+ "See it working" section above for a real example.
441
+ - Shares the exact same `Resource` (service.name, and the pricing-staleness
442
+ attribute when applicable) the dev `TracerProvider` already builds, so
443
+ the two signals can never carry different `service.name` values in dev
444
+ mode.
445
+ - **If a `MeterProvider` is already registered globally** — your own, or
446
+ anything else's — this is a complete no-op: nothing is overridden, no
447
+ exception is thrown, and a single `diag.warn` (once per process) says
448
+ so. `setupMeter()` immediately afterward continues to use whatever was
449
+ already registered, exactly as it does today.
450
+ - Extends `server.shutdown()` (already attached under `setupNodeSdk: true`)
451
+ to also flush and shut down the `MeterProvider` it registered, alongside
452
+ the existing `TracerProvider` shutdown — call it during your process's
453
+ shutdown sequence, same as before, to avoid losing buffered metric data.
454
+
455
+ `@opentelemetry/sdk-metrics` is a real (non-dev) dependency of opentel-mcp
456
+ as of this feature — not an optional peer, not a dynamic import with a
457
+ fallback path. See ADR 023's "Dependency strategy" section for the full
458
+ reasoning: its own transitive dependencies were already in this package's
459
+ tree via `@opentelemetry/resources`/`@opentelemetry/sdk-trace-node`, so
460
+ this adds no new package beyond itself.
341
461
 
342
462
  #### Using the Prometheus exporter instead of OTLP — `service.name` needs an extra option
343
463
 
@@ -2621,7 +2741,7 @@ pragmatic choice rather than a spec-pure one.
2621
2741
  revision 2026-07-28, v0.10.0+; see "MCP v2 support" above for what's
2622
2742
  covered and `docs/known-gaps.md` entries 6 and 8 for what isn't yet)
2623
2743
  - @opentelemetry/api ^1.9.0
2624
- - 948 tests, 944 passing + 4 intentionally skipped (`npm test`) — see `test/`
2744
+ - 1017 tests, 1013 passing + 4 intentionally skipped (`npm test`) — see `test/`
2625
2745
  - `npm run typecheck` (`tsc --noEmit`) type-checks the public `.d.ts`
2626
2746
  surface (`src/index.d.ts` and friends) — see CONTRIBUTING.md
2627
2747
 
@@ -2701,6 +2821,17 @@ pragmatic choice rather than a spec-pure one.
2701
2821
  narrower thing still open, tracked in `docs/known-gaps.md` entry 6:
2702
2822
  `thrashSessionState`'s session-awareness memory isn't shared across v2's
2703
2823
  per-request calls yet, only the fallback id itself.
2824
+ - v0.15.0: "Make it visible" ✓ — a recent audit found that this library's
2825
+ real depth (12 metrics, two-SDK support, a working local dashboard) was
2826
+ mostly invisible to a first-time user. Two fixes, no instrumentation
2827
+ changes: publishing `opentel-mcp-ui` (ADR 022,
2828
+ `docs/adr/022-publish-ui.md`) — previously a working but unpublished,
2829
+ `"private": true` scaffold, now `npx opentel-mcp-ui --demo`-able from a
2830
+ clean machine — and dev-mode metrics under `setupNodeSdk: true` (ADR
2831
+ 023, `docs/adr/023-dev-mode-metrics.md`, see "Dev-mode metrics" above),
2832
+ closing the gap where the documented 30-second quickstart emitted
2833
+ stderr spans but none of the 12 `mcp.tool.*`/`mcp.tool.loop.*`/
2834
+ `mcp.tool.schema_drift.*` metrics it leads with.
2704
2835
  - Future: failure clustering + regression detection; recovery hints;
2705
2836
  root-cause chaining across parent spans; alignment with the OTel GenAI
2706
2837
  SIG's MCP semantic conventions when published
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentel-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -69,13 +69,13 @@
69
69
  "dependencies": {
70
70
  "@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
71
71
  "@opentelemetry/resources": "^2.9.0",
72
+ "@opentelemetry/sdk-metrics": "^2.9.0",
72
73
  "@opentelemetry/sdk-trace-node": "^2.9.0"
73
74
  },
74
75
  "devDependencies": {
75
76
  "@modelcontextprotocol/server": "^2.0.0",
76
77
  "@opentelemetry/api": "^1.9.0",
77
78
  "@opentelemetry/context-async-hooks": "^2.9.0",
78
- "@opentelemetry/sdk-metrics": "^2.9.0",
79
79
  "@opentelemetry/sdk-trace-base": "^2.9.0",
80
80
  "@vitest/coverage-v8": "^2.1.9",
81
81
  "js-yaml": "^4.3.1",
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @module exporters/stderr-metrics
3
+ * A PushMetricExporter that prints metrics to stderr, in a compact,
4
+ * one-line-per-instrument format — not @opentelemetry/sdk-metrics' own
5
+ * ConsoleMetricExporter, which writes via console.dir (stdout) and dumps
6
+ * each instrument's full internal {descriptor, dataPointType, dataPoints}
7
+ * shape as unflattened JSON (ADR 023).
8
+ *
9
+ * Sibling to ./stderr.js (StderrSpanExporter, ADR 003) — same
10
+ * stdio-transport-safety reasoning: stdout is reserved for a
11
+ * StdioServerTransport server's JSON-RPC stream, so diagnostic output of
12
+ * any OTel signal must go to stderr instead.
13
+ *
14
+ * Reads only the attributes the existing 12 instruments — mcp.tool.*
15
+ * (src/metrics.js), mcp.tool.loop.* (src/thrash/emitter.js), and
16
+ * mcp.tool.schema_drift.* (src/schema-drift/emitter.js) — already attach.
17
+ * Adds no new attribute or metric-label surface of its own, so
18
+ * METRIC_SAFE_ATTRIBUTES governance is unaffected.
19
+ */
20
+
21
+ // DataPointType.HISTOGRAM === 0, .SUM === 3 (@opentelemetry/sdk-metrics) —
22
+ // inlined rather than importing the enum, same "avoid a dependency for a
23
+ // couple of integer constants" call stderr.js already makes for
24
+ // ExportResultCode (see that module's own comment).
25
+ const DATA_POINT_TYPE_HISTOGRAM = 0;
26
+
27
+ /**
28
+ * Flattens an attribute bag into a stable, sorted-by-key `{k=v,k=v}`
29
+ * suffix — deterministic across runs (never insertion-order-dependent),
30
+ * so a human diffing two terminal sessions sees only real differences.
31
+ *
32
+ * @param {Record<string, unknown>} attributes
33
+ * @returns {string}
34
+ */
35
+ function formatAttributes(attributes) {
36
+ const keys = Object.keys(attributes).sort();
37
+ if (keys.length === 0) return '';
38
+ return `{${keys.map((key) => `${key}=${attributes[key]}`).join(',')}}`;
39
+ }
40
+
41
+ /**
42
+ * One entry per data point: a stable identity `key` (which time series
43
+ * this is — the instrument name plus its exact attribute set) separate
44
+ * from `line` (the human-readable text) and `signature` (just the
45
+ * value-bearing part, cheap to compare across export intervals without
46
+ * re-parsing `line`). Counters/gauges/up-down-counters print their
47
+ * current value (`= <n>`); histograms print a count + average
48
+ * (`count=<n> avg=<sum/count><unit>`) rather than a full bucket dump — a
49
+ * terminal reader wants "how many, roughly how long," not a distribution.
50
+ *
51
+ * @param {import('@opentelemetry/sdk-metrics').MetricData} metric
52
+ * @returns {Array<{ key: string, line: string, signature: string }>}
53
+ */
54
+ function formatMetric(metric) {
55
+ const { name, unit } = metric.descriptor;
56
+ const entries = [];
57
+
58
+ for (const dataPoint of metric.dataPoints) {
59
+ const attrs = formatAttributes(dataPoint.attributes);
60
+ const key = `${name}${attrs}`;
61
+
62
+ if (metric.dataPointType === DATA_POINT_TYPE_HISTOGRAM) {
63
+ const { count, sum } = dataPoint.value;
64
+ const avg = count > 0 && typeof sum === 'number' ? (sum / count).toFixed(1) : '0';
65
+ entries.push({
66
+ key,
67
+ line: `[opentel-mcp metrics] ${key} count=${count} avg=${avg}${unit}`,
68
+ signature: `${count}:${sum}`,
69
+ });
70
+ } else {
71
+ entries.push({
72
+ key,
73
+ line: `[opentel-mcp metrics] ${key} = ${dataPoint.value}`,
74
+ signature: String(dataPoint.value),
75
+ });
76
+ }
77
+ }
78
+
79
+ return entries;
80
+ }
81
+
82
+ export class StderrMetricExporter {
83
+ constructor() {
84
+ // Every mcp.tool.* instrument uses CUMULATIVE aggregation temporality
85
+ // (the SDK's own default — never overridden here): once a tool is
86
+ // called even once, that (instrument, attribute-set) pair gets a
87
+ // permanent data point that every FUTURE export() call re-receives,
88
+ // unchanged, for the rest of the process's life, regardless of
89
+ // whether any new activity happened in the interval between. Without
90
+ // this map, a single call early in a long dev session reprints two
91
+ // identical lines every 5 seconds forever — confirmed empirically,
92
+ // not assumed. Keyed by formatMetric()'s own `key` (instrument name +
93
+ // exact attribute set); values are the last-printed `signature`, so a
94
+ // repeat export() with no real change is detected and skipped without
95
+ // re-deriving or re-parsing the full line.
96
+ this._lastSignatures = new Map();
97
+ }
98
+
99
+ /**
100
+ * @param {import('@opentelemetry/sdk-metrics').ResourceMetrics} metrics
101
+ * @param {(result: { code: number }) => void} resultCallback
102
+ */
103
+ export(metrics, resultCallback) {
104
+ try {
105
+ for (const scopeMetrics of metrics.scopeMetrics) {
106
+ for (const metric of scopeMetrics.metrics) {
107
+ // Never emit a line for a metric with zero data points yet (no
108
+ // calls recorded ever) — a dev terminal showing twelve "= 0"
109
+ // lines every 5 seconds before any tool has even been called
110
+ // is noise, not signal. formatMetric() already produces zero
111
+ // entries for such a metric, so this loop is naturally a no-op
112
+ // for it without any extra check here.
113
+ for (const { key, line, signature } of formatMetric(metric)) {
114
+ if (this._lastSignatures.get(key) === signature) continue;
115
+ console.error(line);
116
+ // Set only after a successful print, not before — if
117
+ // console.error somehow throws, this export() call fails
118
+ // (caught below, reported via resultCallback({ code: 1 })),
119
+ // and an SDK-level retry should see this as still unprinted
120
+ // rather than silently marked done.
121
+ this._lastSignatures.set(key, signature);
122
+ }
123
+ }
124
+ }
125
+ // 0 === ExportResultCode.SUCCESS (@opentelemetry/core) — see
126
+ // stderr.js's identical comment for why this is inlined.
127
+ resultCallback({ code: 0 });
128
+ } catch {
129
+ // Never-throw: a malformed metric data point must degrade to a
130
+ // failed export, never crash the host process the metric came from.
131
+ resultCallback({ code: 1 });
132
+ }
133
+ }
134
+
135
+ forceFlush() {
136
+ return Promise.resolve();
137
+ }
138
+
139
+ shutdown() {
140
+ return Promise.resolve();
141
+ }
142
+ }
package/src/index.d.ts CHANGED
@@ -44,6 +44,12 @@ export interface InstrumentOptions {
44
44
  * for opting out of metrics even when a `MeterProvider` **is**
45
45
  * registered, not a substitute for that default.
46
46
  *
47
+ * Also gates `setupNodeSdk`'s dev-mode `MeterProvider` (ADR 023,
48
+ * `docs/adr/023-dev-mode-metrics.md`, v0.15.0+) — `{ setupNodeSdk: true,
49
+ * enableMetrics: false }` gets dev-mode traces with no dev-mode metrics
50
+ * at all, the same combination this flag already produces against a
51
+ * host-registered `MeterProvider`.
52
+ *
47
53
  * @default true
48
54
  */
49
55
  enableMetrics?: boolean;
@@ -53,11 +59,28 @@ export interface InstrumentOptions {
53
59
  * `NodeTracerProvider` (always exporting to stderr; additionally to
54
60
  * `exporterUrl` via OTLP/HTTP if set).
55
61
  *
62
+ * **Also stands up a dev-mode `MeterProvider` (ADR 023,
63
+ * `docs/adr/023-dev-mode-metrics.md`, v0.15.0+)**, gated additionally by
64
+ * `enableMetrics` (above, default `true`): a `PeriodicExportingMetricReader`
65
+ * exporting all 12 `mcp.tool.*`/`mcp.tool.loop.*`/`mcp.tool.schema_drift.*`
66
+ * instruments to stderr every 5 seconds, in a compact
67
+ * `name{attrs} = value` format — never `@opentelemetry/sdk-metrics`'
68
+ * own `ConsoleMetricExporter`, which writes to stdout and would corrupt
69
+ * a `StdioServerTransport` server's JSON-RPC stream (the same ADR 003
70
+ * reasoning the dev tracer's stderr-only exporter already follows). If
71
+ * a `MeterProvider` is already registered globally when this runs, this
72
+ * is a complete no-op — nothing is overridden, no exception is thrown,
73
+ * and a single `diag.warn` (once per process) names the situation;
74
+ * metrics continue to flow through whatever was already registered,
75
+ * exactly as they would with this flag `false`.
76
+ *
56
77
  * When `false` (the default), spans are emitted via whatever OpenTelemetry
57
78
  * `TracerProvider` the host application has already registered globally —
58
79
  * or dropped silently if none has been registered. This default keeps
59
80
  * {@link instrumentMcpServer} from ever overriding a host application's own
60
- * OpenTelemetry setup.
81
+ * OpenTelemetry setup. Metrics behavior is completely unaffected by this
82
+ * flag being `false` — API-only, exactly as documented in the README's
83
+ * "Metrics" section, with no dev-mode exception.
61
84
  *
62
85
  * @default false
63
86
  */
@@ -326,9 +349,14 @@ export type DuckTypedMcpServer = {
326
349
  * `options.setupNodeSdk` is `true`, the returned object also gets a
327
350
  * `shutdown()` method that flushes and shuts down the `NodeTracerProvider`
328
351
  * created for it — call it during your process's own shutdown sequence to
329
- * avoid losing buffered spans. `shutdown` is typed as optional because it
330
- * is only attached at runtime when `setupNodeSdk` is `true`; check for its
331
- * presence before calling. The returned object also gets a
352
+ * avoid losing buffered spans. As of v0.15.0 (ADR 023), when dev-mode
353
+ * metrics were also set up (`enableMetrics` not `false`, and no
354
+ * `MeterProvider` was already registered globally — see `setupNodeSdk`'s
355
+ * own doc above), the same `shutdown()` call also flushes and shuts down
356
+ * that `MeterProvider`, so one call covers both signals. `shutdown` is
357
+ * typed as optional because it is only attached at runtime when
358
+ * `setupNodeSdk` is `true`; check for its presence before calling. The
359
+ * returned object also gets a
332
360
  * `getThrashSummary()` method (v0.6.0) returning a point-in-time,
333
361
  * in-process {@link ThrashSummary} — no OTel involved, nothing sent
334
362
  * anywhere; see the README's "Agent Thrash Detection" section. Unlike
package/src/instrument.js CHANGED
@@ -4,13 +4,15 @@
4
4
 
5
5
  import { createRequire } from 'node:module';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { trace, diag, SpanStatusCode, SpanKind } from '@opentelemetry/api';
7
+ import { trace, metrics, diag, SpanStatusCode, SpanKind } from '@opentelemetry/api';
8
8
  import { getV1Sdk, getV2Sdk } from './sdk/detect.js';
9
9
  import { NodeTracerProvider, SimpleSpanProcessor, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
10
+ import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
10
11
  import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
11
12
  import { resourceFromAttributes } from '@opentelemetry/resources';
12
13
  import { resolveOptions } from './config.js';
13
14
  import { StderrSpanExporter } from './exporters/stderr.js';
15
+ import { StderrMetricExporter } from './exporters/stderr-metrics.js';
14
16
  import { setupMeter } from './metrics.js';
15
17
  import { computeFingerprint, MAX_ERROR_CLASS_LENGTH } from './fingerprint/compose.js';
16
18
  import { toSpanAttributes, ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
@@ -697,9 +699,31 @@ function assertInstrumentFirst(server, resolved) {
697
699
  }
698
700
  }
699
701
 
702
+ // ADR 023 (v0.15.0): dev-mode metrics' export interval under
703
+ // setupNodeSdk: true — 5s, not @opentelemetry/sdk-metrics'
704
+ // PeriodicExportingMetricReader's own 60s default. A quickstart user
705
+ // making a few tool calls and watching their terminal should see output
706
+ // within a few seconds, not up to a minute later. Not currently exposed
707
+ // as a configurable option — same "no configurable knobs" posture
708
+ // StderrSpanExporter already takes.
709
+ const DEV_METRICS_EXPORT_INTERVAL_MS = 5000;
710
+
711
+ // Guards the "a MeterProvider is already registered, dev metrics skipped"
712
+ // diagnostic below (ADR 023) so it fires once per process, same
713
+ // once-per-process pattern config.js's warnedServiceNameIgnored/
714
+ // warnedPricingStale already establish.
715
+ let warnedMeterProviderAlreadyRegistered = false;
716
+
717
+ // Test-only, same purpose as config.js's __resetServiceNameWarnedForTests/
718
+ // __resetPricingStaleWarnedForTests. Not part of the public API.
719
+ export function __resetMeterProviderWarnedForTests() {
720
+ warnedMeterProviderAlreadyRegistered = false;
721
+ }
722
+
700
723
  /**
701
724
  * Resolves the Tracer to use for this server, optionally standing up an
702
- * owned NodeTracerProvider first.
725
+ * owned NodeTracerProvider (and, per ADR 023, an owned dev-mode
726
+ * MeterProvider) first.
703
727
  *
704
728
  * @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
705
729
  * @param {Required<import('./config.js').InstrumentOptions>} resolved
@@ -724,13 +748,73 @@ function setupTracer(server, resolved) {
724
748
  ? { [ATTR_MCP_PRICING_DEFAULT_TABLE_LAST_VERIFIED]: DEFAULT_PRICING_LAST_VERIFIED }
725
749
  : {};
726
750
 
727
- const provider = new NodeTracerProvider({
728
- resource: resourceFromAttributes({ 'service.name': resolved.serviceName, ...pricingResourceAttributes }),
729
- spanProcessors,
730
- });
751
+ // ADR 023: shared between the dev TracerProvider below and the dev
752
+ // MeterProvider further down, so service.name (and the pricing
753
+ // resource attribute) can never drift between the two signals in dev
754
+ // mode — one Resource construction, not two independently-built ones.
755
+ const resource = resourceFromAttributes({ 'service.name': resolved.serviceName, ...pricingResourceAttributes });
756
+
757
+ const provider = new NodeTracerProvider({ resource, spanProcessors });
731
758
  provider.register();
732
759
 
733
- server.shutdown = () => provider.shutdown();
760
+ // ADR 023 (v0.15.0): dev-mode metrics. Only attempted when metrics
761
+ // aren't separately opted out via enableMetrics: false — a host who's
762
+ // already said "no metrics from this library" via that flag gets
763
+ // exactly that, with no new interaction between the two options to
764
+ // reason about.
765
+ let meterProvider = null;
766
+ if (resolved.enableMetrics) {
767
+ const candidateMeterProvider = new MeterProvider({
768
+ resource,
769
+ readers: [
770
+ new PeriodicExportingMetricReader({
771
+ exporter: new StderrMetricExporter(),
772
+ exportIntervalMillis: DEV_METRICS_EXPORT_INTERVAL_MS,
773
+ }),
774
+ ],
775
+ });
776
+
777
+ // metrics.setGlobalMeterProvider() returns false (documented,
778
+ // public .d.ts return type — not an internal detail) when a
779
+ // MeterProvider is already globally registered. In that case the
780
+ // ALREADY-registered provider — whoever's it is — keeps being what
781
+ // setupMeter() resolves to immediately below; candidateMeterProvider
782
+ // is never used for anything and must be shut down immediately
783
+ // rather than merely left unreferenced: @opentelemetry/sdk-metrics'
784
+ // MeterProvider constructor already called
785
+ // PeriodicExportingMetricReader's onInitialized() (confirmed by
786
+ // reading its source) — i.e. its setInterval is already running
787
+ // from the moment `new MeterProvider(...)` above returned, BEFORE
788
+ // this registration attempt — so an un-shut-down loser here would
789
+ // tick forever, every DEV_METRICS_EXPORT_INTERVAL_MS, exporting an
790
+ // empty ResourceMetrics (nothing ever creates an instrument on an
791
+ // abandoned provider) to a StderrMetricExporter nobody reads.
792
+ // Fire-and-forget, never awaited: shutdown must not block this
793
+ // synchronous setup path, and a rejection here must never throw
794
+ // past it (never-throw) — this is purely cleanup of a provider
795
+ // nothing else will ever reference again.
796
+ const registered = metrics.setGlobalMeterProvider(candidateMeterProvider);
797
+ if (registered) {
798
+ meterProvider = candidateMeterProvider;
799
+ } else {
800
+ candidateMeterProvider.shutdown().catch(() => {});
801
+ if (!warnedMeterProviderAlreadyRegistered) {
802
+ warnedMeterProviderAlreadyRegistered = true;
803
+ diag.warn(
804
+ 'opentel-mcp: setupNodeSdk is true, but a MeterProvider is already registered globally — ' +
805
+ 'dev-mode stderr metrics (ADR 023) were not set up, and the existing MeterProvider was left ' +
806
+ 'untouched. This mcp.tool.* metrics will still be recorded through it as normal; only the ' +
807
+ "convenience stderr printout is skipped. Remove whatever registered a MeterProvider before " +
808
+ 'instrumentMcpServer() ran if you intended to use the dev-mode printout instead.',
809
+ );
810
+ }
811
+ }
812
+ }
813
+
814
+ server.shutdown = async () => {
815
+ await provider.shutdown();
816
+ if (meterProvider) await meterProvider.shutdown();
817
+ };
734
818
  }
735
819
 
736
820
  // Always the final step: when setupNodeSdk is false, this picks up