opentel-mcp 0.3.0 → 0.4.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/README.md +265 -183
- package/package.json +4 -1
- package/src/config.js +8 -0
- package/src/fingerprint/attributes.js +56 -0
- package/src/fingerprint/classify/auth.js +29 -0
- package/src/fingerprint/classify/dependency.js +32 -0
- package/src/fingerprint/classify/index.js +59 -0
- package/src/fingerprint/classify/internal.js +17 -0
- package/src/fingerprint/classify/network.js +35 -0
- package/src/fingerprint/classify/serialization.js +27 -0
- package/src/fingerprint/classify/timeout.js +29 -0
- package/src/fingerprint/classify/validation.js +29 -0
- package/src/fingerprint/compose.js +149 -0
- package/src/fingerprint/hash.js +39 -0
- package/src/fingerprint/normalize/message.js +30 -0
- package/src/fingerprint/normalize/patterns.js +108 -0
- package/src/fingerprint/normalize/stack.js +165 -0
- package/src/fingerprint/types.d.ts +93 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +4 -0
- package/src/instrument.js +36 -6
- package/src/metrics.js +28 -7
package/README.md
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
# opentel-mcp
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
5
|
-
> long they take, and which ones fail — via standard OTel traces and
|
|
6
|
-
> metrics.
|
|
3
|
+
> Turn every MCP tool call into an OpenTelemetry trace — including the
|
|
4
|
+
> failures your logs won't show you.
|
|
7
5
|
|
|
8
6
|
[](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
|
|
9
7
|
[](https://www.npmjs.com/package/opentel-mcp)
|
|
8
|
+
[](https://www.npmjs.com/package/opentel-mcp)
|
|
10
9
|
[](https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/LICENSE)
|
|
11
10
|
|
|
12
|
-
|
|
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) traces — the standard most
|
|
14
|
+
dashboards already read. One function call; no changes to your tools'
|
|
15
|
+
code.
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
## The problem
|
|
18
|
+
|
|
19
|
+
Your AI agent calls 15 MCP tools across 3 servers this turn. One tool
|
|
20
|
+
returns `{ isError: true }` inside an otherwise-successful response — how
|
|
21
|
+
a tool reports "I couldn't do that" without crashing. Your logs show
|
|
22
|
+
success. Your metrics show success. The agent gives a wrong answer, and
|
|
23
|
+
nothing you're monitoring says why.
|
|
24
|
+
|
|
25
|
+
opentel-mcp makes that failure visible: one span per tool call, marked as
|
|
26
|
+
an error when it actually is one, using the same standard your dashboards
|
|
27
|
+
already speak.
|
|
17
28
|
|
|
18
29
|
## Install
|
|
19
30
|
|
|
@@ -21,141 +32,121 @@ attribute changes will land in minor versions until `1.0`.
|
|
|
21
32
|
npm install opentel-mcp @opentelemetry/api
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
|
|
25
|
-
its package.json, or you must use `.mjs` file extensions.
|
|
26
|
-
|
|
27
|
-
## Why
|
|
35
|
+
opentel-mcp is an ES module — add `"type": "module"` to package.json.
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
visibility into which was slow, which errored silently, which sequence
|
|
31
|
-
ran. opentel-mcp wraps any MCP server and emits one OTel span per tool
|
|
32
|
-
invocation with rich attributes, using standard OpenTelemetry APIs so
|
|
33
|
-
it plugs into your existing observability stack (Jaeger, Grafana Tempo,
|
|
34
|
-
Honeycomb, Datadog, whatever).
|
|
37
|
+
## 30-second quickstart
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
```js
|
|
40
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
41
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
42
|
+
import { instrumentMcpServer } from 'opentel-mcp';
|
|
43
|
+
import { z } from 'zod';
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
seconds, you have no idea which tool caused it. opentel-mcp turns every
|
|
40
|
-
tool call into an OpenTelemetry span you can read.
|
|
45
|
+
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
// Wraps every tool registered below. Must run BEFORE server.tool() —
|
|
48
|
+
// see "Ordering constraint" below for why.
|
|
49
|
+
instrumentMcpServer(server, {
|
|
50
|
+
serviceName: 'my-mcp-server', // shows up on your traces
|
|
51
|
+
setupNodeSdk: true, // dev mode: prints traces to your terminal
|
|
52
|
+
});
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
// A normal tool, registered exactly as usual.
|
|
55
|
+
server.tool('echo', { text: z.string() }, async ({ text }) => ({
|
|
56
|
+
content: [{ type: 'text', text: `you said: ${text}` }],
|
|
57
|
+
}));
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
├─ tools/call search_documents ........... 46ms OK
|
|
50
|
-
├─ tools/call fetch_weather ............. 601ms OK
|
|
51
|
-
└─ tools/call generate_advice .......... 1802ms OK ← the bottleneck
|
|
59
|
+
const transport = new StdioServerTransport();
|
|
60
|
+
await server.connect(transport);
|
|
52
61
|
```
|
|
53
62
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
`search_documents` and `fetch_weather` are cheap by comparison. That's
|
|
57
|
-
the difference between guessing and knowing.
|
|
63
|
+
That's it. Every tool call now emits a trace. Wire an exporter to see them
|
|
64
|
+
(next section).
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
## See it working
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
Run the snippet above and this prints to your terminal — a real, captured
|
|
69
|
+
run (full dump: `examples/hello-mcpserver/README.md`):
|
|
68
70
|
|
|
69
71
|
```
|
|
70
|
-
tools/call
|
|
71
|
-
|
|
72
|
+
name: 'tools/call echo'
|
|
73
|
+
kind: 1 // SpanKind.SERVER
|
|
74
|
+
status: { code: 1 } // OK
|
|
75
|
+
attributes: {
|
|
76
|
+
'mcp.method.name': 'tools/call',
|
|
77
|
+
'gen_ai.tool.name': 'echo',
|
|
78
|
+
'mcp.tool.argument_count': 1,
|
|
79
|
+
'jsonrpc.request.id': '1'
|
|
80
|
+
}
|
|
72
81
|
```
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
reflects reality instead of reading as a flat 100% success.
|
|
83
|
+
No dashboard needed — `setupNodeSdk: true`'s dev exporter printed this
|
|
84
|
+
directly. Point it at a real backend later; see "Two modes" below.
|
|
77
85
|
|
|
78
|
-
|
|
86
|
+
---
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
The rest of this README goes deeper: both server APIs, every attribute
|
|
89
|
+
and metric emitted, how failure grouping works, configuration, and the
|
|
90
|
+
non-obvious design decisions behind each.
|
|
83
91
|
|
|
84
|
-
|
|
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` |
|
|
92
|
+
## Both server APIs
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
MCP servers are built on one of two classes from `@modelcontextprotocol/sdk`;
|
|
95
|
+
opentel-mcp detects and wraps either one the same way (see ADR 001 in
|
|
96
|
+
`docs/adr/` for how).
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
**`McpServer`** — the high-level API most servers are actually built on.
|
|
99
|
+
Use it unless you have a specific reason not to; this is what the
|
|
100
|
+
quickstart above uses.
|
|
96
101
|
|
|
97
|
-
|
|
102
|
+
**`Server`** — the low-level API, for when you're handling raw JSON-RPC
|
|
103
|
+
yourself or building a library on top of MCP:
|
|
98
104
|
|
|
99
105
|
```js
|
|
100
106
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
107
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
108
|
+
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
101
109
|
import { instrumentMcpServer } from 'opentel-mcp';
|
|
102
110
|
|
|
103
|
-
const server = new Server({ name: 'my-server', version: '1.0.0' }, {
|
|
104
|
-
capabilities: { tools: {} }
|
|
105
|
-
});
|
|
111
|
+
const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
// Must run before setRequestHandler(CallToolRequestSchema, ...) below.
|
|
114
|
+
instrumentMcpServer(server, { serviceName: 'my-mcp-server', setupNodeSdk: true });
|
|
115
|
+
|
|
116
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
117
|
+
const { text } = request.params.arguments ?? {};
|
|
118
|
+
return { content: [{ type: 'text', text: `you said: ${text}` }] };
|
|
111
119
|
});
|
|
112
120
|
|
|
113
|
-
|
|
114
|
-
server.
|
|
121
|
+
const transport = new StdioServerTransport();
|
|
122
|
+
await server.connect(transport);
|
|
115
123
|
```
|
|
116
124
|
|
|
117
|
-
|
|
125
|
+
Runnable versions of both live in `examples/hello-server/` and
|
|
126
|
+
`examples/hello-mcpserver/`.
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
121
|
-
import { instrumentMcpServer } from 'opentel-mcp';
|
|
128
|
+
## What gets emitted
|
|
122
129
|
|
|
123
|
-
|
|
130
|
+
### Tool-level failures, specifically
|
|
124
131
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
An MCP tool can fail two ways: it can throw, or it can return
|
|
133
|
+
`isError: true` on an otherwise-successful response (the case from "The
|
|
134
|
+
problem" above). opentel-mcp treats both the same way — span marked
|
|
135
|
+
`ERROR`, nothing thrown, the result returned to the caller unchanged:
|
|
129
136
|
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
```
|
|
138
|
+
tools/call fetch_weather ................. 605ms ERROR
|
|
139
|
+
error.type = tool_error
|
|
132
140
|
```
|
|
133
141
|
|
|
134
|
-
|
|
135
|
-
registered — see "Ordering constraint" below.
|
|
136
|
-
|
|
137
|
-
## Semantic conventions
|
|
138
|
-
|
|
139
|
-
opentel-mcp follows the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
|
|
140
|
-
published by the OTel GenAI SIG (they moved there from the main
|
|
141
|
-
`semantic-conventions` repo, where the MCP conventions are now marked
|
|
142
|
-
deprecated). **That spec's status is Development, not Stable** — attribute
|
|
143
|
-
names and requirement levels may still change upstream, and this package
|
|
144
|
-
will follow suit when they do. See ADR 004 in `docs/adr/` for the full
|
|
145
|
-
reasoning.
|
|
146
|
-
|
|
147
|
-
One attribute, `mcp.tool.argument_count`, is **not** part of the spec — it's
|
|
148
|
-
our own addition, documented as such in `src/attributes.js`. It's a
|
|
149
|
-
privacy-preserving alternative to the spec's opt-in
|
|
150
|
-
`gen_ai.tool.call.arguments`: it gives shape/anomaly signal (argument count
|
|
151
|
-
changed) without capturing any argument values.
|
|
142
|
+
Verified in `test/instrument.test.js`'s "tool-level failure" tests.
|
|
152
143
|
|
|
153
|
-
|
|
144
|
+
### Span attributes
|
|
154
145
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
`
|
|
146
|
+
Every span follows the OpenTelemetry MCP semantic conventions (see
|
|
147
|
+
"Semantic conventions" below), name `{mcp.method.name} {tool name}`
|
|
148
|
+
(e.g. `tools/call echo`), kind `SERVER`, status `ERROR` whenever
|
|
149
|
+
`error.type` is set.
|
|
159
150
|
|
|
160
151
|
| Attribute | Requirement Level | Description | Example |
|
|
161
152
|
|---|---|---|---|
|
|
@@ -167,46 +158,33 @@ back to just `mcp.method.name` when no tool name is available), kind
|
|
|
167
158
|
| mcp.tool.argument_count | **Custom — not spec** | Number of arguments (values not captured) | 2 |
|
|
168
159
|
|
|
169
160
|
Span status description carries the error message on failure (thrown
|
|
170
|
-
errors); no separate error-message attribute
|
|
171
|
-
|
|
161
|
+
errors); there's no separate error-message attribute — the spec expresses
|
|
162
|
+
success/failure through span status, not an attribute. Source of truth:
|
|
163
|
+
`src/attributes.js`.
|
|
172
164
|
|
|
173
|
-
|
|
165
|
+
### Metrics
|
|
174
166
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
`
|
|
180
|
-
registered; tracing is unaffected either way.
|
|
167
|
+
Four `mcp.tool.*` metrics via `@opentelemetry/api`'s Metrics API — same
|
|
168
|
+
API-only pattern as tracing (see "Two modes" below): nothing is recorded
|
|
169
|
+
until a `MeterProvider` is registered. Set `enableMetrics: false` to opt
|
|
170
|
+
out even when one is; tracing is unaffected either way. Source of truth:
|
|
171
|
+
`src/metrics.js`.
|
|
181
172
|
|
|
182
|
-
|
|
|
173
|
+
| Metric | Type | Unit | Attributes | Emitted when |
|
|
183
174
|
|---|---|---|---|---|
|
|
184
175
|
| `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` |
|
|
186
|
-
| `mcp.tool.silent_failures` | Counter | — | `gen_ai.tool.name` |
|
|
187
|
-
| `mcp.tool.duration` | Histogram |
|
|
188
|
-
|
|
189
|
-
`mcp.
|
|
190
|
-
|
|
191
|
-
`
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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:
|
|
176
|
+
| `mcp.tool.errors` | Counter | — | `gen_ai.tool.name`, `error.type`[^1] | Handler threw or rejected |
|
|
177
|
+
| `mcp.tool.silent_failures` | Counter | — | `gen_ai.tool.name`[^1] | Result had `isError: true` |
|
|
178
|
+
| `mcp.tool.duration` | Histogram | ms | `gen_ai.tool.name`, `mcp.tool.outcome`[^1] | Every call, completion |
|
|
179
|
+
|
|
180
|
+
[^1]: Also carries `mcp.failure.category` when fingerprinting finds one — see "Failure Fingerprinting" below.
|
|
181
|
+
|
|
182
|
+
`mcp.tool.silent_failures` increments from the exact same check that marks
|
|
183
|
+
the span `ERROR` (`isToolResultError()` in `src/instrument.js`) — the
|
|
184
|
+
detection logic isn't duplicated between traces and metrics.
|
|
185
|
+
|
|
186
|
+
Wiring a real `MeterProvider`/`TracerProvider` — a worked example against
|
|
187
|
+
SigNoz's local OTLP endpoint:
|
|
210
188
|
|
|
211
189
|
```js
|
|
212
190
|
import { metrics, trace } from '@opentelemetry/api';
|
|
@@ -236,51 +214,147 @@ const tracerProvider = new NodeTracerProvider({
|
|
|
236
214
|
});
|
|
237
215
|
tracerProvider.register();
|
|
238
216
|
|
|
239
|
-
const server = new Server({ name: 'my-server', version: '1.0.0' }, {
|
|
240
|
-
capabilities: { tools: {} },
|
|
241
|
-
});
|
|
217
|
+
const server = new Server({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
242
218
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// NodeTracerProvider above — registered globally via
|
|
247
|
-
// tracerProvider.register(), which is what trace.getTracer() picks up —
|
|
248
|
-
// for traces.
|
|
249
|
-
});
|
|
219
|
+
// setupNodeSdk: false (default) — both providers above are already
|
|
220
|
+
// registered globally, so instrumentMcpServer() picks them up as-is.
|
|
221
|
+
instrumentMcpServer(server, {});
|
|
250
222
|
```
|
|
251
223
|
|
|
252
|
-
`http://localhost:4318` is SigNoz's default local OTLP/HTTP
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
`@opentelemetry/exporter-metrics-otlp-http` are
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
that
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
224
|
+
`http://localhost:4318` is SigNoz's default local OTLP/HTTP (OpenTelemetry
|
|
225
|
+
Protocol — the wire format traces/metrics travel over) endpoint; point it
|
|
226
|
+
at your own collector in production. `@opentelemetry/sdk-metrics` and
|
|
227
|
+
`@opentelemetry/exporter-metrics-otlp-http` are host-app dependencies —
|
|
228
|
+
opentel-mcp doesn't bundle them (see `package.json`'s `peerDependencies`).
|
|
229
|
+
`@opentelemetry/sdk-trace-node` and `@opentelemetry/exporter-trace-otlp-http`
|
|
230
|
+
are already runtime dependencies of opentel-mcp itself (its `setupNodeSdk:
|
|
231
|
+
true` dev path uses them), so no extra install is needed for those two.
|
|
232
|
+
|
|
233
|
+
## Failure Fingerprinting (v0.4.0+)
|
|
234
|
+
|
|
235
|
+
Groups logically identical failures under one stable identifier, even
|
|
236
|
+
when the error message contains UUIDs, timestamps, or user IDs. Ten
|
|
237
|
+
calls that fail the same way but each mention a different user ID show
|
|
238
|
+
up as **one** issue, not ten.
|
|
239
|
+
|
|
240
|
+
Runs locally and synchronously over the error object already in hand —
|
|
241
|
+
no network call, no third-party service. Every thrown error and every
|
|
242
|
+
`isError: true` result gets one, automatically; disable with
|
|
243
|
+
`{ fingerprinting: false }` (default: enabled). Full algorithm: ADR 006
|
|
244
|
+
in `docs/adr/`.
|
|
245
|
+
|
|
246
|
+
| Attribute | Description | Example |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| mcp.failure.fingerprint | Stable 16-hex-char identity for the failure | "a3f4c8e2b1d09f77" |
|
|
249
|
+
| mcp.failure.signature | Human-readable `errorClass@fn:line`, ≤60 chars | "TypeError@doThing:42" |
|
|
250
|
+
| mcp.failure.category | One of 8 categories (below) | "timeout" |
|
|
251
|
+
| mcp.failure.origin | `tool_error` \| `thrown` \| `transport` | "thrown" |
|
|
252
|
+
| mcp.failure.error_class | Error class / constructor name | "TypeError" |
|
|
253
|
+
|
|
254
|
+
Source of truth: `src/fingerprint/attributes.js`. Every category:
|
|
255
|
+
|
|
256
|
+
- `validation` — bad input (Zod/Joi/Yup errors, "invalid"/"required" wording)
|
|
257
|
+
- `timeout` — an operation timed out (`TimeoutError`, `ETIMEDOUT`, ...)
|
|
258
|
+
- `network` — a connection failed (`ECONNREFUSED`, `FetchError`, ...)
|
|
259
|
+
- `auth` — 401/403, "unauthorized"/"forbidden" wording
|
|
260
|
+
- `dependency` — a downstream service or package failed (Mongo, Postgres, ...)
|
|
261
|
+
- `serialization` — malformed JSON, "unexpected token" wording
|
|
262
|
+
- `internal` — nothing more specific matched (the catch-all)
|
|
263
|
+
- `unknown` — fingerprinting itself hit an internal error (should not normally happen)
|
|
264
|
+
|
|
265
|
+
Full classifier source: `src/fingerprint/classify/`.
|
|
266
|
+
|
|
267
|
+
**Cardinality:** the fingerprint itself is unbounded — a new bug means a
|
|
268
|
+
new fingerprint, forever. That's fine on span attributes (each span is
|
|
269
|
+
its own record), but it must **never** go on a metric label, or every
|
|
270
|
+
distinct failure becomes its own permanent time series. opentel-mcp
|
|
271
|
+
enforces this structurally, not by convention: `src/metrics.js` can only
|
|
272
|
+
reach a fingerprint-derived value through
|
|
273
|
+
`METRIC_SAFE_ATTRIBUTES` — a frozen list containing only `category` and
|
|
274
|
+
`origin` (24 combinations max). There is no code path today that could
|
|
275
|
+
accidentally attach `fingerprint`, `signature`, or `error_class` to a
|
|
276
|
+
counter or histogram label. See `src/fingerprint/attributes.js` and ADR
|
|
277
|
+
006's "Consequences" section.
|
|
278
|
+
|
|
279
|
+
**Extending it:** `computeFingerprint(err, ctx, opts)`
|
|
280
|
+
(`src/fingerprint/compose.js`) accepts `opts.classifiers` to prepend your
|
|
281
|
+
own detection rules ahead of the built-in eight, and `opts.stackFrames` to
|
|
282
|
+
change how many stack frames feed the signature — see
|
|
283
|
+
`test/fingerprint/compose.test.js`'s "uses a custom classifiers list" and
|
|
284
|
+
"respects a custom opts.stackFrames count" tests, and
|
|
285
|
+
`examples/fingerprint-demo.js` for a runnable, standalone demo (`node
|
|
286
|
+
examples/fingerprint-demo.js`). Not yet wired through
|
|
287
|
+
`instrumentMcpServer()`'s own options — today this means importing
|
|
288
|
+
`computeFingerprint` directly rather than configuring the automatic
|
|
289
|
+
per-call-site wrapping; tracked in the roadmap below.
|
|
290
|
+
|
|
291
|
+
## Configuration
|
|
292
|
+
|
|
293
|
+
All options passed to `instrumentMcpServer(server, options)`. Source of
|
|
294
|
+
truth: `src/config.js`.
|
|
295
|
+
|
|
296
|
+
| Option | Type | Default | Description |
|
|
297
|
+
|---|---|---|---|
|
|
298
|
+
| `serviceName` | string | — | Resource name for traces[^2] |
|
|
299
|
+
| `setupNodeSdk` | boolean | `false` | Dev mode: stderr tracer, no setup[^3] |
|
|
300
|
+
| `exporterUrl` | string | — | OTLP/HTTP traces endpoint[^4] |
|
|
301
|
+
| `enabled` | boolean | `true` | `false` disables all instrumentation |
|
|
302
|
+
| `enableMetrics` | boolean | `true` | `false` disables `mcp.tool.*` metrics only |
|
|
303
|
+
| `fingerprinting` | boolean | `true` | `false` disables `mcp.failure.*` attributes |
|
|
269
304
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
existing OTel setup without conflict. In this mode the host's
|
|
274
|
-
TracerProvider owns the resource, so `serviceName` is not needed and has
|
|
275
|
-
no effect — set `service.name` on the host's Resource instead. Passing
|
|
276
|
-
`serviceName` anyway is harmless but logs a one-time `diag.warn`.
|
|
305
|
+
[^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
|
+
[^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
|
+
[^4]: Only takes effect when `setupNodeSdk` is `true`.
|
|
277
308
|
|
|
278
309
|
## Ordering constraint
|
|
279
310
|
|
|
280
|
-
|
|
311
|
+
Instrumentation works by wrapping the tool-call handler at the moment
|
|
312
|
+
it's registered. If a handler is registered before `instrumentMcpServer()`
|
|
313
|
+
runs, that handler was never wrapped — it slipped past the trap before it
|
|
314
|
+
was set.
|
|
315
|
+
|
|
316
|
+
Call `instrumentMcpServer()` **before** registering any tool handlers —
|
|
281
317
|
before `server.setRequestHandler(CallToolRequestSchema, ...)` (low-level
|
|
282
318
|
`Server`) or before any `.tool()`/`.registerTool()` call (`McpServer`).
|
|
283
|
-
See ADR 002 in docs/adr
|
|
319
|
+
See ADR 002 in `docs/adr/` for the detection logic that catches violations
|
|
320
|
+
of this at instrument time.
|
|
321
|
+
|
|
322
|
+
## Two modes
|
|
323
|
+
|
|
324
|
+
### Quick dev setup
|
|
325
|
+
|
|
326
|
+
`setupNodeSdk: true` sets up a `NodeTracerProvider` that prints spans to
|
|
327
|
+
stderr (safe alongside stdio-transport MCP servers — see ADR 003),
|
|
328
|
+
optionally plus an OTLP exporter if `exporterUrl` is provided. No separate
|
|
329
|
+
OTel SDK setup needed — `serviceName` is required in this mode, since it
|
|
330
|
+
names the resource of the provider opentel-mcp creates.
|
|
331
|
+
|
|
332
|
+
### Production setup
|
|
333
|
+
|
|
334
|
+
Omit `setupNodeSdk` (default `false`). opentel-mcp uses whatever
|
|
335
|
+
`TracerProvider` is already registered via
|
|
336
|
+
`trace.setGlobalTracerProvider()`, so it plugs into any existing OTel
|
|
337
|
+
setup without conflict. The host's `TracerProvider` owns the resource
|
|
338
|
+
here, so `serviceName` is not needed and has no effect — set
|
|
339
|
+
`service.name` on the host's `Resource` instead. Passing `serviceName`
|
|
340
|
+
anyway is harmless but logs a one-time `diag.warn`.
|
|
341
|
+
|
|
342
|
+
## Semantic conventions
|
|
343
|
+
|
|
344
|
+
`0.x` — the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
|
|
345
|
+
this library implements are Development-stage, not Stable, and may still
|
|
346
|
+
change upstream; breaking attribute renames will land in minor versions
|
|
347
|
+
until `1.0`, tracked in release notes rather than silently shipped.
|
|
348
|
+
|
|
349
|
+
opentel-mcp follows those conventions (published by the OTel GenAI SIG,
|
|
350
|
+
moved there from the main `semantic-conventions` repo, where the MCP
|
|
351
|
+
conventions are now deprecated) for everything they define, and adds two
|
|
352
|
+
namespaces of its own where they don't yet: `mcp.tool.*` (call-count and
|
|
353
|
+
duration metrics) and `mcp.failure.*` (failure fingerprinting). Both are
|
|
354
|
+
documented as non-spec at every attribute (`src/attributes.js`,
|
|
355
|
+
`src/fingerprint/attributes.js`), and are candidates to fold into the
|
|
356
|
+
spec's own metrics/error vocabulary if it grows an equivalent. Full
|
|
357
|
+
reasoning: ADR 004 in `docs/adr/`.
|
|
284
358
|
|
|
285
359
|
## Compatibility
|
|
286
360
|
|
|
@@ -290,16 +364,24 @@ See ADR 002 in docs/adr/ for why.
|
|
|
290
364
|
- Supports both low-level `Server` and high-level `McpServer` APIs
|
|
291
365
|
- @modelcontextprotocol/sdk ^1.0.0
|
|
292
366
|
- @opentelemetry/api ^1.9.0
|
|
367
|
+
- 127 tests (`npm test`) — see `test/`
|
|
293
368
|
|
|
294
369
|
## Roadmap
|
|
295
370
|
|
|
296
|
-
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
371
|
+
- v0.4: Deep Failure Fingerprinting ✓ — see "Failure Fingerprinting" above
|
|
372
|
+
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
|
|
377
|
+
- Also still tracked, not silently dropped: exposing `computeFingerprint`'s
|
|
378
|
+
`classifiers`/`stackFrames` options through `instrumentMcpServer()`
|
|
379
|
+
itself; opt-in `gen_ai.tool.call.arguments` support with a redaction
|
|
380
|
+
callback; the spec's own `mcp.server.operation.duration` /
|
|
381
|
+
`mcp.server.session.duration` metrics; W3C trace context propagation via
|
|
382
|
+
`params._meta` per
|
|
383
|
+
[SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta);
|
|
384
|
+
and client-side instrumentation, so a single trace can span the client
|
|
303
385
|
call and the server's tool execution
|
|
304
386
|
|
|
305
387
|
## Contributing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentel-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "vitest run",
|
|
25
25
|
"test:watch": "vitest",
|
|
26
|
+
"test:coverage": "vitest run --coverage",
|
|
27
|
+
"bench": "vitest bench --run",
|
|
26
28
|
"prepack": "node scripts/strip-workspaces.js",
|
|
27
29
|
"postpack": "node scripts/restore-workspaces.js"
|
|
28
30
|
},
|
|
@@ -59,6 +61,7 @@
|
|
|
59
61
|
"@opentelemetry/api": "^1.9.0",
|
|
60
62
|
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
61
63
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
64
|
+
"@vitest/coverage-v8": "^2.1.9",
|
|
62
65
|
"typescript": "^7.0.2",
|
|
63
66
|
"vitest": "^2.1.8"
|
|
64
67
|
}
|
package/src/config.js
CHANGED
|
@@ -26,6 +26,13 @@ import { diag } from '@opentelemetry/api';
|
|
|
26
26
|
* whatever OpenTelemetry TracerProvider the host application
|
|
27
27
|
* has already registered globally — or dropped silently if none has been registered. This default keeps
|
|
28
28
|
* instrumentMcpServer() from ever overriding a host application's own OpenTelemetry setup.
|
|
29
|
+
* @property {boolean} [fingerprinting=true] - Set to false to disable deep-failure fingerprinting. When enabled
|
|
30
|
+
* (the default), every thrown error and tool-level failure (isError: true) is run through
|
|
31
|
+
* src/fingerprint/compose.js's computeFingerprint(), adding mcp.failure.* span attributes and an
|
|
32
|
+
* mcp.failure.category attribute on the mcp.tool.errors / mcp.tool.silent_failures / mcp.tool.duration
|
|
33
|
+
* metrics (see src/fingerprint/attributes.js). computeFingerprint() never throws, so this only trades a
|
|
34
|
+
* small amount of per-failure CPU (see the p99 < 200µs budget in test/fingerprint/benchmark.test.js) for
|
|
35
|
+
* fingerprinting.
|
|
29
36
|
*/
|
|
30
37
|
|
|
31
38
|
// Guards the "serviceName has no effect" diagnostic below so it fires once
|
|
@@ -72,5 +79,6 @@ export function resolveOptions(options) {
|
|
|
72
79
|
enabled: opts.enabled ?? true,
|
|
73
80
|
enableMetrics: opts.enableMetrics ?? true,
|
|
74
81
|
setupNodeSdk,
|
|
82
|
+
fingerprinting: opts.fingerprinting ?? true,
|
|
75
83
|
};
|
|
76
84
|
}
|