opentel-mcp 0.15.0 → 0.16.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 +89 -0
- package/README.md +98 -20
- package/package.json +3 -2
- package/src/attributes.js +23 -0
- package/src/config.js +75 -0
- package/src/flush-on-exit.js +215 -0
- package/src/index.d.ts +63 -0
- package/src/instrument.js +245 -3
- package/src/metrics.js +15 -0
- package/src/sdk/detect.js +9 -2
- package/src/unactionable.js +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
Finishes what 0.15 started. Telemetry now survives the way MCP clients
|
|
6
|
+
actually stop servers (`flushOnExit`), and two new signals land: errors
|
|
7
|
+
your agent can't act on, and opt-in resources/prompts coverage. Plus a fix
|
|
8
|
+
for SDK v2 servers that declare `capabilities.tools`.
|
|
9
|
+
`setupNodeSdk: false` (production) behavior is unchanged apart from the
|
|
10
|
+
new span attributes on `isError: true` results and the v2 fix; resources/
|
|
11
|
+
prompts coverage is off unless you opt in. No new metric label keys.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`flushOnExit` (ADR 024, `docs/adr/024-flush-on-exit.md`).** Under
|
|
16
|
+
`setupNodeSdk: true`, buffered spans and dev-mode metrics are now
|
|
17
|
+
flushed automatically when the process stops: on `beforeExit` (stdin
|
|
18
|
+
closed, loop drained), `SIGTERM` and `SIGINT`. MCP clients built on the
|
|
19
|
+
SDK's stdio transport stop servers with exactly that sequence (stdin
|
|
20
|
+
close → SIGTERM → SIGKILL, 2 s apart); the old `process.on('beforeExit')`
|
|
21
|
+
snippet only covered the first step. On by default with
|
|
22
|
+
`setupNodeSdk: true`; `flushOnExit: false` opts out;
|
|
23
|
+
`flushOnExit: { timeoutMs }` lowers the 1000 ms cap.
|
|
24
|
+
- One listener set per process, however many servers are instrumented,
|
|
25
|
+
and it's removed again once every server has shut down.
|
|
26
|
+
- After flushing, a signal is re-raised when no other listener for it
|
|
27
|
+
exists, so the process still dies by that signal. If your code has its
|
|
28
|
+
own listener, yours decides. `beforeExit` never changes the exit code.
|
|
29
|
+
- **No effect at all with `setupNodeSdk: false`** (production
|
|
30
|
+
behavior unchanged): passing it there logs a one-time `diag.warn`.
|
|
31
|
+
- Not covered: `process.exit()` (call `await server.shutdown()` first)
|
|
32
|
+
and `SIGKILL`.
|
|
33
|
+
|
|
34
|
+
- **Unactionable tool errors (ADR 025, `docs/adr/025-unactionable-errors.md`).**
|
|
35
|
+
Every `isError: true` span now carries `mcp.failure.unactionable`
|
|
36
|
+
(boolean) and `mcp.failure.content_length_bucket` (`empty` | `tiny` |
|
|
37
|
+
`short` | `medium` | `long`). Unactionable means no content, or under
|
|
38
|
+
10 characters of trimmed text with no image/audio/resource item: an
|
|
39
|
+
error that gives the agent nothing to act on. Computed only from text
|
|
40
|
+
lengths and non-text item counts, never from the text; a canary-string
|
|
41
|
+
test checks every exported span and metric. Span-only (no metric label,
|
|
42
|
+
`METRIC_SAFE_ATTRIBUTES` unchanged); fingerprints and categories
|
|
43
|
+
unchanged. Configure with `unactionableErrors: { enabled, minTextLength }`
|
|
44
|
+
or `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` /
|
|
45
|
+
`OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH`.
|
|
46
|
+
|
|
47
|
+
- **Resources and prompts coverage, opt-in (ADR 026,
|
|
48
|
+
`docs/adr/026-resources-prompts-coverage.md`).**
|
|
49
|
+
`coverage: { resources: true, prompts: true }` also traces
|
|
50
|
+
`resources/read`, `resources/list`, `resources/templates/list`,
|
|
51
|
+
`prompts/get` and `prompts/list`, on SDK v1 and v2, low-level `Server`
|
|
52
|
+
and `McpServer`. Both are off by default; nothing changes unless you opt in.
|
|
53
|
+
- Spans are named by method (`prompts/get <name>` for prompts/get) and
|
|
54
|
+
carry `mcp.method.name` (+ `gen_ai.prompt.name` on prompts/get, capped
|
|
55
|
+
at 128 characters). `gen_ai.tool.name` is absent.
|
|
56
|
+
- Never captured: resource URIs, prompt arguments, result content, or
|
|
57
|
+
the exception message of a failing request (SDK resource errors embed
|
|
58
|
+
the URI). Failures get status `ERROR`, `error.type` and the usual
|
|
59
|
+
fingerprint/category/channel.
|
|
60
|
+
- New histogram `mcp.server.operation.duration`, labeled by
|
|
61
|
+
`mcp.method.name` + `error.type` only. No new label keys;
|
|
62
|
+
`mcp.method.name` stays a fixed set of constants (now 7 values).
|
|
63
|
+
- A handler already registered when `instrumentMcpServer()` runs is
|
|
64
|
+
skipped with one `diag.warn`, never a throw. That includes SDK v2
|
|
65
|
+
`McpServer` declaring `capabilities.resources/prompts` in its
|
|
66
|
+
constructor.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- `server.shutdown()` (under `setupNodeSdk: true`) is now idempotent:
|
|
71
|
+
every call returns the same promise, so an explicit `shutdown()` and
|
|
72
|
+
`flushOnExit` never shut the providers down twice.
|
|
73
|
+
- The README quickstart and both examples no longer need the
|
|
74
|
+
`process.on('beforeExit', ...)` block; it's been removed.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **SDK v2: a `McpServer` constructed with `capabilities: { tools: {} }`
|
|
79
|
+
can now be instrumented.** v2's `McpServer` installs its own
|
|
80
|
+
`tools/list`/`tools/call` dispatchers in its constructor when that
|
|
81
|
+
capability is declared, so `instrumentMcpServer()` threw
|
|
82
|
+
`INSTRUMENT_FIRST_ERROR` on every such server, whatever the call order.
|
|
83
|
+
It now recognizes McpServer's own constructor-installed handlers (v2
|
|
84
|
+
McpServer, installer ran, no tool registered yet, not yet connected),
|
|
85
|
+
takes them down with the public `removeRequestHandler()`, and lets
|
|
86
|
+
McpServer re-install them through the instrumented `setRequestHandler`,
|
|
87
|
+
so they're wrapped like any lazily-installed handler. Genuine misorders
|
|
88
|
+
still throw: a tool registered before instrumenting (v1 or v2, with or
|
|
89
|
+
without declared capabilities) or a low-level `Server` handler
|
|
90
|
+
registered first. Present since v2 support shipped (SDK 2.0.0).
|
|
91
|
+
|
|
3
92
|
## 0.15.0
|
|
4
93
|
|
|
5
94
|
"Make it visible" — no instrumentation changes, no `tools/call` hot-path
|
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ const server = new McpServer({ name: 'my-server', version: '1.0.0' });
|
|
|
79
79
|
|
|
80
80
|
// Wraps every tool registered below. Must run BEFORE server.tool() —
|
|
81
81
|
// see "Ordering constraint" below for why.
|
|
82
|
-
|
|
82
|
+
instrumentMcpServer(server, {
|
|
83
83
|
serviceName: 'my-mcp-server', // shows up on your traces
|
|
84
84
|
setupNodeSdk: true, // dev mode: prints traces to your terminal
|
|
85
85
|
});
|
|
@@ -91,26 +91,25 @@ server.tool('echo', { text: z.string() }, async ({ text }) => ({
|
|
|
91
91
|
|
|
92
92
|
const transport = new StdioServerTransport();
|
|
93
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
|
-
});
|
|
105
94
|
```
|
|
106
95
|
|
|
107
96
|
That's it. Every tool call now emits a trace. Wire an exporter to see them
|
|
108
|
-
(next section).
|
|
97
|
+
(next section). When the server stops — your MCP client closing stdin, or
|
|
98
|
+
sending SIGTERM/SIGINT — anything still buffered is flushed first,
|
|
99
|
+
automatically (see "Flushing on exit" below).
|
|
109
100
|
|
|
110
101
|
## See it working
|
|
111
102
|
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
Save the snippet as `server.js`. On its own it just waits: an MCP server
|
|
104
|
+
reads requests from stdin and does nothing until a client sends one. To
|
|
105
|
+
try it without a client, pipe in a single tool call:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' | node server.js
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The JSON-RPC response goes to stdout, and this prints to stderr — a real,
|
|
112
|
+
captured run (full dump: `examples/hello-mcpserver/README.md`):
|
|
114
113
|
|
|
115
114
|
```
|
|
116
115
|
name: 'tools/call echo'
|
|
@@ -127,10 +126,12 @@ attributes: {
|
|
|
127
126
|
No dashboard needed — `setupNodeSdk: true`'s dev exporter printed this
|
|
128
127
|
directly. Point it at a real backend later; see "Two modes" below.
|
|
129
128
|
|
|
130
|
-
**Metrics print too (v0.15.0+)** —
|
|
131
|
-
60-second export interval), a compact one-line-per-metric summary follows
|
|
129
|
+
**Metrics print too (v0.15.0+)** — a compact one-line-per-metric summary
|
|
132
130
|
on stderr, reusing the exact `mcp.tool.*` instruments documented in
|
|
133
|
-
"Metrics" below
|
|
131
|
+
"Metrics" below. A long-running server prints it every 5 seconds (not the
|
|
132
|
+
default 60-second export interval). The one-shot piped run above prints it
|
|
133
|
+
once as it exits, because the server flushes when stdin closes (v0.16.0,
|
|
134
|
+
see "Flushing on exit"):
|
|
134
135
|
|
|
135
136
|
```
|
|
136
137
|
[opentel-mcp metrics] mcp.tool.calls{gen_ai.tool.name=echo,mcp.method.name=tools/call} = 1
|
|
@@ -315,6 +316,60 @@ error.type = tool_error
|
|
|
315
316
|
|
|
316
317
|
Verified in `test/instrument.test.js`'s "tool-level failure" tests.
|
|
317
318
|
|
|
319
|
+
#### Errors your agent can't act on (v0.16.0)
|
|
320
|
+
|
|
321
|
+
An `isError: true` result with no content, or just `"Error"`, tells the
|
|
322
|
+
model nothing about what went wrong or what to do next — a common cause
|
|
323
|
+
of retries and thrash. Every `isError: true` span also carries:
|
|
324
|
+
|
|
325
|
+
| Attribute | Values |
|
|
326
|
+
| --- | --- |
|
|
327
|
+
| `mcp.failure.unactionable` | `true` when there's no content, or fewer than 10 characters of text (whitespace-trimmed) and no image/audio/resource item |
|
|
328
|
+
| `mcp.failure.content_length_bucket` | `empty` (0) · `tiny` (1–9) · `short` (< 80) · `medium` (< 500) · `long` |
|
|
329
|
+
|
|
330
|
+
Both are computed **only from lengths and item counts — never from the
|
|
331
|
+
text itself** (proved by a canary-string test in
|
|
332
|
+
`test/unactionable.test.js`), on spans only, never as metric labels. They
|
|
333
|
+
don't change fingerprints or categories. Configure with
|
|
334
|
+
`unactionableErrors: { enabled, minTextLength }` (defaults `true`, `10`).
|
|
335
|
+
Design: [ADR 025](../../docs/adr/025-unactionable-errors.md).
|
|
336
|
+
|
|
337
|
+
### Resources and prompts (opt-in, v0.16.0)
|
|
338
|
+
|
|
339
|
+
Tools are traced by default. `resources/*` and `prompts/*` requests can be
|
|
340
|
+
traced too, if you turn them on:
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
instrumentMcpServer(server, {
|
|
344
|
+
coverage: { resources: true, prompts: true }, // either or both
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
| Method | Span name | Attributes |
|
|
349
|
+
| --- | --- | --- |
|
|
350
|
+
| `resources/read`, `resources/list`, `resources/templates/list` | the method, e.g. `resources/read` | `mcp.method.name` |
|
|
351
|
+
| `prompts/get` | `prompts/get <prompt name>` | `mcp.method.name`, `gen_ai.prompt.name` |
|
|
352
|
+
| `prompts/list` | `prompts/list` | `mcp.method.name` |
|
|
353
|
+
|
|
354
|
+
- **Never captured:** resource URIs, prompt arguments, result content. A
|
|
355
|
+
failing request gets status `ERROR`, `error.type`, and the usual
|
|
356
|
+
fingerprint/category/channel (`mcp.failure.*`), but **not** the exception
|
|
357
|
+
message, because SDK resource errors embed the URI in it.
|
|
358
|
+
- `gen_ai.tool.name` is absent (not empty) on these spans, so they never
|
|
359
|
+
show up in per-tool views or tool metrics.
|
|
360
|
+
- Durations go to a separate histogram, `mcp.server.operation.duration`,
|
|
361
|
+
labeled only by `mcp.method.name` (one of the five methods above) and
|
|
362
|
+
`error.type`. No thrash detection or cost tracking applies.
|
|
363
|
+
- **Ordering:** instrument before registering resources/prompts, same as
|
|
364
|
+
tools. If a handler for one of these methods already exists, that method
|
|
365
|
+
is skipped with a single `diag.warn`. It never throws, so turning this on
|
|
366
|
+
can't break an existing server. On an SDK v2 `McpServer`, declaring
|
|
367
|
+
`capabilities: { resources: {}, prompts: {} }` in the constructor
|
|
368
|
+
registers those handlers immediately (and so skips them); leave them out,
|
|
369
|
+
since registering a resource or prompt declares the capability for you.
|
|
370
|
+
|
|
371
|
+
Design: [ADR 026](../../docs/adr/026-resources-prompts-coverage.md).
|
|
372
|
+
|
|
318
373
|
### Span attributes
|
|
319
374
|
|
|
320
375
|
Every span follows the OpenTelemetry MCP semantic conventions (see
|
|
@@ -449,8 +504,30 @@ What it does, under `setupNodeSdk: true` and `enableMetrics` (default
|
|
|
449
504
|
already registered, exactly as it does today.
|
|
450
505
|
- Extends `server.shutdown()` (already attached under `setupNodeSdk: true`)
|
|
451
506
|
to also flush and shut down the `MeterProvider` it registered, alongside
|
|
452
|
-
the existing `TracerProvider` shutdown
|
|
453
|
-
|
|
507
|
+
the existing `TracerProvider` shutdown. As of v0.16.0 this runs
|
|
508
|
+
automatically when the process stops — see "Flushing on exit" below.
|
|
509
|
+
|
|
510
|
+
### Flushing on exit (`flushOnExit`, v0.16.0)
|
|
511
|
+
|
|
512
|
+
Under `setupNodeSdk: true`, buffered spans and dev-mode metrics are
|
|
513
|
+
flushed automatically when the process stops. It's on by default; pass
|
|
514
|
+
`flushOnExit: false` to turn it off. MCP clients built on the SDK's stdio
|
|
515
|
+
transport stop a server by closing stdin, then sending SIGTERM 2 seconds
|
|
516
|
+
later, then SIGKILL; this covers the first two:
|
|
517
|
+
|
|
518
|
+
| Event | What happens |
|
|
519
|
+
| --- | --- |
|
|
520
|
+
| stdin closes and nothing else keeps the process alive | flushed on `beforeExit`; the process exits with its own exit code |
|
|
521
|
+
| `SIGTERM` / `SIGINT` | flushed, then the same signal is re-raised so the process still dies by it, unless your code has its own listener for that signal, in which case yours decides |
|
|
522
|
+
| `process.exit()` | **not covered**: call `await server.shutdown()` before exiting |
|
|
523
|
+
| `SIGKILL` | can't be intercepted |
|
|
524
|
+
|
|
525
|
+
The flush waits at most 1000 ms (`flushOnExit: { timeoutMs }` can only
|
|
526
|
+
lower that), so a stuck exporter can't keep a stopping server alive.
|
|
527
|
+
`server.shutdown()` is safe to call yourself as well: it runs once, however
|
|
528
|
+
many times it's called. With `setupNodeSdk: false` this option does
|
|
529
|
+
nothing — your application owns its providers and its shutdown. Design:
|
|
530
|
+
[ADR 024](../../docs/adr/024-flush-on-exit.md).
|
|
454
531
|
|
|
455
532
|
`@opentelemetry/sdk-metrics` is a real (non-dev) dependency of opentel-mcp
|
|
456
533
|
as of this feature — not an optional peer, not a dynamic import with a
|
|
@@ -2241,6 +2318,7 @@ so they're spelled out instead of forced into one bucket.
|
|
|
2241
2318
|
| `mcp.tool.pricing_status` | span attr, metric label | **blend — library-computed, depends on tool + host** | which of three enum values depends on a tool-controlled model name resolving (or not) in a library-default-or-host-overridden pricing table |
|
|
2242
2319
|
| `mcp.failure.fingerprint` / `.signature` / `.category` / `.origin` / `.channel` | span attr (2 also metric labels) | library-computed | hashes/enums — the governed core this project has kept clean since v0.4.0 |
|
|
2243
2320
|
| `mcp.failure.error_class` | span attr | **blend — tool-controlled, capped only** | `err.name`, length-capped at 128 characters but, unlike everything hashed into the fingerprint, *not* pattern-scrubbed — a documented, deliberate tradeoff (`docs/known-gaps.md` entry 10) |
|
|
2321
|
+
| `mcp.failure.unactionable` / `.content_length_bucket` | span attr | library-computed | a boolean and a 5-value enum derived from the trimmed text LENGTH and non-text item COUNT of an `isError: true` result — no text is read for anything but its length (ADR 025, v0.16.0) |
|
|
2244
2322
|
| `mcp.failure.validation_paths` | span attr | mostly library-computed (gated) | one legacy Zod-issue-rendering path can still carry a tool-controlled dynamic object key, now redacted to a `<KEY>` placeholder rather than closed structurally (`docs/known-gaps.md` entry 10, item 3) |
|
|
2245
2323
|
| `exception.type` (event) | event attr | tool-controlled | length-capped only under `'normalized'` mode; uncapped under `'full'` (the native SDK call) |
|
|
2246
2324
|
| `exception.message` / `.stacktrace` (event), status message | event attr + status | **blend — layered** | raw tool content under `'full'`; this library's `normalizeMessage()`/`parseAndNormalizeStack()` transform of that same content under `'normalized'`; optionally a host-supplied `errorRecording.redactor`'s output feeding *that*, first (v0.14.0) — the most layered-provenance attribute in the system |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentel-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"@opentelemetry/sdk-trace-node": "^2.9.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@modelcontextprotocol/
|
|
76
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
77
|
+
"@modelcontextprotocol/server": "2.3.1",
|
|
77
78
|
"@opentelemetry/api": "^1.9.0",
|
|
78
79
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
79
80
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
package/src/attributes.js
CHANGED
|
@@ -76,6 +76,29 @@ export const MCP_METHOD_NAME_TOOLS_CALL = 'tools/call';
|
|
|
76
76
|
/** Well-known mcp.method.name value for a tools/list request (ADR 010, schema drift detection). */
|
|
77
77
|
export const MCP_METHOD_NAME_TOOLS_LIST = 'tools/list';
|
|
78
78
|
|
|
79
|
+
// ADR 026 (v0.16.0, opt-in `coverage`): the five resource/prompt methods.
|
|
80
|
+
// mcp.method.name only ever takes these fixed string constants, never a
|
|
81
|
+
// value read from a request, so its value space stays bounded (7 total).
|
|
82
|
+
|
|
83
|
+
/** Well-known mcp.method.name value for a resources/read request (ADR 026). */
|
|
84
|
+
export const MCP_METHOD_NAME_RESOURCES_READ = 'resources/read';
|
|
85
|
+
/** Well-known mcp.method.name value for a resources/list request (ADR 026). */
|
|
86
|
+
export const MCP_METHOD_NAME_RESOURCES_LIST = 'resources/list';
|
|
87
|
+
/** Well-known mcp.method.name value for a resources/templates/list request (ADR 026). */
|
|
88
|
+
export const MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST = 'resources/templates/list';
|
|
89
|
+
/** Well-known mcp.method.name value for a prompts/get request (ADR 026). */
|
|
90
|
+
export const MCP_METHOD_NAME_PROMPTS_GET = 'prompts/get';
|
|
91
|
+
/** Well-known mcp.method.name value for a prompts/list request (ADR 026). */
|
|
92
|
+
export const MCP_METHOD_NAME_PROMPTS_LIST = 'prompts/list';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* gen_ai.prompt.name — the prompt a prompts/get span requested (ADR 026).
|
|
96
|
+
* Span attribute only, never a metric label. Capped at
|
|
97
|
+
* MAX_PROMPT_NAME_LENGTH, since the value comes from the request.
|
|
98
|
+
*/
|
|
99
|
+
export const ATTR_GEN_AI_PROMPT_NAME = 'gen_ai.prompt.name';
|
|
100
|
+
export const MAX_PROMPT_NAME_LENGTH = 128;
|
|
101
|
+
|
|
79
102
|
// --- Custom (non-spec) attributes ---
|
|
80
103
|
|
|
81
104
|
/**
|
package/src/config.js
CHANGED
|
@@ -10,6 +10,8 @@ import { normalizeModelName } from './cost/calculator.js';
|
|
|
10
10
|
import { resolveThrashConfig } from './thrash/config.js';
|
|
11
11
|
import { resolveSchemaDriftConfig } from './schema-drift/config.js';
|
|
12
12
|
import { resolveErrorRecordingConfig } from './error-recording/config.js';
|
|
13
|
+
import { resolveFlushTimeout } from './flush-on-exit.js';
|
|
14
|
+
import { resolveUnactionableErrorsConfig } from './unactionable.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* @typedef {object} CostTrackingOptions
|
|
@@ -98,6 +100,31 @@ import { resolveErrorRecordingConfig } from './error-recording/config.js';
|
|
|
98
100
|
* variable (lower precedence than this option), same OTEL_MCP_<FEATURE>_ prefix pattern as
|
|
99
101
|
* OTEL_MCP_THRASH_ and OTEL_MCP_SCHEMA_DRIFT_; an unrecognized value from either source falls back to
|
|
100
102
|
* 'full' silently, never a throw.
|
|
103
|
+
* @property {boolean | { timeoutMs?: number }} [flushOnExit] - ADR 024 (docs/adr/024-flush-on-exit.md):
|
|
104
|
+
* flush the providers this library created when the process stops — on `beforeExit`, `SIGTERM` and `SIGINT`
|
|
105
|
+
* — so spans and dev-mode metrics still buffered aren't lost. Only meaningful with `setupNodeSdk: true`,
|
|
106
|
+
* where it defaults to ON; pass `false` to opt out. With `setupNodeSdk: false` (the default) it never has
|
|
107
|
+
* any effect: the host owns its providers and its shutdown, and passing it logs a one-time `diag.warn`.
|
|
108
|
+
* A signal is re-raised after the flush when the host has no listener of its own for it, so the process
|
|
109
|
+
* still dies by that signal; with a host listener, the host decides. The flush waits at most `timeoutMs`
|
|
110
|
+
* (default and maximum 1000 ms). Not covered: `process.exit()` (call `await server.shutdown()` first) and
|
|
111
|
+
* `SIGKILL`.
|
|
112
|
+
* @property {Partial<import('./unactionable.js').UnactionableErrorsConfig>} [unactionableErrors] - ADR 025
|
|
113
|
+
* (docs/adr/025-unactionable-errors.md): on every `isError: true` result, sets the span attributes
|
|
114
|
+
* `mcp.failure.unactionable` (boolean) and `mcp.failure.content_length_bucket` (`empty` | `tiny` | `short` |
|
|
115
|
+
* `medium` | `long`), computed only from the whitespace-trimmed length of the text items and the count of
|
|
116
|
+
* non-text items — never from the text itself. Unactionable = no content, or under `minTextLength`
|
|
117
|
+
* (default 10) characters of text with no image/audio/resource item. `{ enabled: true, minTextLength: 10 }`
|
|
118
|
+
* by default; independent of `fingerprinting`. Span-only: never a metric label. Also settable via
|
|
119
|
+
* `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` / `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH` (lower precedence).
|
|
120
|
+
* @property {{ resources?: boolean, prompts?: boolean }} [coverage] - ADR 026 (docs/adr/026-resources-prompts-coverage.md),
|
|
121
|
+
* opt-in, both off by default: also trace `resources/read`, `resources/list`, `resources/templates/list`
|
|
122
|
+
* (`resources: true`) and `prompts/get`, `prompts/list` (`prompts: true`). Spans are named by method
|
|
123
|
+
* (`prompts/get <name>` for prompts/get), carry `mcp.method.name` and never `gen_ai.tool.name`; resource
|
|
124
|
+
* URIs and prompt arguments are never captured. A handler for one of these methods that's already
|
|
125
|
+
* registered when instrumentMcpServer() runs is skipped (not wrapped) with a single `diag.warn` — never a
|
|
126
|
+
* throw. Durations go to the `mcp.server.operation.duration` histogram (labels: `mcp.method.name`,
|
|
127
|
+
* `error.type`).
|
|
101
128
|
* @property {string} [instanceKey] - Host-supplied stable identifier for one logical service (ADR 012,
|
|
102
129
|
* docs/adr/012-tracker-lifecycle-and-shared-state.md, Option C — Phase 2: this option and its wiring).
|
|
103
130
|
* When provided, the four in-memory trackers this library keeps per instrumented server — budget
|
|
@@ -135,6 +162,41 @@ export function __resetServiceNameWarnedForTests() {
|
|
|
135
162
|
warnedServiceNameIgnored = false;
|
|
136
163
|
}
|
|
137
164
|
|
|
165
|
+
// Guards the "flushOnExit has no effect without setupNodeSdk" diagnostic
|
|
166
|
+
// (ADR 024), same once-per-process pattern.
|
|
167
|
+
let warnedFlushOnExitIgnored = false;
|
|
168
|
+
|
|
169
|
+
// Test-only, same purpose as __resetServiceNameWarnedForTests above.
|
|
170
|
+
export function __resetFlushOnExitWarnedForTests() {
|
|
171
|
+
warnedFlushOnExitIgnored = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* ADR 024: `false` turns it off; an object supplies `timeoutMs`; anything
|
|
176
|
+
* else (true, undefined, null, junk) means "on, defaults". Only consulted
|
|
177
|
+
* when setupNodeSdk is true.
|
|
178
|
+
*
|
|
179
|
+
* @param {unknown} value
|
|
180
|
+
* @returns {{ timeoutMs: number } | null}
|
|
181
|
+
*/
|
|
182
|
+
function resolveFlushOnExit(value) {
|
|
183
|
+
if (value === false) return null;
|
|
184
|
+
const timeoutMs = value !== null && typeof value === 'object' ? /** @type {any} */ (value).timeoutMs : undefined;
|
|
185
|
+
return { timeoutMs: resolveFlushTimeout(timeoutMs) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* ADR 026: opt-in resources/* and prompts/* coverage. Only a literal `true`
|
|
190
|
+
* turns a family on; anything else (including the default) is off.
|
|
191
|
+
*
|
|
192
|
+
* @param {unknown} value
|
|
193
|
+
* @returns {{ resources: boolean, prompts: boolean }}
|
|
194
|
+
*/
|
|
195
|
+
function resolveCoverage(value) {
|
|
196
|
+
const v = value !== null && typeof value === 'object' ? /** @type {any} */ (value) : {};
|
|
197
|
+
return { resources: v.resources === true, prompts: v.prompts === true };
|
|
198
|
+
}
|
|
199
|
+
|
|
138
200
|
// Guards the DEFAULT_PRICING staleness diagnostic below (ADR 016 point 3),
|
|
139
201
|
// same one-per-process pattern as warnedServiceNameIgnored above.
|
|
140
202
|
let warnedPricingStale = false;
|
|
@@ -210,6 +272,16 @@ export function resolveOptions(options) {
|
|
|
210
272
|
);
|
|
211
273
|
}
|
|
212
274
|
|
|
275
|
+
const flushOnExitRequested = opts.flushOnExit !== undefined && opts.flushOnExit !== false;
|
|
276
|
+
if (!setupNodeSdk && flushOnExitRequested && !warnedFlushOnExitIgnored) {
|
|
277
|
+
warnedFlushOnExitIgnored = true;
|
|
278
|
+
diag.warn(
|
|
279
|
+
'opentel-mcp: flushOnExit was provided but setupNodeSdk is false, so it has no effect. ' +
|
|
280
|
+
'The host application owns its TracerProvider/MeterProvider and their shutdown — flush them in your ' +
|
|
281
|
+
"own shutdown sequence. See ADR 024, docs/adr/024-flush-on-exit.md.",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
213
285
|
const rawCostTracking = opts.costTracking ?? {};
|
|
214
286
|
const costTrackingEnabled = rawCostTracking.enabled ?? true;
|
|
215
287
|
|
|
@@ -275,5 +347,8 @@ export function resolveOptions(options) {
|
|
|
275
347
|
schemaDrift: resolveSchemaDriftConfig(opts.schemaDrift),
|
|
276
348
|
errorRecording: resolveErrorRecordingConfig(opts.errorRecording),
|
|
277
349
|
instanceKey: resolveInstanceKey(opts.instanceKey, process.env[ENV_INSTANCE_KEY]),
|
|
350
|
+
flushOnExit: setupNodeSdk ? resolveFlushOnExit(opts.flushOnExit) : null,
|
|
351
|
+
unactionableErrors: resolveUnactionableErrorsConfig(opts.unactionableErrors),
|
|
352
|
+
coverage: resolveCoverage(opts.coverage),
|
|
278
353
|
};
|
|
279
354
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module flush-on-exit
|
|
3
|
+
*
|
|
4
|
+
* ADR 024 (docs/adr/024-flush-on-exit.md): flush the providers this
|
|
5
|
+
* library owns (setupNodeSdk: true) when the process is about to stop.
|
|
6
|
+
*
|
|
7
|
+
* MCP clients built on the SDK's StdioClientTransport stop a server by
|
|
8
|
+
* closing stdin, then SIGTERM 2 s later, then SIGKILL 2 s after that
|
|
9
|
+
* (@modelcontextprotocol/sdk client/stdio.js close()). `beforeExit` only
|
|
10
|
+
* covers the first step, and only when nothing else holds the loop open.
|
|
11
|
+
*
|
|
12
|
+
* One listener set per PROCESS, not per instrumented server: per-instance
|
|
13
|
+
* listeners would each see the others as "the host's own handler" and none
|
|
14
|
+
* would ever re-raise. Every instrumented server registers its memoized
|
|
15
|
+
* shutdown() here; the listeners flush them all.
|
|
16
|
+
*
|
|
17
|
+
* - beforeExit: flush once. The process then exits on its own with
|
|
18
|
+
* whatever process.exitCode already was; nothing here changes it. No
|
|
19
|
+
* process.exit() call: a host's own beforeExit work must not be cut short.
|
|
20
|
+
* - SIGTERM / SIGINT: remove our listener for that signal first (so a
|
|
21
|
+
* second signal takes Node's default and kills immediately), flush
|
|
22
|
+
* bounded by the timeout, then re-raise the same signal ONLY if no other
|
|
23
|
+
* listener for it remains. Installing a signal listener removes Node's
|
|
24
|
+
* default "exit on signal"; re-raising with no listeners restores it, so
|
|
25
|
+
* the parent sees the same death-by-signal it would have without us. If
|
|
26
|
+
* the host has its own listener, the host owns the exit.
|
|
27
|
+
* - The flush wait is capped (FLUSH_ON_EXIT_MAX_TIMEOUT_MS) with an
|
|
28
|
+
* unref'd timer, so it never holds the process open by itself.
|
|
29
|
+
*
|
|
30
|
+
* Listeners are removed again once no server is registered (every
|
|
31
|
+
* instrumented server shut down), so an explicitly shut-down process has
|
|
32
|
+
* exactly the signal behavior it had before instrumenting.
|
|
33
|
+
*
|
|
34
|
+
* Never throws: everything that touches `process` is guarded.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { diag } from '@opentelemetry/api';
|
|
38
|
+
|
|
39
|
+
/** Upper bound on how long a stop waits for telemetry to flush (ADR 024). */
|
|
40
|
+
export const FLUSH_ON_EXIT_MAX_TIMEOUT_MS = 1000;
|
|
41
|
+
|
|
42
|
+
const SIGNALS = /** @type {const} */ (['SIGTERM', 'SIGINT']);
|
|
43
|
+
|
|
44
|
+
/** @type {Map<() => Promise<void>, number>} memoized shutdown -> its timeout */
|
|
45
|
+
const registered = new Map();
|
|
46
|
+
|
|
47
|
+
/** @type {{ beforeExit: (() => void) | null, SIGTERM: (() => void) | null, SIGINT: (() => void) | null }} */
|
|
48
|
+
const listeners = { beforeExit: null, SIGTERM: null, SIGINT: null };
|
|
49
|
+
|
|
50
|
+
let warnedInstallFailed = false;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Clamps a user-supplied timeout into (0, FLUSH_ON_EXIT_MAX_TIMEOUT_MS].
|
|
54
|
+
* Anything that isn't a positive finite number gets the maximum.
|
|
55
|
+
*
|
|
56
|
+
* @param {unknown} value
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
export function resolveFlushTimeout(value) {
|
|
60
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
61
|
+
return Math.min(value, FLUSH_ON_EXIT_MAX_TIMEOUT_MS);
|
|
62
|
+
}
|
|
63
|
+
return FLUSH_ON_EXIT_MAX_TIMEOUT_MS;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Runs every registered shutdown once, in parallel, and resolves when all
|
|
68
|
+
* settle or the longest registered timeout elapses, whichever is first.
|
|
69
|
+
* Never rejects.
|
|
70
|
+
*
|
|
71
|
+
* @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
export function flushAll() {
|
|
74
|
+
const entries = [...registered];
|
|
75
|
+
registered.clear();
|
|
76
|
+
if (entries.length === 0) return Promise.resolve();
|
|
77
|
+
|
|
78
|
+
const timeoutMs = Math.max(...entries.map(([, ms]) => ms));
|
|
79
|
+
const work = Promise.allSettled(
|
|
80
|
+
entries.map(([shutdown]) => {
|
|
81
|
+
try {
|
|
82
|
+
return Promise.resolve(shutdown());
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return Promise.reject(err);
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let timer;
|
|
91
|
+
try {
|
|
92
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
} catch {
|
|
95
|
+
// No timers available: fall back to waiting for the work itself.
|
|
96
|
+
}
|
|
97
|
+
work.then(() => {
|
|
98
|
+
if (timer) clearTimeout(timer);
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onBeforeExit() {
|
|
105
|
+
// Fires again once our own flush settles and the loop drains: nothing
|
|
106
|
+
// registered by then, so this returns and the process exits normally.
|
|
107
|
+
if (registered.size === 0) return;
|
|
108
|
+
flushAll().then(uninstallIfIdle, uninstallIfIdle);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {'SIGTERM' | 'SIGINT'} signal */
|
|
112
|
+
function makeSignalListener(signal) {
|
|
113
|
+
const listener = () => {
|
|
114
|
+
try {
|
|
115
|
+
process.removeListener(signal, listener);
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
listeners[signal] = null;
|
|
120
|
+
flushAll().then(() => {
|
|
121
|
+
try {
|
|
122
|
+
if (process.listenerCount(signal) === 0) {
|
|
123
|
+
process.kill(process.pid, signal);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
uninstallIfIdle();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
diag.debug(`opentel-mcp: flushOnExit could not re-raise ${signal}: ${err?.message ?? err}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
return listener;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function install() {
|
|
136
|
+
try {
|
|
137
|
+
if (!listeners.beforeExit) {
|
|
138
|
+
listeners.beforeExit = onBeforeExit;
|
|
139
|
+
process.on('beforeExit', onBeforeExit);
|
|
140
|
+
}
|
|
141
|
+
for (const signal of SIGNALS) {
|
|
142
|
+
if (!listeners[signal]) {
|
|
143
|
+
const listener = makeSignalListener(signal);
|
|
144
|
+
listeners[signal] = listener;
|
|
145
|
+
process.on(signal, listener);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
uninstall();
|
|
151
|
+
if (!warnedInstallFailed) {
|
|
152
|
+
warnedInstallFailed = true;
|
|
153
|
+
diag.warn(
|
|
154
|
+
`opentel-mcp: flushOnExit could not install process listeners (${err?.message ?? err}); ` +
|
|
155
|
+
'telemetry will not be flushed automatically on exit. Call `await server.shutdown()` yourself.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function uninstall() {
|
|
163
|
+
try {
|
|
164
|
+
if (listeners.beforeExit) process.removeListener('beforeExit', listeners.beforeExit);
|
|
165
|
+
for (const signal of SIGNALS) {
|
|
166
|
+
if (listeners[signal]) process.removeListener(signal, listeners[signal]);
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// ignore
|
|
170
|
+
}
|
|
171
|
+
listeners.beforeExit = null;
|
|
172
|
+
listeners.SIGTERM = null;
|
|
173
|
+
listeners.SIGINT = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function uninstallIfIdle() {
|
|
177
|
+
if (registered.size === 0) uninstall();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Registers one instrumented server's memoized shutdown(). Returns an
|
|
182
|
+
* unregister function (idempotent). Never throws.
|
|
183
|
+
*
|
|
184
|
+
* @param {() => Promise<void>} shutdown
|
|
185
|
+
* @param {number} timeoutMs already resolved via resolveFlushTimeout()
|
|
186
|
+
* @returns {() => void}
|
|
187
|
+
*/
|
|
188
|
+
export function registerFlushOnExit(shutdown, timeoutMs) {
|
|
189
|
+
try {
|
|
190
|
+
registered.set(shutdown, timeoutMs);
|
|
191
|
+
if (!install()) registered.delete(shutdown);
|
|
192
|
+
} catch {
|
|
193
|
+
// never throw
|
|
194
|
+
}
|
|
195
|
+
return () => {
|
|
196
|
+
try {
|
|
197
|
+
registered.delete(shutdown);
|
|
198
|
+
uninstallIfIdle();
|
|
199
|
+
} catch {
|
|
200
|
+
// never throw
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Test-only: drop every registration and listener. Not part of the public API. */
|
|
206
|
+
export function __resetFlushOnExitForTests() {
|
|
207
|
+
registered.clear();
|
|
208
|
+
uninstall();
|
|
209
|
+
warnedInstallFailed = false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Test-only: how many servers are registered. Not part of the public API. */
|
|
213
|
+
export function __registeredCountForTests() {
|
|
214
|
+
return registered.size;
|
|
215
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -86,6 +86,65 @@ export interface InstrumentOptions {
|
|
|
86
86
|
*/
|
|
87
87
|
setupNodeSdk?: boolean;
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* ADR 024: flush the providers this library created when the process
|
|
91
|
+
* stops — on `beforeExit`, `SIGTERM` and `SIGINT` — so buffered spans and
|
|
92
|
+
* dev-mode metrics aren't lost. Only meaningful with `setupNodeSdk: true`,
|
|
93
|
+
* where it defaults to on; pass `false` to opt out. With `setupNodeSdk:
|
|
94
|
+
* false` it never has any effect (the host owns its providers), and
|
|
95
|
+
* passing it logs a one-time `diag.warn`.
|
|
96
|
+
*
|
|
97
|
+
* After flushing, a signal is re-raised when no other listener for it
|
|
98
|
+
* exists, so the process still dies by that signal; if the host has its
|
|
99
|
+
* own listener, the host decides. The flush waits at most `timeoutMs`
|
|
100
|
+
* (default and maximum 1000 ms). Not covered: `process.exit()` (call
|
|
101
|
+
* `await server.shutdown()` first) and `SIGKILL`.
|
|
102
|
+
*
|
|
103
|
+
* @default true when `setupNodeSdk` is true
|
|
104
|
+
*/
|
|
105
|
+
flushOnExit?: boolean | { timeoutMs?: number };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ADR 025: on every `isError: true` result, sets the span attributes
|
|
109
|
+
* `mcp.failure.unactionable` (boolean) and
|
|
110
|
+
* `mcp.failure.content_length_bucket` (`'empty' | 'tiny' | 'short' |
|
|
111
|
+
* 'medium' | 'long'`). Computed only from the whitespace-trimmed length of
|
|
112
|
+
* the text items and the count of non-text items, never from the text
|
|
113
|
+
* itself. Unactionable means no content, or fewer than `minTextLength`
|
|
114
|
+
* characters of text with no image/audio/resource item. Span-only (never a
|
|
115
|
+
* metric label); independent of `fingerprinting`. Also settable via
|
|
116
|
+
* `OTEL_MCP_UNACTIONABLE_ERRORS_ENABLED` /
|
|
117
|
+
* `OTEL_MCP_UNACTIONABLE_ERRORS_MIN_TEXT_LENGTH` (lower precedence).
|
|
118
|
+
*
|
|
119
|
+
* @default { enabled: true, minTextLength: 10 }
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* ADR 026, opt-in (both off by default): also trace `resources/read`,
|
|
123
|
+
* `resources/list`, `resources/templates/list` (`resources: true`) and
|
|
124
|
+
* `prompts/get`, `prompts/list` (`prompts: true`). Spans are named by
|
|
125
|
+
* method (`prompts/get <name>` for prompts/get), carry `mcp.method.name`,
|
|
126
|
+
* and never carry `gen_ai.tool.name`. Resource URIs, prompt arguments and
|
|
127
|
+
* result content are never captured; on a thrown error the span gets
|
|
128
|
+
* status ERROR, `error.type` and (with fingerprinting) the
|
|
129
|
+
* `mcp.failure.*` hash/enums, never the exception message. Durations go
|
|
130
|
+
* to the `mcp.server.operation.duration` histogram (labels:
|
|
131
|
+
* `mcp.method.name`, `error.type`). A handler for one of these methods
|
|
132
|
+
* that's already registered when `instrumentMcpServer()` runs is skipped
|
|
133
|
+
* with a single `diag.warn`, never a throw.
|
|
134
|
+
*
|
|
135
|
+
* @default { resources: false, prompts: false }
|
|
136
|
+
*/
|
|
137
|
+
coverage?: {
|
|
138
|
+
resources?: boolean;
|
|
139
|
+
prompts?: boolean;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
unactionableErrors?: {
|
|
143
|
+
enabled?: boolean;
|
|
144
|
+
/** Integer 0–200. `0` flags only empty (or whitespace-only) errors. @default 10 */
|
|
145
|
+
minTextLength?: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
89
148
|
/**
|
|
90
149
|
* Controls the `mcp.tool.tokens.*` / `mcp.tool.model` / `mcp.tool.cost.*` span attributes, the
|
|
91
150
|
* `mcp.tool.tokens.total` / `mcp.tool.cost.total` metrics, and the optional per-session/per-tool budget
|
|
@@ -383,6 +442,10 @@ export function instrumentMcpServer<T extends DuckTypedServer | DuckTypedMcpServ
|
|
|
383
442
|
server: T,
|
|
384
443
|
options?: InstrumentOptions,
|
|
385
444
|
): T & {
|
|
445
|
+
/**
|
|
446
|
+
* Present under `setupNodeSdk: true`. Flushes and shuts down the providers
|
|
447
|
+
* this library created. Idempotent: every call returns the same promise.
|
|
448
|
+
*/
|
|
386
449
|
shutdown?: () => Promise<void>;
|
|
387
450
|
getThrashSummary?: (options?: { topOffendersLimit?: number }) => ThrashSummary;
|
|
388
451
|
getObservationState?: () => ObservationState;
|
package/src/instrument.js
CHANGED
|
@@ -31,8 +31,17 @@ import { createSchemaDriftEmitter } from './schema-drift/emitter.js';
|
|
|
31
31
|
import { ToolOutcomeCounter } from './observation/tool-outcome-counter.js';
|
|
32
32
|
import { detectObservationIntegrity } from './observation/integrity.js';
|
|
33
33
|
import { InstanceRegistry } from './registry/instance-registry.js';
|
|
34
|
+
import { registerFlushOnExit } from './flush-on-exit.js';
|
|
35
|
+
import { applyUnactionableAttributes } from './unactionable.js';
|
|
34
36
|
import {
|
|
35
37
|
ATTR_MCP_METHOD_NAME,
|
|
38
|
+
ATTR_GEN_AI_PROMPT_NAME,
|
|
39
|
+
MAX_PROMPT_NAME_LENGTH,
|
|
40
|
+
MCP_METHOD_NAME_RESOURCES_READ,
|
|
41
|
+
MCP_METHOD_NAME_RESOURCES_LIST,
|
|
42
|
+
MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST,
|
|
43
|
+
MCP_METHOD_NAME_PROMPTS_GET,
|
|
44
|
+
MCP_METHOD_NAME_PROMPTS_LIST,
|
|
36
45
|
ATTR_GEN_AI_TOOL_NAME,
|
|
37
46
|
ATTR_GEN_AI_OPERATION_NAME,
|
|
38
47
|
ATTR_GEN_AI_RESPONSE_MODEL,
|
|
@@ -390,8 +399,29 @@ export function instrumentMcpServer(input, options) {
|
|
|
390
399
|
return input;
|
|
391
400
|
}
|
|
392
401
|
|
|
402
|
+
// v2 McpServer declared with `capabilities.tools` installs its own
|
|
403
|
+
// tools/list + tools/call dispatchers in its constructor, before this
|
|
404
|
+
// function can ever run — see hasConstructorInstalledToolHandlers().
|
|
405
|
+
// Those aren't user handlers that would be bypassed: they're McpServer's
|
|
406
|
+
// own, and McpServer will install identical ones again on request. Take
|
|
407
|
+
// them down here (public removeRequestHandler) so the ordering check
|
|
408
|
+
// below only sees genuine user registrations, and re-install them through
|
|
409
|
+
// the patched setRequestHandler at the end of this function. If anything
|
|
410
|
+
// in between throws, McpServer re-installs them (unwrapped) on its next
|
|
411
|
+
// registerTool(), since its own guard flag is reset too.
|
|
412
|
+
const reinstallToolHandlers = hasConstructorInstalledToolHandlers(server, outer, kind);
|
|
413
|
+
if (reinstallToolHandlers) {
|
|
414
|
+
server.removeRequestHandler(TOOLS_CALL_METHOD);
|
|
415
|
+
server.removeRequestHandler(TOOLS_LIST_METHOD);
|
|
416
|
+
outer._toolHandlersInitialized = false;
|
|
417
|
+
}
|
|
418
|
+
|
|
393
419
|
assertInstrumentFirst(server, resolved);
|
|
394
420
|
|
|
421
|
+
// ADR 026: opt-in resources/* and prompts/* methods to wrap — minus any
|
|
422
|
+
// already registered (skipped with one diag.warn, never a throw).
|
|
423
|
+
const coveredMethods = resolveCoveredMethods(server, resolved);
|
|
424
|
+
|
|
395
425
|
const tracer = setupTracer(server, resolved);
|
|
396
426
|
const metricsRecorder = resolved.enableMetrics ? setupMeter(PACKAGE_VERSION) : null;
|
|
397
427
|
// One tracker per instrumented server, not per call — session/tool cost
|
|
@@ -586,6 +616,7 @@ export function instrumentMcpServer(input, options) {
|
|
|
586
616
|
resolved.errorRecording,
|
|
587
617
|
costAttributionState,
|
|
588
618
|
errorRecordingState,
|
|
619
|
+
resolved.unactionableErrors,
|
|
589
620
|
);
|
|
590
621
|
} else if (schema === v1.ListToolsRequestSchema && schemaDriftDetector) {
|
|
591
622
|
handler = wrapToolsListHandler(
|
|
@@ -598,6 +629,11 @@ export function instrumentMcpServer(input, options) {
|
|
|
598
629
|
resolved.errorRecording,
|
|
599
630
|
errorRecordingState,
|
|
600
631
|
);
|
|
632
|
+
} else if (coveredMethods.size > 0) {
|
|
633
|
+
const method = v1CoveredMethodForSchema(v1, schema);
|
|
634
|
+
if (method && coveredMethods.has(method)) {
|
|
635
|
+
handler = wrapNonToolHandler(handler, method, tracer, metricsRecorder, resolved.fingerprinting, kind);
|
|
636
|
+
}
|
|
601
637
|
}
|
|
602
638
|
return originalSetRequestHandler(schema, handler);
|
|
603
639
|
};
|
|
@@ -640,6 +676,7 @@ export function instrumentMcpServer(input, options) {
|
|
|
640
676
|
resolved.errorRecording,
|
|
641
677
|
costAttributionState,
|
|
642
678
|
errorRecordingState,
|
|
679
|
+
resolved.unactionableErrors,
|
|
643
680
|
);
|
|
644
681
|
} else if (maybeHandler === undefined && method === TOOLS_LIST_METHOD && schemaDriftDetector) {
|
|
645
682
|
handlerOrSchemas = wrapToolsListHandler(
|
|
@@ -653,17 +690,202 @@ export function instrumentMcpServer(input, options) {
|
|
|
653
690
|
errorRecordingState,
|
|
654
691
|
);
|
|
655
692
|
}
|
|
693
|
+
if (maybeHandler === undefined && coveredMethods.has(method)) {
|
|
694
|
+
handlerOrSchemas = wrapNonToolHandler(handlerOrSchemas, method, tracer, metricsRecorder, resolved.fingerprinting, kind);
|
|
695
|
+
}
|
|
656
696
|
return maybeHandler === undefined
|
|
657
697
|
? originalSetRequestHandler(method, handlerOrSchemas)
|
|
658
698
|
: originalSetRequestHandler(method, handlerOrSchemas, maybeHandler);
|
|
659
699
|
};
|
|
660
700
|
}
|
|
661
701
|
|
|
702
|
+
if (reinstallToolHandlers) {
|
|
703
|
+
// McpServer's own (TS-private) installer, guarded by the flag reset
|
|
704
|
+
// above: re-registers its tools/list + tools/call dispatchers, this time
|
|
705
|
+
// through the patched setRequestHandler, so they come back wrapped.
|
|
706
|
+
outer.setToolRequestHandlers();
|
|
707
|
+
}
|
|
708
|
+
|
|
662
709
|
server[kInstrumented] = true;
|
|
663
710
|
if (outer) outer[kInstrumented] = true;
|
|
664
711
|
return input;
|
|
665
712
|
}
|
|
666
713
|
|
|
714
|
+
/**
|
|
715
|
+
* True when `outer` is a v2 McpServer whose tools/list + tools/call
|
|
716
|
+
* handlers were installed by its own constructor rather than by the user:
|
|
717
|
+
* v2's constructor calls `this.setToolRequestHandlers()` when
|
|
718
|
+
* `capabilities.tools` is declared (@modelcontextprotocol/server 2.x,
|
|
719
|
+
* `mcp-*.mjs`: `if (options?.capabilities?.tools) this.setToolRequestHandlers()`),
|
|
720
|
+
* so with that (common) option the handlers exist before
|
|
721
|
+
* instrumentMcpServer() can be called at all, and ADR 002's check threw on
|
|
722
|
+
* every such server.
|
|
723
|
+
*
|
|
724
|
+
* Deliberately narrow, so every genuine misorder still throws:
|
|
725
|
+
* - v2 McpServer only (v1's McpServer never installs eagerly);
|
|
726
|
+
* - its installer ran (`_toolHandlersInitialized === true`) but no tool has
|
|
727
|
+
* been registered yet (`_registeredTools` empty). A tool registered before
|
|
728
|
+
* instrumenting keeps throwing, same as v1 and same as before;
|
|
729
|
+
* - not yet connected: re-installing calls `registerCapabilities()`, which
|
|
730
|
+
* v2 refuses after connect, so a connected server keeps the old behavior;
|
|
731
|
+
* - every member used is feature-detected. These are McpServer internals
|
|
732
|
+
* (ADR 001/002 avoid them in general); they're read only to recognize
|
|
733
|
+
* McpServer's own handlers, never to reach a handler function. Any
|
|
734
|
+
* mismatch falls back to the previous behavior (ADR 002's throw).
|
|
735
|
+
*
|
|
736
|
+
* Known limit: a user who replaced McpServer's own dispatcher directly on
|
|
737
|
+
* `mcp.server` (setRequestHandler after construction, no tool registered)
|
|
738
|
+
* is indistinguishable from the constructor's handler without reading the
|
|
739
|
+
* private handler map; that handler would be replaced by McpServer's.
|
|
740
|
+
*
|
|
741
|
+
* Never throws.
|
|
742
|
+
*
|
|
743
|
+
* @returns {boolean}
|
|
744
|
+
*/
|
|
745
|
+
function hasConstructorInstalledToolHandlers(server, outer, kind) {
|
|
746
|
+
try {
|
|
747
|
+
if (kind !== 'v2' || !outer) return false;
|
|
748
|
+
const registered = outer._registeredTools;
|
|
749
|
+
return (
|
|
750
|
+
outer._toolHandlersInitialized === true &&
|
|
751
|
+
registered !== null &&
|
|
752
|
+
typeof registered === 'object' &&
|
|
753
|
+
Object.keys(registered).length === 0 &&
|
|
754
|
+
typeof outer.setToolRequestHandlers === 'function' &&
|
|
755
|
+
typeof server.removeRequestHandler === 'function' &&
|
|
756
|
+
!server.transport
|
|
757
|
+
);
|
|
758
|
+
} catch {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const RESOURCE_METHODS = [
|
|
764
|
+
MCP_METHOD_NAME_RESOURCES_READ,
|
|
765
|
+
MCP_METHOD_NAME_RESOURCES_LIST,
|
|
766
|
+
MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST,
|
|
767
|
+
];
|
|
768
|
+
const PROMPT_METHODS = [MCP_METHOD_NAME_PROMPTS_GET, MCP_METHOD_NAME_PROMPTS_LIST];
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* ADR 026: which resources/* and prompts/* methods this instance wraps.
|
|
772
|
+
* Only the families the caller opted into (`coverage`), minus any method
|
|
773
|
+
* whose handler is already registered: wrapping happens at registration
|
|
774
|
+
* (ADR 001), so an existing handler can't be wrapped, and ADR 010 showed
|
|
775
|
+
* that throwing on misorder breaks upgrades. Such methods are skipped with
|
|
776
|
+
* ONE diag.warn naming them — never a throw. Never throws.
|
|
777
|
+
*
|
|
778
|
+
* @returns {Set<string>}
|
|
779
|
+
*/
|
|
780
|
+
function resolveCoveredMethods(server, resolved) {
|
|
781
|
+
const covered = new Set();
|
|
782
|
+
try {
|
|
783
|
+
const wanted = [
|
|
784
|
+
...(resolved.coverage.resources ? RESOURCE_METHODS : []),
|
|
785
|
+
...(resolved.coverage.prompts ? PROMPT_METHODS : []),
|
|
786
|
+
];
|
|
787
|
+
const skipped = [];
|
|
788
|
+
for (const method of wanted) {
|
|
789
|
+
if (typeof server.assertCanSetRequestHandler === 'function') {
|
|
790
|
+
try {
|
|
791
|
+
server.assertCanSetRequestHandler(method);
|
|
792
|
+
} catch {
|
|
793
|
+
skipped.push(method);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
covered.add(method);
|
|
798
|
+
}
|
|
799
|
+
if (skipped.length > 0) {
|
|
800
|
+
diag.warn(
|
|
801
|
+
`opentel-mcp: coverage requested for ${skipped.join(', ')}, but a handler for ` +
|
|
802
|
+
`${skipped.length === 1 ? 'it' : 'each'} was already registered, so ${skipped.length === 1 ? 'it is' : 'they are'} not traced. ` +
|
|
803
|
+
'Call instrumentMcpServer() before registering resources/prompts. On an SDK v2 McpServer, ' +
|
|
804
|
+
"don't declare capabilities.resources/prompts in the constructor (that registers the handlers immediately); " +
|
|
805
|
+
'registering a resource or prompt declares the capability for you. See ADR 026.',
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
} catch {
|
|
809
|
+
// never throw
|
|
810
|
+
}
|
|
811
|
+
return covered;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* v1 dispatches by schema object identity (ADR 001); maps a schema to its
|
|
816
|
+
* ADR 026 method name, or undefined. Never throws.
|
|
817
|
+
*/
|
|
818
|
+
function v1CoveredMethodForSchema(v1, schema) {
|
|
819
|
+
if (!v1 || !schema) return undefined;
|
|
820
|
+
if (schema === v1.ReadResourceRequestSchema) return MCP_METHOD_NAME_RESOURCES_READ;
|
|
821
|
+
if (schema === v1.ListResourcesRequestSchema) return MCP_METHOD_NAME_RESOURCES_LIST;
|
|
822
|
+
if (schema === v1.ListResourceTemplatesRequestSchema) return MCP_METHOD_NAME_RESOURCES_TEMPLATES_LIST;
|
|
823
|
+
if (schema === v1.GetPromptRequestSchema) return MCP_METHOD_NAME_PROMPTS_GET;
|
|
824
|
+
if (schema === v1.ListPromptsRequestSchema) return MCP_METHOD_NAME_PROMPTS_LIST;
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* ADR 026: a SERVER span around one resources/* or prompts/* handler.
|
|
830
|
+
*
|
|
831
|
+
* - Name: the method; `prompts/get <prompt name>` for prompts/get.
|
|
832
|
+
* - `mcp.method.name` always; `gen_ai.prompt.name` on prompts/get (capped);
|
|
833
|
+
* `gen_ai.tool.name` NEVER set — absent, not empty.
|
|
834
|
+
* - Never captured: resource URIs (request params or results), prompt
|
|
835
|
+
* arguments, result content. On a thrown error, only status ERROR,
|
|
836
|
+
* `error.type` (the class name), and — with fingerprinting — the
|
|
837
|
+
* fingerprint hash/category/channel enums. The exception message and
|
|
838
|
+
* stack are deliberately NOT recorded here, whatever errorRecording.mode
|
|
839
|
+
* says: the SDK's own resource errors embed the URI in the message
|
|
840
|
+
* (e.g. `Resource ${uri} not found`).
|
|
841
|
+
* - Duration: mcp.server.operation.duration, labeled by method (+ error.type).
|
|
842
|
+
* - No thrash detection, no cost attribution, no isError handling (these
|
|
843
|
+
* methods have no such result field).
|
|
844
|
+
*
|
|
845
|
+
* The handler's result and errors pass through unchanged.
|
|
846
|
+
*/
|
|
847
|
+
function wrapNonToolHandler(handler, method, tracer, metricsRecorder, fingerprintingEnabled, kind) {
|
|
848
|
+
return (request, extra) => {
|
|
849
|
+
let promptName;
|
|
850
|
+
const rawName = request?.params?.name;
|
|
851
|
+
if (method === MCP_METHOD_NAME_PROMPTS_GET && typeof rawName === 'string' && rawName !== '') {
|
|
852
|
+
promptName = rawName.slice(0, MAX_PROMPT_NAME_LENGTH);
|
|
853
|
+
}
|
|
854
|
+
const spanName = promptName ? `${method} ${promptName}` : method;
|
|
855
|
+
const parentContext = extractTraceContext(request?.params?._meta);
|
|
856
|
+
|
|
857
|
+
return tracer.startActiveSpan(spanName, { kind: SpanKind.SERVER }, parentContext, async (span) => {
|
|
858
|
+
span.setAttribute(ATTR_MCP_METHOD_NAME, method);
|
|
859
|
+
if (promptName) span.setAttribute(ATTR_GEN_AI_PROMPT_NAME, promptName);
|
|
860
|
+
const { requestId } = extractSessionAndRequestId(kind, extra);
|
|
861
|
+
if (requestId !== undefined && requestId !== null) {
|
|
862
|
+
span.setAttribute(ATTR_JSONRPC_REQUEST_ID, String(requestId));
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const startTime = performance.now();
|
|
866
|
+
try {
|
|
867
|
+
const result = await handler(request, extra);
|
|
868
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
869
|
+
metricsRecorder?.recordOperationDuration(method, performance.now() - startTime);
|
|
870
|
+
return result;
|
|
871
|
+
} catch (err) {
|
|
872
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
873
|
+
const errorType = String(err?.name ?? 'Error').slice(0, MAX_ERROR_CLASS_LENGTH);
|
|
874
|
+
span.setAttribute(ATTR_ERROR_TYPE, errorType);
|
|
875
|
+
if (fingerprintingEnabled) {
|
|
876
|
+
const failure = computeFingerprint(err, { toolName: undefined, origin: 'thrown', cwd: process.cwd() });
|
|
877
|
+
span.setAttributes(toSpanAttributes(failure));
|
|
878
|
+
span.setAttribute(ATTRIBUTE_KEYS.CHANNEL, classifyFailureChannel(err));
|
|
879
|
+
}
|
|
880
|
+
metricsRecorder?.recordOperationDuration(method, performance.now() - startTime, errorType);
|
|
881
|
+
throw err;
|
|
882
|
+
} finally {
|
|
883
|
+
span.end();
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
667
889
|
/**
|
|
668
890
|
* Throws INSTRUMENT_FIRST_ERROR if a tools/call handler — or, when schema
|
|
669
891
|
* drift detection is enabled, a tools/list handler — is already
|
|
@@ -811,10 +1033,25 @@ function setupTracer(server, resolved) {
|
|
|
811
1033
|
}
|
|
812
1034
|
}
|
|
813
1035
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1036
|
+
// Memoized (ADR 024): the first call — from the host, or from
|
|
1037
|
+
// flushOnExit's process listeners — creates the promise; every later
|
|
1038
|
+
// call gets the same one, so the providers are shut down exactly once.
|
|
1039
|
+
let shutdownPromise = null;
|
|
1040
|
+
let unregisterFlushOnExit = null;
|
|
1041
|
+
const shutdown = () => {
|
|
1042
|
+
if (!shutdownPromise) {
|
|
1043
|
+
unregisterFlushOnExit?.();
|
|
1044
|
+
shutdownPromise = (async () => {
|
|
1045
|
+
await provider.shutdown();
|
|
1046
|
+
if (meterProvider) await meterProvider.shutdown();
|
|
1047
|
+
})();
|
|
1048
|
+
}
|
|
1049
|
+
return shutdownPromise;
|
|
817
1050
|
};
|
|
1051
|
+
server.shutdown = shutdown;
|
|
1052
|
+
if (resolved.flushOnExit) {
|
|
1053
|
+
unregisterFlushOnExit = registerFlushOnExit(shutdown, resolved.flushOnExit.timeoutMs);
|
|
1054
|
+
}
|
|
818
1055
|
}
|
|
819
1056
|
|
|
820
1057
|
// Always the final step: when setupNodeSdk is false, this picks up
|
|
@@ -1712,6 +1949,7 @@ function wrapToolCallHandler(
|
|
|
1712
1949
|
errorRecordingConfig,
|
|
1713
1950
|
costAttributionState,
|
|
1714
1951
|
errorRecordingState,
|
|
1952
|
+
unactionableErrorsConfig,
|
|
1715
1953
|
) {
|
|
1716
1954
|
return (request, extra) => {
|
|
1717
1955
|
const toolName = request?.params?.name;
|
|
@@ -1772,6 +2010,10 @@ function wrapToolCallHandler(
|
|
|
1772
2010
|
if (isToolResultError(result)) {
|
|
1773
2011
|
span.setAttribute(ATTR_ERROR_TYPE, ERROR_TYPE_TOOL_ERROR);
|
|
1774
2012
|
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
2013
|
+
// ADR 025: content SHAPE only (lengths, item counts), own
|
|
2014
|
+
// try/catch, before fingerprinting so a classifier problem can't
|
|
2015
|
+
// suppress it. Doesn't feed the fingerprint or the category.
|
|
2016
|
+
applyUnactionableAttributes(span, result, unactionableErrorsConfig);
|
|
1775
2017
|
|
|
1776
2018
|
let failureCategory = '';
|
|
1777
2019
|
let failure = null;
|
package/src/metrics.js
CHANGED
|
@@ -76,6 +76,7 @@ import { ATTRIBUTE_KEYS } from './fingerprint/attributes.js';
|
|
|
76
76
|
* recordDuration: (toolName: string | undefined, durationMs: number, outcome: string, failureCategory?: string) => void,
|
|
77
77
|
* recordTokens: (toolName: string | undefined, model: string | undefined, totalTokens: number, pricingStatus: string) => void,
|
|
78
78
|
* recordCost: (toolName: string | undefined, model: string | undefined, costUsd: number, pricingStatus: string) => void,
|
|
79
|
+
* recordOperationDuration: (method: string, durationMs: number, errorType?: string) => void,
|
|
79
80
|
* }}
|
|
80
81
|
*/
|
|
81
82
|
export function setupMeter(packageVersion) {
|
|
@@ -103,8 +104,22 @@ export function setupMeter(packageVersion) {
|
|
|
103
104
|
description: 'Total estimated cost of MCP tool calls (see src/cost/calculator.js).',
|
|
104
105
|
unit: 'USD',
|
|
105
106
|
});
|
|
107
|
+
// ADR 026 (v0.16.0): resources/* and prompts/* handler durations, only
|
|
108
|
+
// recorded when that opt-in coverage is on. Labeled by mcp.method.name
|
|
109
|
+
// (one of five fixed constants) and, on failure, error.type: no new
|
|
110
|
+
// label keys, and nothing request-derived (no URI, no prompt name).
|
|
111
|
+
const operationDuration = meter.createHistogram('mcp.server.operation.duration', {
|
|
112
|
+
description: 'Duration of MCP resources/* and prompts/* request handling (opt-in coverage, ADR 026).',
|
|
113
|
+
unit: 'ms',
|
|
114
|
+
});
|
|
106
115
|
|
|
107
116
|
return {
|
|
117
|
+
recordOperationDuration(method, durationMs, errorType) {
|
|
118
|
+
operationDuration.record(durationMs, {
|
|
119
|
+
[ATTR_MCP_METHOD_NAME]: method,
|
|
120
|
+
...(errorType ? { [ATTR_ERROR_TYPE]: errorType } : {}),
|
|
121
|
+
});
|
|
122
|
+
},
|
|
108
123
|
recordCall(toolName) {
|
|
109
124
|
calls.add(1, {
|
|
110
125
|
[ATTR_GEN_AI_TOOL_NAME]: toolName,
|
package/src/sdk/detect.js
CHANGED
|
@@ -74,13 +74,20 @@ const [v1ServerMod, v1TypesMod, v2Mod] = await Promise.all([
|
|
|
74
74
|
tryImport('@modelcontextprotocol/server'),
|
|
75
75
|
]);
|
|
76
76
|
|
|
77
|
-
/** @type {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown } | null} */
|
|
77
|
+
/** @type {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown, [schema: string]: unknown } | null} */
|
|
78
78
|
const v1Sdk =
|
|
79
79
|
v1ServerMod && v1TypesMod
|
|
80
80
|
? {
|
|
81
81
|
Server: v1ServerMod.Server,
|
|
82
82
|
CallToolRequestSchema: v1TypesMod.CallToolRequestSchema,
|
|
83
83
|
ListToolsRequestSchema: v1TypesMod.ListToolsRequestSchema,
|
|
84
|
+
// ADR 026 (opt-in coverage): v1 dispatches by schema identity, so
|
|
85
|
+
// each resource/prompt method needs its schema object too.
|
|
86
|
+
ReadResourceRequestSchema: v1TypesMod.ReadResourceRequestSchema,
|
|
87
|
+
ListResourcesRequestSchema: v1TypesMod.ListResourcesRequestSchema,
|
|
88
|
+
ListResourceTemplatesRequestSchema: v1TypesMod.ListResourceTemplatesRequestSchema,
|
|
89
|
+
GetPromptRequestSchema: v1TypesMod.GetPromptRequestSchema,
|
|
90
|
+
ListPromptsRequestSchema: v1TypesMod.ListPromptsRequestSchema,
|
|
84
91
|
}
|
|
85
92
|
: null;
|
|
86
93
|
|
|
@@ -101,7 +108,7 @@ const v2Sdk = v2Mod ? { Server: v2Mod.Server } : null;
|
|
|
101
108
|
* Resolves `@modelcontextprotocol/sdk` (v1). `null` when not installed (an
|
|
102
109
|
* optional peer dependency — see this module's docblock).
|
|
103
110
|
*
|
|
104
|
-
* @returns {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown } | null}
|
|
111
|
+
* @returns {{ Server: Function, CallToolRequestSchema: unknown, ListToolsRequestSchema: unknown, [schema: string]: unknown } | null}
|
|
105
112
|
*/
|
|
106
113
|
export function getV1Sdk() {
|
|
107
114
|
return v1Sdk;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module unactionable
|
|
3
|
+
*
|
|
4
|
+
* ADR 025 (docs/adr/025-unactionable-errors.md): flag tool errors that give
|
|
5
|
+
* the agent nothing to act on — `isError: true` with empty or near-empty
|
|
6
|
+
* content.
|
|
7
|
+
*
|
|
8
|
+
* PRIVACY: computed only from the SHAPE of `result.content` — how many
|
|
9
|
+
* items are text vs. non-text, and the whitespace-trimmed LENGTH of the
|
|
10
|
+
* text items. Strings are read only to be measured; no text is stored,
|
|
11
|
+
* hashed, compared, or emitted. The two attributes produced have fixed,
|
|
12
|
+
* tiny value domains (a boolean, and one of five bucket names).
|
|
13
|
+
*
|
|
14
|
+
* Span-only. Neither attribute is a metric label (ADR 025 "Metric"); they
|
|
15
|
+
* are deliberately NOT in any METRIC_SAFE_ATTRIBUTES list.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Boolean: the tool error gave the agent nothing to act on. */
|
|
19
|
+
export const ATTR_MCP_FAILURE_UNACTIONABLE = 'mcp.failure.unactionable';
|
|
20
|
+
|
|
21
|
+
/** One of CONTENT_LENGTH_BUCKETS: the trimmed text length of the error, bucketed. */
|
|
22
|
+
export const ATTR_MCP_FAILURE_CONTENT_LENGTH_BUCKET = 'mcp.failure.content_length_bucket';
|
|
23
|
+
|
|
24
|
+
/** The only values `mcp.failure.content_length_bucket` ever takes. */
|
|
25
|
+
export const CONTENT_LENGTH_BUCKETS = Object.freeze(['empty', 'tiny', 'short', 'medium', 'long']);
|
|
26
|
+
|
|
27
|
+
const SHORT_LIMIT = 80;
|
|
28
|
+
const MEDIUM_LIMIT = 500;
|
|
29
|
+
|
|
30
|
+
const ENV_PREFIX = 'OTEL_MCP_UNACTIONABLE_ERRORS_';
|
|
31
|
+
const DEFAULTS = Object.freeze({ enabled: true, minTextLength: 10 });
|
|
32
|
+
const MAX_MIN_TEXT_LENGTH = 200;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} UnactionableErrorsConfig
|
|
36
|
+
* @property {boolean} enabled
|
|
37
|
+
* @property {number} minTextLength
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
function readEnvBoolean(name) {
|
|
41
|
+
const raw = process.env[name];
|
|
42
|
+
if (raw === 'true' || raw === '1') return true;
|
|
43
|
+
if (raw === 'false' || raw === '0') return false;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readEnvInt(name) {
|
|
48
|
+
const raw = process.env[name];
|
|
49
|
+
if (raw === undefined || raw.trim() === '') return undefined;
|
|
50
|
+
const parsed = Number(raw);
|
|
51
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Same partial-overrides-individual-defaults behavior, and the same
|
|
56
|
+
* OTEL_MCP_<FEATURE>_ env-var fallback, as resolveSchemaDriftConfig().
|
|
57
|
+
* Invalid values fall back to the defaults; never throws.
|
|
58
|
+
*
|
|
59
|
+
* @param {Partial<UnactionableErrorsConfig> | undefined} partial
|
|
60
|
+
* @returns {UnactionableErrorsConfig}
|
|
61
|
+
*/
|
|
62
|
+
export function resolveUnactionableErrorsConfig(partial) {
|
|
63
|
+
const p = partial !== null && typeof partial === 'object' ? partial : {};
|
|
64
|
+
const enabled = p.enabled !== undefined ? p.enabled : readEnvBoolean(`${ENV_PREFIX}ENABLED`);
|
|
65
|
+
const minTextLength = p.minTextLength !== undefined ? p.minTextLength : readEnvInt(`${ENV_PREFIX}MIN_TEXT_LENGTH`);
|
|
66
|
+
return {
|
|
67
|
+
enabled: typeof enabled === 'boolean' ? enabled : DEFAULTS.enabled,
|
|
68
|
+
minTextLength:
|
|
69
|
+
typeof minTextLength === 'number' &&
|
|
70
|
+
Number.isInteger(minTextLength) &&
|
|
71
|
+
minTextLength >= 0 &&
|
|
72
|
+
minTextLength <= MAX_MIN_TEXT_LENGTH
|
|
73
|
+
? minTextLength
|
|
74
|
+
: DEFAULTS.minTextLength,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {unknown} result a CallToolResult with isError: true
|
|
80
|
+
* @param {number} minTextLength
|
|
81
|
+
* @returns {{ unactionable: boolean, bucket: string }}
|
|
82
|
+
*/
|
|
83
|
+
export function assessToolErrorContent(result, minTextLength) {
|
|
84
|
+
const content = /** @type {any} */ (result)?.content;
|
|
85
|
+
let textLength = 0;
|
|
86
|
+
let nonText = 0;
|
|
87
|
+
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
for (const item of content) {
|
|
90
|
+
if (item === null || typeof item !== 'object') continue;
|
|
91
|
+
if (item.type === 'text') {
|
|
92
|
+
if (typeof item.text === 'string') textLength += item.text.trim().length;
|
|
93
|
+
} else {
|
|
94
|
+
// image, audio, resource, resource_link, or anything else typed:
|
|
95
|
+
// the agent was handed something other than prose.
|
|
96
|
+
nonText++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let bucket;
|
|
102
|
+
if (textLength === 0) bucket = 'empty';
|
|
103
|
+
else if (textLength < minTextLength) bucket = 'tiny';
|
|
104
|
+
else if (textLength < SHORT_LIMIT) bucket = 'short';
|
|
105
|
+
else if (textLength < MEDIUM_LIMIT) bucket = 'medium';
|
|
106
|
+
else bucket = 'long';
|
|
107
|
+
|
|
108
|
+
const unactionable = nonText === 0 && (textLength === 0 || textLength < minTextLength);
|
|
109
|
+
return { unactionable, bucket };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sets both attributes on `span` for an isError: true result. Never throws:
|
|
114
|
+
* on any failure both attributes are omitted rather than guessed.
|
|
115
|
+
*
|
|
116
|
+
* @param {import('@opentelemetry/api').Span} span
|
|
117
|
+
* @param {unknown} result
|
|
118
|
+
* @param {UnactionableErrorsConfig} config
|
|
119
|
+
*/
|
|
120
|
+
export function applyUnactionableAttributes(span, result, config) {
|
|
121
|
+
try {
|
|
122
|
+
if (!config?.enabled) return;
|
|
123
|
+
const { unactionable, bucket } = assessToolErrorContent(result, config.minTextLength);
|
|
124
|
+
span.setAttribute(ATTR_MCP_FAILURE_UNACTIONABLE, unactionable);
|
|
125
|
+
span.setAttribute(ATTR_MCP_FAILURE_CONTENT_LENGTH_BUCKET, bucket);
|
|
126
|
+
} catch {
|
|
127
|
+
// Never throw into the tool-call path (ADR 025 "Order").
|
|
128
|
+
}
|
|
129
|
+
}
|