opentel-mcp 0.2.0 → 0.3.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 +60 -0
- package/README.md +155 -5
- package/package.json +3 -1
- package/src/attributes.js +22 -0
- package/src/config.js +5 -0
- package/src/index.d.ts +13 -0
- package/src/instrument.js +43 -10
- package/src/metrics.js +87 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- OTel metrics, via `@opentelemetry/api`'s Metrics API only (no bundled
|
|
8
|
+
SDK/exporter — same host-app-provides-the-SDK pattern tracing already
|
|
9
|
+
uses):
|
|
10
|
+
- `mcp.tool.calls` (counter) — every tool call; `gen_ai.tool.name`,
|
|
11
|
+
`mcp.method.name`
|
|
12
|
+
- `mcp.tool.errors` (counter) — thrown/rejected handler; `gen_ai.tool.name`,
|
|
13
|
+
`error.type`
|
|
14
|
+
- `mcp.tool.silent_failures` (counter) — JSON-RPC succeeded but
|
|
15
|
+
`CallToolResult.isError === true`; `gen_ai.tool.name`
|
|
16
|
+
- `mcp.tool.duration` (histogram, unit `ms`) — call latency;
|
|
17
|
+
`gen_ai.tool.name`, `mcp.tool.outcome` (`success` | `error` |
|
|
18
|
+
`silent_failure`)
|
|
19
|
+
- `mcp.tool.silent_failures` fires from the same `isError` check that
|
|
20
|
+
marks the span ERROR — extracted into one shared `isToolResultError()`
|
|
21
|
+
helper in `src/instrument.js` so the detection logic isn't duplicated
|
|
22
|
+
between traces and metrics.
|
|
23
|
+
- Metrics are a zero-overhead no-op until the host application registers
|
|
24
|
+
a `MeterProvider` (default `@opentelemetry/api` behavior — not
|
|
25
|
+
special-cased here).
|
|
26
|
+
- `enableMetrics` option (default `true`) to opt out of metric emission
|
|
27
|
+
without affecting tracing.
|
|
28
|
+
|
|
29
|
+
### Naming note (no attribute rename)
|
|
30
|
+
|
|
31
|
+
The tool-name attribute on all four new metrics is `gen_ai.tool.name`, not
|
|
32
|
+
`mcp.tool.name` — the same spec-aligned name spans have used since v0.2's
|
|
33
|
+
semantic-conventions pass (ADR 004). Traces and metrics were already
|
|
34
|
+
consistent going into this release, so nothing was renamed here; this is
|
|
35
|
+
called out because a naive read of the MCP semantic conventions might
|
|
36
|
+
suggest a `mcp.tool.name` attribute, but the spec's actual server-span/
|
|
37
|
+
metric attribute for this is `gen_ai.tool.name` (MCP tool calls are
|
|
38
|
+
GenAI `execute_tool` calls under the hood — see `docs/adr/004-semantic-conventions-alignment.md`
|
|
39
|
+
and `.spec-reference/mcp-semconv.md`). `mcp.method.name`, `error.type`,
|
|
40
|
+
and `mcp.tool.argument_count` are unchanged. `mcp.tool.outcome` is a new
|
|
41
|
+
custom (non-spec) attribute, documented in `src/attributes.js` alongside
|
|
42
|
+
the other custom attribute.
|
|
43
|
+
|
|
44
|
+
### Docs
|
|
45
|
+
|
|
46
|
+
- README: new "Metrics" section (instrument table, `enableMetrics`, and a
|
|
47
|
+
`PeriodicExportingMetricReader` + OTLP/HTTP wiring example targeting
|
|
48
|
+
SigNoz's default local endpoint).
|
|
49
|
+
- Roadmap updated to reflect metrics shipping in this release.
|
|
50
|
+
|
|
51
|
+
## 0.2.0
|
|
52
|
+
|
|
53
|
+
See git history — TypeScript declarations (`.d.ts`), workspace stripping
|
|
54
|
+
for publish, and README documentation improvements.
|
|
55
|
+
|
|
56
|
+
## 0.1.0
|
|
57
|
+
|
|
58
|
+
Initial release: OTel tracing for MCP tool calls, including detection of
|
|
59
|
+
`CallToolResult.isError: true` "silent failures" as `error.type: tool_error`
|
|
60
|
+
span status.
|
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> OpenTelemetry instrumentation for Model Context Protocol (MCP) servers.
|
|
4
4
|
> One-line visibility into which tools your AI agent is calling, how
|
|
5
|
-
> long they take, and which ones fail — via standard OTel traces
|
|
5
|
+
> long they take, and which ones fail — via standard OTel traces and
|
|
6
|
+
> metrics.
|
|
6
7
|
|
|
7
8
|
[](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
|
|
8
9
|
[](https://www.npmjs.com/package/opentel-mcp)
|
|
@@ -32,6 +33,65 @@ invocation with rich attributes, using standard OpenTelemetry APIs so
|
|
|
32
33
|
it plugs into your existing observability stack (Jaeger, Grafana Tempo,
|
|
33
34
|
Honeycomb, Datadog, whatever).
|
|
34
35
|
|
|
36
|
+
## What you get
|
|
37
|
+
|
|
38
|
+
MCP tool calls are invisible by default. When an agent turn takes 2.5
|
|
39
|
+
seconds, you have no idea which tool caused it. opentel-mcp turns every
|
|
40
|
+
tool call into an OpenTelemetry span you can read.
|
|
41
|
+
|
|
42
|
+
### Example trace
|
|
43
|
+
|
|
44
|
+
One agent turn, captured in Jaeger (spans generated by opentel-mcp,
|
|
45
|
+
exported over OTLP):
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
agent_turn ............................. 2470ms OK
|
|
49
|
+
├─ tools/call search_documents ........... 46ms OK
|
|
50
|
+
├─ tools/call fetch_weather ............. 601ms OK
|
|
51
|
+
└─ tools/call generate_advice .......... 1802ms OK ← the bottleneck
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Without tracing you'd know only that the turn took ~2.5s. With it, the
|
|
55
|
+
waterfall shows `generate_advice` is where the time goes —
|
|
56
|
+
`search_documents` and `fetch_weather` are cheap by comparison. That's
|
|
57
|
+
the difference between guessing and knowing.
|
|
58
|
+
|
|
59
|
+
### Tool-level failures
|
|
60
|
+
|
|
61
|
+
An MCP tool can fail two ways: it can throw, or it can return
|
|
62
|
+
`isError: true` on an otherwise-successful response. The second is how a
|
|
63
|
+
tool reports a failure to the agent without crashing the server — and
|
|
64
|
+
most instrumentation records it as a success, because the call
|
|
65
|
+
technically succeeded.
|
|
66
|
+
|
|
67
|
+
opentel-mcp catches it:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
tools/call fetch_weather ................. 605ms ERROR
|
|
71
|
+
error.type = tool_error
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The span is marked ERROR with `error.type = tool_error`, the result is
|
|
75
|
+
returned to the agent unchanged, and nothing is thrown. Your error rate
|
|
76
|
+
reflects reality instead of reading as a flat 100% success.
|
|
77
|
+
|
|
78
|
+
### Span attributes
|
|
79
|
+
|
|
80
|
+
Every span follows the OpenTelemetry MCP semantic conventions, so it
|
|
81
|
+
drops into existing dashboards (Jaeger, Grafana Tempo, Honeycomb, SigNoz)
|
|
82
|
+
with no custom mapping:
|
|
83
|
+
|
|
84
|
+
| Attribute | Example value |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `mcp.method.name` | `tools/call` |
|
|
87
|
+
| `gen_ai.tool.name` | `generate_advice` |
|
|
88
|
+
| `gen_ai.operation.name` | `execute_tool` |
|
|
89
|
+
| `jsonrpc.request.id` | `3` |
|
|
90
|
+
| `mcp.tool.argument_count` | `2` |
|
|
91
|
+
|
|
92
|
+
Span name follows the spec's `{method} {target}` form (e.g.
|
|
93
|
+
`tools/call generate_advice`), and span kind is `SERVER`.
|
|
94
|
+
|
|
35
95
|
## Quickstart (5-line usage)
|
|
36
96
|
|
|
37
97
|
Works with either the low-level `Server` API:
|
|
@@ -110,6 +170,95 @@ Span status description carries the error message on failure (thrown
|
|
|
110
170
|
errors); no separate error-message attribute is emitted — the spec
|
|
111
171
|
expresses success/failure through span status, not a status attribute.
|
|
112
172
|
|
|
173
|
+
## Metrics
|
|
174
|
+
|
|
175
|
+
Alongside spans, opentel-mcp emits four `mcp.tool.*` metrics via
|
|
176
|
+
`@opentelemetry/api`'s Metrics API — same API-only pattern as tracing (see
|
|
177
|
+
"Two modes" below): no SDK or exporter is bundled, and recording is a
|
|
178
|
+
zero-overhead no-op until the host application registers a
|
|
179
|
+
`MeterProvider`. Set `enableMetrics: false` to opt out even when one is
|
|
180
|
+
registered; tracing is unaffected either way.
|
|
181
|
+
|
|
182
|
+
| Instrument | Type | Unit | Attributes | Emitted when |
|
|
183
|
+
|---|---|---|---|---|
|
|
184
|
+
| `mcp.tool.calls` | Counter | — | `gen_ai.tool.name`, `mcp.method.name` | Every tool call |
|
|
185
|
+
| `mcp.tool.errors` | Counter | — | `gen_ai.tool.name`, `error.type` | The handler threw or its promise rejected |
|
|
186
|
+
| `mcp.tool.silent_failures` | Counter | — | `gen_ai.tool.name` | The JSON-RPC response succeeded but `CallToolResult.isError` was `true` |
|
|
187
|
+
| `mcp.tool.duration` | Histogram | `ms` | `gen_ai.tool.name`, `mcp.tool.outcome` (`success` \| `error` \| `silent_failure`) | Every tool call, on completion |
|
|
188
|
+
|
|
189
|
+
`mcp.tool.silent_failures` is incremented from the exact same `isError`
|
|
190
|
+
check that marks the span ERROR (see `isToolResultError()` in
|
|
191
|
+
`src/instrument.js`) — the detection logic isn't duplicated between traces
|
|
192
|
+
and metrics. `gen_ai.tool.name`, `mcp.method.name`, and `error.type` are
|
|
193
|
+
the same spec-aligned attribute names the spans already use (see "Span
|
|
194
|
+
attributes emitted" above); `mcp.tool.outcome` is a custom (non-spec)
|
|
195
|
+
attribute, documented in `src/attributes.js`, that lets a single duration
|
|
196
|
+
histogram distinguish thrown/protocol errors from silent failures without
|
|
197
|
+
joining against `error.type`, which silent failures don't set on this
|
|
198
|
+
metric.
|
|
199
|
+
|
|
200
|
+
### Wiring a MeterProvider and TracerProvider (example: SigNoz)
|
|
201
|
+
|
|
202
|
+
opentel-mcp never creates a `MeterProvider` itself (except the stderr/dev
|
|
203
|
+
`setupNodeSdk: true` tracing path, which doesn't touch metrics) — register
|
|
204
|
+
one the same way you would for any OTel-instrumented Node app, before
|
|
205
|
+
calling `instrumentMcpServer()`. The same is true of the `TracerProvider`
|
|
206
|
+
in bring-your-own-SDK mode (`setupNodeSdk: false`, the default): without
|
|
207
|
+
registering one, `trace.getTracer()` inside opentel-mcp resolves to the
|
|
208
|
+
`@opentelemetry/api` no-op default and spans are silently dropped, even
|
|
209
|
+
though `mcp.tool.*` metrics keep flowing:
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
import { metrics, trace } from '@opentelemetry/api';
|
|
213
|
+
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
|
214
|
+
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
|
|
215
|
+
import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
|
|
216
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
217
|
+
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
218
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
219
|
+
import { instrumentMcpServer } from 'opentel-mcp';
|
|
220
|
+
|
|
221
|
+
const resource = resourceFromAttributes({ 'service.name': 'my-mcp-server' });
|
|
222
|
+
|
|
223
|
+
const meterProvider = new MeterProvider({
|
|
224
|
+
resource,
|
|
225
|
+
readers: [
|
|
226
|
+
new PeriodicExportingMetricReader({
|
|
227
|
+
exporter: new OTLPMetricExporter({ url: 'http://localhost:4318/v1/metrics' }),
|
|
228
|
+
}),
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
metrics.setGlobalMeterProvider(meterProvider);
|
|
232
|
+
|
|
233
|
+
const tracerProvider = new NodeTracerProvider({
|
|
234
|
+
resource,
|
|
235
|
+
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }))],
|
|
236
|
+
});
|
|
237
|
+
tracerProvider.register();
|
|
238
|
+
|
|
239
|
+
const server = new Server({ name: 'my-server', version: '1.0.0' }, {
|
|
240
|
+
capabilities: { tools: {} },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
instrumentMcpServer(server, {
|
|
244
|
+
// setupNodeSdk: false (default) — this example brings its own SDK for
|
|
245
|
+
// both signals: the MeterProvider above for metrics, and the
|
|
246
|
+
// NodeTracerProvider above — registered globally via
|
|
247
|
+
// tracerProvider.register(), which is what trace.getTracer() picks up —
|
|
248
|
+
// for traces.
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`http://localhost:4318` is SigNoz's default local OTLP/HTTP endpoint (both
|
|
253
|
+
`/v1/metrics` and `/v1/traces`); point it at your collector/SigNoz
|
|
254
|
+
ingestion endpoint in production. `@opentelemetry/sdk-metrics` and
|
|
255
|
+
`@opentelemetry/exporter-metrics-otlp-http` are peer/host-app
|
|
256
|
+
dependencies — opentel-mcp doesn't bundle them (see the peerDependencies
|
|
257
|
+
note in `package.json`). `@opentelemetry/sdk-trace-node` and
|
|
258
|
+
`@opentelemetry/exporter-trace-otlp-http`, by contrast, are already
|
|
259
|
+
runtime dependencies of opentel-mcp itself (used by its `setupNodeSdk: true`
|
|
260
|
+
dev path), so no extra install is needed to use them here.
|
|
261
|
+
|
|
113
262
|
## Two modes
|
|
114
263
|
|
|
115
264
|
**One-line (dev):** `setupNodeSdk: true` sets up a NodeTracerProvider
|
|
@@ -144,10 +293,11 @@ See ADR 002 in docs/adr/ for why.
|
|
|
144
293
|
|
|
145
294
|
## Roadmap
|
|
146
295
|
|
|
147
|
-
- v0.
|
|
148
|
-
`
|
|
149
|
-
`
|
|
150
|
-
|
|
296
|
+
- Shipped in v0.3: `mcp.tool.*` metrics (see "Metrics" above). Opt-in
|
|
297
|
+
`gen_ai.tool.call.arguments` support with a redaction callback, and the
|
|
298
|
+
spec's own `mcp.server.operation.duration` / `mcp.server.session.duration`
|
|
299
|
+
metrics, remain unimplemented — tracked here, not silently dropped.
|
|
300
|
+
- Next: W3C trace context propagation via `params._meta` per
|
|
151
301
|
[SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta),
|
|
152
302
|
plus client-side instrumentation, so a single trace can span the client
|
|
153
303
|
call and the server's tool execution
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentel-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
16
|
"README.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
19
20
|
"engines": {
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
59
|
"@opentelemetry/api": "^1.9.0",
|
|
60
|
+
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
59
61
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
60
62
|
"typescript": "^7.0.2",
|
|
61
63
|
"vitest": "^2.1.8"
|
package/src/attributes.js
CHANGED
|
@@ -63,3 +63,25 @@ export const ATTR_MCP_TOOL_ARGUMENT_COUNT = 'mcp.tool.argument_count';
|
|
|
63
63
|
|
|
64
64
|
export const ATTR_MCP_SERVER_NAME = 'mcp.server.name';
|
|
65
65
|
export const ATTR_MCP_SERVER_VERSION = 'mcp.server.version';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* NOT part of the MCP semantic conventions. Our own addition, on the
|
|
69
|
+
* mcp.tool.duration histogram (see src/metrics.js): which of the three
|
|
70
|
+
* call outcomes a given duration measurement belongs to. The spec's
|
|
71
|
+
* mcp.server.operation.duration metric (not yet implemented here — see
|
|
72
|
+
* README roadmap) expresses failure only via error.type; this attribute
|
|
73
|
+
* additionally distinguishes "thrown/protocol error" from "silent failure"
|
|
74
|
+
* (isError: true) so both are visible on the same histogram without
|
|
75
|
+
* requiring a join against error.type, which silent failures don't set
|
|
76
|
+
* on the duration metric.
|
|
77
|
+
*/
|
|
78
|
+
export const ATTR_MCP_TOOL_OUTCOME = 'mcp.tool.outcome';
|
|
79
|
+
|
|
80
|
+
/** Well-known mcp.tool.outcome value: the call succeeded. */
|
|
81
|
+
export const MCP_TOOL_OUTCOME_SUCCESS = 'success';
|
|
82
|
+
|
|
83
|
+
/** Well-known mcp.tool.outcome value: the handler threw or its promise rejected. */
|
|
84
|
+
export const MCP_TOOL_OUTCOME_ERROR = 'error';
|
|
85
|
+
|
|
86
|
+
/** Well-known mcp.tool.outcome value: isError: true (see ERROR_TYPE_TOOL_ERROR above). */
|
|
87
|
+
export const MCP_TOOL_OUTCOME_SILENT_FAILURE = 'silent_failure';
|
package/src/config.js
CHANGED
|
@@ -16,6 +16,10 @@ import { diag } from '@opentelemetry/api';
|
|
|
16
16
|
* Only takes effect when `setupNodeSdk` is true.
|
|
17
17
|
* @property {boolean} [enabled=true] - Set to false to disable instrumentation entirely; instrumentMcpServer()
|
|
18
18
|
* becomes a no-op.
|
|
19
|
+
* @property {boolean} [enableMetrics=true] - Set to false to disable the mcp.tool.* metrics (tracing is
|
|
20
|
+
* unaffected). Metrics are already a zero-overhead no-op when no MeterProvider is registered — the default
|
|
21
|
+
* @opentelemetry/api behavior — so this flag exists for opting out even when one *is* registered, not as a
|
|
22
|
+
* substitute for that default.
|
|
19
23
|
* @property {boolean} [setupNodeSdk=false] - When true, instrumentMcpServer() creates and registers its own
|
|
20
24
|
* NodeTracerProvider (always exporting to stderr — safe alongside stdio-transport MCP servers, see ADR 003;
|
|
21
25
|
* additionally to `exporterUrl` via OTLP/HTTP if set). When false (the default), spans are emitted via
|
|
@@ -66,6 +70,7 @@ export function resolveOptions(options) {
|
|
|
66
70
|
serviceName: opts.serviceName,
|
|
67
71
|
exporterUrl: opts.exporterUrl,
|
|
68
72
|
enabled: opts.enabled ?? true,
|
|
73
|
+
enableMetrics: opts.enableMetrics ?? true,
|
|
69
74
|
setupNodeSdk,
|
|
70
75
|
};
|
|
71
76
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -32,6 +32,19 @@ export interface InstrumentOptions {
|
|
|
32
32
|
*/
|
|
33
33
|
enabled?: boolean;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Set to `false` to disable the `mcp.tool.*` metrics. Tracing is
|
|
37
|
+
* unaffected.
|
|
38
|
+
*
|
|
39
|
+
* Metrics are already a zero-overhead no-op when no `MeterProvider` is
|
|
40
|
+
* registered (the default `@opentelemetry/api` behavior) — this flag is
|
|
41
|
+
* for opting out of metrics even when a `MeterProvider` **is**
|
|
42
|
+
* registered, not a substitute for that default.
|
|
43
|
+
*
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
enableMetrics?: boolean;
|
|
47
|
+
|
|
35
48
|
/**
|
|
36
49
|
* When `true`, {@link instrumentMcpServer} creates and registers its own
|
|
37
50
|
* `NodeTracerProvider` (always exporting to stderr; additionally to
|
package/src/instrument.js
CHANGED
|
@@ -11,6 +11,7 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
|
11
11
|
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
12
12
|
import { resolveOptions } from './config.js';
|
|
13
13
|
import { StderrSpanExporter } from './exporters/stderr.js';
|
|
14
|
+
import { setupMeter } from './metrics.js';
|
|
14
15
|
import {
|
|
15
16
|
ATTR_MCP_METHOD_NAME,
|
|
16
17
|
ATTR_GEN_AI_TOOL_NAME,
|
|
@@ -21,6 +22,9 @@ import {
|
|
|
21
22
|
ERROR_TYPE_TOOL_ERROR,
|
|
22
23
|
GEN_AI_OPERATION_NAME_EXECUTE_TOOL,
|
|
23
24
|
MCP_METHOD_NAME_TOOLS_CALL,
|
|
25
|
+
MCP_TOOL_OUTCOME_SUCCESS,
|
|
26
|
+
MCP_TOOL_OUTCOME_ERROR,
|
|
27
|
+
MCP_TOOL_OUTCOME_SILENT_FAILURE,
|
|
24
28
|
} from './attributes.js';
|
|
25
29
|
|
|
26
30
|
const require = createRequire(import.meta.url);
|
|
@@ -130,6 +134,7 @@ export function instrumentMcpServer(input, options) {
|
|
|
130
134
|
assertInstrumentFirst(server);
|
|
131
135
|
|
|
132
136
|
const tracer = setupTracer(server, resolved);
|
|
137
|
+
const metricsRecorder = resolved.enableMetrics ? setupMeter(PACKAGE_VERSION) : null;
|
|
133
138
|
if (outer && server.shutdown) {
|
|
134
139
|
outer.shutdown = server.shutdown;
|
|
135
140
|
}
|
|
@@ -137,7 +142,7 @@ export function instrumentMcpServer(input, options) {
|
|
|
137
142
|
const originalSetRequestHandler = server.setRequestHandler.bind(server);
|
|
138
143
|
server.setRequestHandler = (schema, handler) => {
|
|
139
144
|
if (schema === CallToolRequestSchema) {
|
|
140
|
-
handler = wrapToolCallHandler(handler, tracer);
|
|
145
|
+
handler = wrapToolCallHandler(handler, tracer, metricsRecorder);
|
|
141
146
|
}
|
|
142
147
|
return originalSetRequestHandler(schema, handler);
|
|
143
148
|
};
|
|
@@ -207,9 +212,27 @@ function setupTracer(server, resolved) {
|
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
215
|
+
* True when `result` is a JSON-RPC-successful CallToolResult carrying a
|
|
216
|
+
* tool-level failure (isError: true) — the "silent failure" this package
|
|
217
|
+
* exists to catch (see the README's "Tool-level failures" section). Single
|
|
218
|
+
* source of truth for that detection: wrapToolCallHandler below keys both
|
|
219
|
+
* the span's status/error.type and the mcp.tool.silent_failures counter /
|
|
220
|
+
* mcp.tool.duration outcome off this same check, rather than repeating
|
|
221
|
+
* `result?.isError === true` at each call site.
|
|
222
|
+
*
|
|
223
|
+
* @param {*} result
|
|
224
|
+
* @returns {boolean}
|
|
225
|
+
*/
|
|
226
|
+
function isToolResultError(result) {
|
|
227
|
+
return result?.isError === true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Wraps a tools/call handler in a span covering its execution, plus the
|
|
232
|
+
* mcp.tool.* metrics (see src/metrics.js). This sits as the innermost layer
|
|
233
|
+
* relative to Server's own request/response validation wrapping (see ADR
|
|
234
|
+
* 001), so both the span and the duration measurement time exactly the
|
|
235
|
+
* real handler logic.
|
|
213
236
|
*
|
|
214
237
|
* Span shape follows the MCP semantic conventions' server span (ADR 004):
|
|
215
238
|
* name `{mcp.method.name} {target}` (falling back to just the method name
|
|
@@ -217,14 +240,15 @@ function setupTracer(server, resolved) {
|
|
|
217
240
|
* error.type is set — which happens either because the handler threw, or
|
|
218
241
|
* because it resolved successfully but returned a CallToolResult with
|
|
219
242
|
* isError: true (a JSON-RPC-level success carrying a tool-level failure;
|
|
220
|
-
* the spec calls this error.type value "tool_error"
|
|
221
|
-
*
|
|
222
|
-
* call itself succeeded.
|
|
243
|
+
* the spec calls this error.type value "tool_error", see
|
|
244
|
+
* isToolResultError() above). In the isError case the result is returned
|
|
245
|
+
* unchanged and nothing is thrown — the JSON-RPC call itself succeeded.
|
|
223
246
|
*
|
|
224
247
|
* @param {Function} handler
|
|
225
248
|
* @param {import('@opentelemetry/api').Tracer} tracer
|
|
249
|
+
* @param {ReturnType<import('./metrics.js').setupMeter> | null} metricsRecorder
|
|
226
250
|
*/
|
|
227
|
-
function wrapToolCallHandler(handler, tracer) {
|
|
251
|
+
function wrapToolCallHandler(handler, tracer, metricsRecorder) {
|
|
228
252
|
return (request, extra) => {
|
|
229
253
|
const toolName = request?.params?.name;
|
|
230
254
|
const spanName = toolName ? `${TOOLS_CALL_METHOD} ${toolName}` : TOOLS_CALL_METHOD;
|
|
@@ -240,19 +264,28 @@ function wrapToolCallHandler(handler, tracer) {
|
|
|
240
264
|
span.setAttribute(ATTR_JSONRPC_REQUEST_ID, String(extra.requestId));
|
|
241
265
|
}
|
|
242
266
|
|
|
267
|
+
metricsRecorder?.recordCall(toolName);
|
|
268
|
+
const startTime = performance.now();
|
|
269
|
+
|
|
243
270
|
try {
|
|
244
271
|
const result = await handler(request, extra);
|
|
245
|
-
if (result
|
|
272
|
+
if (isToolResultError(result)) {
|
|
246
273
|
span.setAttribute(ATTR_ERROR_TYPE, ERROR_TYPE_TOOL_ERROR);
|
|
247
274
|
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
275
|
+
metricsRecorder?.recordSilentFailure(toolName);
|
|
276
|
+
metricsRecorder?.recordDuration(toolName, performance.now() - startTime, MCP_TOOL_OUTCOME_SILENT_FAILURE);
|
|
248
277
|
} else {
|
|
249
278
|
span.setStatus({ code: SpanStatusCode.OK });
|
|
279
|
+
metricsRecorder?.recordDuration(toolName, performance.now() - startTime, MCP_TOOL_OUTCOME_SUCCESS);
|
|
250
280
|
}
|
|
251
281
|
return result;
|
|
252
282
|
} catch (err) {
|
|
253
283
|
span.recordException(err);
|
|
254
284
|
span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
|
|
255
|
-
|
|
285
|
+
const errorType = err?.name ?? 'Error';
|
|
286
|
+
span.setAttribute(ATTR_ERROR_TYPE, errorType);
|
|
287
|
+
metricsRecorder?.recordError(toolName, errorType);
|
|
288
|
+
metricsRecorder?.recordDuration(toolName, performance.now() - startTime, MCP_TOOL_OUTCOME_ERROR);
|
|
256
289
|
throw err;
|
|
257
290
|
} finally {
|
|
258
291
|
span.end();
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module metrics
|
|
3
|
+
* OpenTelemetry Metrics instruments for opentel-mcp.
|
|
4
|
+
*
|
|
5
|
+
* Follows the same @opentelemetry/api-only pattern as tracing (see
|
|
6
|
+
* setupTracer() in instrument.js): instruments come from
|
|
7
|
+
* metrics.getMeter(), which resolves to whatever MeterProvider the host
|
|
8
|
+
* application has already registered globally, or the API's built-in
|
|
9
|
+
* no-op implementation if none has. This module never creates or
|
|
10
|
+
* registers a MeterProvider itself — recording through it is a
|
|
11
|
+
* zero-overhead no-op until the host does. See config.js's enableMetrics
|
|
12
|
+
* option for the opt-out, and the README's "Metrics" section for how to
|
|
13
|
+
* wire up a real MeterProvider.
|
|
14
|
+
*
|
|
15
|
+
* Unlike @opentelemetry/api's tracing API, its metrics API (as of
|
|
16
|
+
* @opentelemetry/api ^1.9) does not proxy a not-yet-registered
|
|
17
|
+
* MeterProvider — metrics.getMeter() resolves the current global
|
|
18
|
+
* synchronously at call time rather than lazily delegating later. So, as
|
|
19
|
+
* with setupTracer(), setupMeter() must run after the host application has
|
|
20
|
+
* registered its MeterProvider (the same ordering the README already
|
|
21
|
+
* documents for tracing).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { metrics } from '@opentelemetry/api';
|
|
25
|
+
import {
|
|
26
|
+
ATTR_MCP_METHOD_NAME,
|
|
27
|
+
ATTR_GEN_AI_TOOL_NAME,
|
|
28
|
+
ATTR_ERROR_TYPE,
|
|
29
|
+
ATTR_MCP_TOOL_OUTCOME,
|
|
30
|
+
MCP_METHOD_NAME_TOOLS_CALL,
|
|
31
|
+
} from './attributes.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates the mcp.tool.* instruments and returns small record*() wrappers
|
|
35
|
+
* around them, keyed to the well-known attribute names above so call
|
|
36
|
+
* sites in instrument.js never construct attribute bags by hand.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} packageVersion
|
|
39
|
+
* @returns {{
|
|
40
|
+
* recordCall: (toolName: string | undefined) => void,
|
|
41
|
+
* recordError: (toolName: string | undefined, errorType: string) => void,
|
|
42
|
+
* recordSilentFailure: (toolName: string | undefined) => void,
|
|
43
|
+
* recordDuration: (toolName: string | undefined, durationMs: number, outcome: string) => void,
|
|
44
|
+
* }}
|
|
45
|
+
*/
|
|
46
|
+
export function setupMeter(packageVersion) {
|
|
47
|
+
const meter = metrics.getMeter('opentel-mcp', packageVersion);
|
|
48
|
+
|
|
49
|
+
const calls = meter.createCounter('mcp.tool.calls', {
|
|
50
|
+
description: 'Number of MCP tool calls received, regardless of outcome.',
|
|
51
|
+
});
|
|
52
|
+
const errors = meter.createCounter('mcp.tool.errors', {
|
|
53
|
+
description: 'Number of MCP tool calls whose handler threw or rejected (protocol-level failure).',
|
|
54
|
+
});
|
|
55
|
+
const silentFailures = meter.createCounter('mcp.tool.silent_failures', {
|
|
56
|
+
description:
|
|
57
|
+
'Number of MCP tool calls whose JSON-RPC response succeeded but whose CallToolResult had isError: true.',
|
|
58
|
+
});
|
|
59
|
+
const duration = meter.createHistogram('mcp.tool.duration', {
|
|
60
|
+
description: 'Duration of MCP tool call execution.',
|
|
61
|
+
unit: 'ms',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
recordCall(toolName) {
|
|
66
|
+
calls.add(1, {
|
|
67
|
+
[ATTR_GEN_AI_TOOL_NAME]: toolName,
|
|
68
|
+
[ATTR_MCP_METHOD_NAME]: MCP_METHOD_NAME_TOOLS_CALL,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
recordError(toolName, errorType) {
|
|
72
|
+
errors.add(1, {
|
|
73
|
+
[ATTR_GEN_AI_TOOL_NAME]: toolName,
|
|
74
|
+
[ATTR_ERROR_TYPE]: errorType,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
recordSilentFailure(toolName) {
|
|
78
|
+
silentFailures.add(1, { [ATTR_GEN_AI_TOOL_NAME]: toolName });
|
|
79
|
+
},
|
|
80
|
+
recordDuration(toolName, durationMs, outcome) {
|
|
81
|
+
duration.record(durationMs, {
|
|
82
|
+
[ATTR_GEN_AI_TOOL_NAME]: toolName,
|
|
83
|
+
[ATTR_MCP_TOOL_OUTCOME]: outcome,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|