opentel-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thirumalaiboobathi B
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # opentel-mcp
2
+
3
+ > OpenTelemetry instrumentation for Model Context Protocol (MCP) servers.
4
+ > One-line visibility into which tools your AI agent is calling, how
5
+ > long they take, and which ones fail — via standard OTel traces.
6
+
7
+ [![CI](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
8
+ [![license](https://img.shields.io/npm/l/opentel-mcp.svg)](https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/LICENSE)
9
+
10
+ **Status: pre-release. Not yet published to npm.**
11
+
12
+ ## Why
13
+
14
+ When Claude Code calls 15 MCP tools across 3 servers, you have zero
15
+ visibility into which was slow, which errored silently, which sequence
16
+ ran. opentel-mcp wraps any MCP server and emits one OTel span per tool
17
+ invocation with rich attributes, using standard OpenTelemetry APIs so
18
+ it plugs into your existing observability stack (Jaeger, Grafana Tempo,
19
+ Honeycomb, Datadog, whatever).
20
+
21
+ ## Quickstart (5-line usage)
22
+
23
+ Works with either the low-level `Server` API:
24
+
25
+ ```js
26
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
27
+ import { instrumentMcpServer } from 'opentel-mcp';
28
+
29
+ const server = new Server({ name: 'my-server', version: '1.0.0' }, {
30
+ capabilities: { tools: {} }
31
+ });
32
+
33
+ instrumentMcpServer(server, {
34
+ serviceName: 'my-mcp-server',
35
+ setupNodeSdk: true, // dev-friendly stderr output; omit in prod
36
+ // if you already have OTel configured
37
+ });
38
+
39
+ // ...register tools as usual...
40
+ server.setRequestHandler(CallToolRequestSchema, async (request) => { /*...*/ });
41
+ ```
42
+
43
+ ...or the high-level `McpServer` API most servers actually use:
44
+
45
+ ```js
46
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
47
+ import { instrumentMcpServer } from 'opentel-mcp';
48
+
49
+ const server = new McpServer({ name: 'my-server', version: '1.0.0' });
50
+
51
+ instrumentMcpServer(server, {
52
+ serviceName: 'my-mcp-server',
53
+ setupNodeSdk: true,
54
+ });
55
+
56
+ // ...register tools as usual...
57
+ server.tool('my-tool', async (args) => { /*...*/ });
58
+ ```
59
+
60
+ Either way, `instrumentMcpServer()` must run before any tool is
61
+ registered — see "Ordering constraint" below.
62
+
63
+ ## Semantic conventions
64
+
65
+ opentel-mcp follows the [MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
66
+ published by the OTel GenAI SIG (they moved there from the main
67
+ `semantic-conventions` repo, where the MCP conventions are now marked
68
+ deprecated). **That spec's status is Development, not Stable** — attribute
69
+ names and requirement levels may still change upstream, and this package
70
+ will follow suit when they do. See ADR 004 in `docs/adr/` for the full
71
+ reasoning.
72
+
73
+ One attribute, `mcp.tool.argument_count`, is **not** part of the spec — it's
74
+ our own addition, documented as such in `src/attributes.js`. It's a
75
+ privacy-preserving alternative to the spec's opt-in
76
+ `gen_ai.tool.call.arguments`: it gives shape/anomaly signal (argument count
77
+ changed) without capturing any argument values.
78
+
79
+ ## Span attributes emitted
80
+
81
+ Span (server-side `tools/call` handling) follows the spec's MCP server
82
+ span: name `{mcp.method.name} {tool name}` (e.g. `tools/call echo`, falling
83
+ back to just `mcp.method.name` when no tool name is available), kind
84
+ `SERVER`, status `ERROR` whenever `error.type` is set.
85
+
86
+ | Attribute | Requirement Level | Description | Example |
87
+ |---|---|---|---|
88
+ | mcp.method.name | Required | JSON-RPC method name | "tools/call" |
89
+ | gen_ai.tool.name | Conditionally Required | Tool name from request | "echo" |
90
+ | gen_ai.operation.name | Recommended | GenAI operation type | "execute_tool" |
91
+ | jsonrpc.request.id | Conditionally Required | JSON-RPC request id (string) | "abc-123" |
92
+ | error.type | Conditionally Required (on failure) | Error class name, or `"tool_error"` when the tool call itself returned `isError: true` | "TypeError" |
93
+ | mcp.tool.argument_count | **Custom — not spec** | Number of arguments (values not captured) | 2 |
94
+
95
+ Span status description carries the error message on failure (thrown
96
+ errors); no separate error-message attribute is emitted — the spec
97
+ expresses success/failure through span status, not a status attribute.
98
+
99
+ ## Two modes
100
+
101
+ **One-line (dev):** `setupNodeSdk: true` sets up a NodeTracerProvider
102
+ that prints spans to stderr (safe alongside stdio-transport MCP servers —
103
+ see ADR 003), optionally + an OTLP exporter if `exporterUrl` is provided.
104
+ No separate OTel SDK setup needed — `serviceName` is still required.
105
+
106
+ **Bring-your-own-SDK (prod):** Omit `setupNodeSdk` (default false).
107
+ opentel-mcp uses whatever tracer provider you've already registered
108
+ via `trace.setGlobalTracerProvider()`. This means it plugs into any
109
+ existing OTel setup without conflict.
110
+
111
+ ## Ordering constraint
112
+
113
+ Call `instrumentMcpServer()` BEFORE registering any tool handlers —
114
+ before `server.setRequestHandler(CallToolRequestSchema, ...)` (low-level
115
+ `Server`) or before any `.tool()`/`.registerTool()` call (`McpServer`).
116
+ See ADR 002 in docs/adr/ for why.
117
+
118
+ ## Compatibility
119
+
120
+ - Node.js 20+
121
+ - Windows, macOS, Linux (CI matrix tested)
122
+ - Pure JavaScript, zero native dependencies
123
+ - Supports both low-level `Server` and high-level `McpServer` APIs
124
+ - @modelcontextprotocol/sdk ^1.0.0
125
+ - @opentelemetry/api ^1.9.0
126
+
127
+ ## Roadmap
128
+
129
+ - v0.2: OTel metrics (`mcp.server.operation.duration`,
130
+ `mcp.server.session.duration`) alongside traces, plus opt-in
131
+ `gen_ai.tool.call.arguments` support with a redaction callback
132
+ - v0.3: W3C trace context propagation via `params._meta` per
133
+ [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta),
134
+ plus client-side instrumentation, so a single trace can span the client
135
+ call and the server's tool execution
136
+
137
+ ## Contributing
138
+
139
+ See CONTRIBUTING.md and docs/adr/ for architecture decisions.
140
+ Issues and PRs welcome.
141
+
142
+ ## License
143
+
144
+ MIT © Thirumalaiboobathi B
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "opentel-mcp",
3
+ "version": "0.1.0",
4
+ "description": "One-line OpenTelemetry instrumentation for Model Context Protocol (MCP) servers",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "workspaces": [
19
+ "examples/hello-server",
20
+ "examples/hello-mcpserver"
21
+ ],
22
+ "scripts": {
23
+ "test": "vitest run",
24
+ "test:watch": "vitest"
25
+ },
26
+ "keywords": [
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "opentelemetry",
30
+ "otel",
31
+ "observability",
32
+ "tracing",
33
+ "ai-agents",
34
+ "claude"
35
+ ],
36
+ "author": "Thirumalaiboobathi B",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/Thirumalaiboobathi/opentel-mcp.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/Thirumalaiboobathi/opentel-mcp/issues"
44
+ },
45
+ "homepage": "https://github.com/Thirumalaiboobathi/opentel-mcp#readme",
46
+ "peerDependencies": {
47
+ "@modelcontextprotocol/sdk": ">=1.0.0",
48
+ "@opentelemetry/api": "^1.9.0"
49
+ },
50
+ "dependencies": {
51
+ "@opentelemetry/sdk-trace-node": "^2.9.0",
52
+ "@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
53
+ "@opentelemetry/resources": "^2.9.0"
54
+ },
55
+ "devDependencies": {
56
+ "vitest": "^2.1.8",
57
+ "@opentelemetry/api": "^1.9.0",
58
+ "@opentelemetry/sdk-trace-base": "^2.9.0"
59
+ }
60
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @module attributes
3
+ * Semantic attribute constants for opentel-mcp spans.
4
+ *
5
+ * Names follow the MCP semantic conventions published by the OTel GenAI
6
+ * SIG in open-telemetry/semantic-conventions-genai (the conventions moved
7
+ * there from the main semantic-conventions repo, where they're now marked
8
+ * deprecated). That spec's status is Development, not Stable — see ADR 004
9
+ * for what that means for this package and why we're aligning to it now
10
+ * anyway.
11
+ */
12
+
13
+ // --- Spec attributes (MCP semconv, server span) ---
14
+
15
+ /** Required. The JSON-RPC method name, e.g. "tools/call". */
16
+ export const ATTR_MCP_METHOD_NAME = 'mcp.method.name';
17
+
18
+ /** Conditionally Required (when the operation targets a specific tool). */
19
+ export const ATTR_GEN_AI_TOOL_NAME = 'gen_ai.tool.name';
20
+
21
+ /** Conditionally Required (when the client executes a request with a non-null id). */
22
+ export const ATTR_JSONRPC_REQUEST_ID = 'jsonrpc.request.id';
23
+
24
+ /**
25
+ * Conditionally Required iff the operation fails — either a thrown
26
+ * exception or a successful JSON-RPC response whose CallToolResult carries
27
+ * isError: true, in which case this is set to ERROR_TYPE_TOOL_ERROR.
28
+ */
29
+ export const ATTR_ERROR_TYPE = 'error.type';
30
+
31
+ /**
32
+ * Recommended. SHOULD be "execute_tool" for tool calls, SHOULD NOT be set
33
+ * otherwise. Lets consumers treat MCP tool-call spans like other GenAI
34
+ * tool-call spans.
35
+ */
36
+ export const ATTR_GEN_AI_OPERATION_NAME = 'gen_ai.operation.name';
37
+
38
+ /**
39
+ * Well-known error.type value for a JSON-RPC call that succeeded but whose
40
+ * CallToolResult has isError: true — a tool-level failure, not a transport
41
+ * or protocol error.
42
+ */
43
+ export const ERROR_TYPE_TOOL_ERROR = 'tool_error';
44
+
45
+ /** Well-known gen_ai.operation.name value for tool execution. */
46
+ export const GEN_AI_OPERATION_NAME_EXECUTE_TOOL = 'execute_tool';
47
+
48
+ /** Well-known mcp.method.name value for a tools/call request. */
49
+ export const MCP_METHOD_NAME_TOOLS_CALL = 'tools/call';
50
+
51
+ // --- Custom (non-spec) attributes ---
52
+
53
+ /**
54
+ * NOT part of the MCP semantic conventions. Our own addition: a
55
+ * privacy-preserving alternative to the spec's opt-in
56
+ * gen_ai.tool.call.arguments attribute (which captures full argument
57
+ * values and is therefore Opt-In due to sensitivity). Recording just the
58
+ * count gives shape/anomaly signal (e.g. "this call suddenly has 0 args")
59
+ * without capturing any argument content. See ADR 004 and the README's
60
+ * "Semantic conventions" section.
61
+ */
62
+ export const ATTR_MCP_TOOL_ARGUMENT_COUNT = 'mcp.tool.argument_count';
63
+
64
+ export const ATTR_MCP_SERVER_NAME = 'mcp.server.name';
65
+ export const ATTR_MCP_SERVER_VERSION = 'mcp.server.version';
package/src/config.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @module config
3
+ * Options parsing and defaults for instrumentMcpServer().
4
+ */
5
+
6
+ /**
7
+ * @typedef {object} InstrumentOptions
8
+ * @property {string} serviceName - Required. Identifies this server in emitted telemetry.
9
+ * @property {string} [exporterUrl] - OTLP/HTTP traces endpoint (e.g. 'http://localhost:4318/v1/traces').
10
+ * Only takes effect when `setupNodeSdk` is true.
11
+ * @property {boolean} [enabled=true] - Set to false to disable instrumentation entirely; instrumentMcpServer()
12
+ * becomes a no-op.
13
+ * @property {boolean} [setupNodeSdk=false] - When true, instrumentMcpServer() creates and registers its own
14
+ * NodeTracerProvider (always exporting to stderr — safe alongside stdio-transport MCP servers, see ADR 003;
15
+ * additionally to `exporterUrl` via OTLP/HTTP if set). When false (the default), spans are emitted via
16
+ * whatever OpenTelemetry TracerProvider the host application
17
+ * has already registered globally — or dropped silently if none has been registered. This default keeps
18
+ * instrumentMcpServer() from ever overriding a host application's own OpenTelemetry setup.
19
+ */
20
+
21
+ /**
22
+ * Validates and applies defaults to raw instrumentMcpServer() options.
23
+ *
24
+ * @param {InstrumentOptions} [options]
25
+ * @returns {Required<InstrumentOptions>}
26
+ */
27
+ export function resolveOptions(options) {
28
+ const opts = options ?? {};
29
+
30
+ if (typeof opts.serviceName !== 'string' || opts.serviceName.trim() === '') {
31
+ throw new Error('opentel-mcp: options.serviceName is required and must be a non-empty string.');
32
+ }
33
+
34
+ return {
35
+ serviceName: opts.serviceName,
36
+ exporterUrl: opts.exporterUrl,
37
+ enabled: opts.enabled ?? true,
38
+ setupNodeSdk: opts.setupNodeSdk ?? false,
39
+ };
40
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @module exporters/stderr
3
+ * A SpanExporter that prints spans to stderr rather than stdout.
4
+ *
5
+ * stdio-transport MCP servers write their JSON-RPC protocol messages to
6
+ * stdout, so a diagnostic exporter sharing that stream would corrupt the
7
+ * protocol for a real client (see ADR 003). Modeled on
8
+ * @opentelemetry/sdk-trace's ConsoleSpanExporter, but writes via
9
+ * console.error (stderr) instead of console.dir (stdout).
10
+ */
11
+
12
+ export class StderrSpanExporter {
13
+ export(spans, resultCallback) {
14
+ for (const span of spans) {
15
+ console.error({
16
+ resource: { attributes: span.resource.attributes },
17
+ traceId: span.spanContext().traceId,
18
+ name: span.name,
19
+ kind: span.kind,
20
+ id: span.spanContext().spanId,
21
+ timestamp: span.startTime,
22
+ duration: span.duration,
23
+ attributes: span.attributes,
24
+ status: span.status,
25
+ events: span.events,
26
+ });
27
+ }
28
+ // 0 === ExportResultCode.SUCCESS (@opentelemetry/core) — inlined to
29
+ // avoid adding that package as a dependency for one enum value.
30
+ resultCallback({ code: 0 });
31
+ }
32
+
33
+ shutdown() {
34
+ return Promise.resolve();
35
+ }
36
+
37
+ forceFlush() {
38
+ return Promise.resolve();
39
+ }
40
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { instrumentMcpServer } from './instrument.js';
@@ -0,0 +1,262 @@
1
+ /**
2
+ * @module instrument
3
+ */
4
+
5
+ import { createRequire } from 'node:module';
6
+ import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
7
+ import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import { NodeTracerProvider, SimpleSpanProcessor, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
10
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
11
+ import { resourceFromAttributes } from '@opentelemetry/resources';
12
+ import { resolveOptions } from './config.js';
13
+ import { StderrSpanExporter } from './exporters/stderr.js';
14
+ import {
15
+ ATTR_MCP_METHOD_NAME,
16
+ ATTR_GEN_AI_TOOL_NAME,
17
+ ATTR_GEN_AI_OPERATION_NAME,
18
+ ATTR_JSONRPC_REQUEST_ID,
19
+ ATTR_MCP_TOOL_ARGUMENT_COUNT,
20
+ ATTR_ERROR_TYPE,
21
+ ERROR_TYPE_TOOL_ERROR,
22
+ GEN_AI_OPERATION_NAME_EXECUTE_TOOL,
23
+ MCP_METHOD_NAME_TOOLS_CALL,
24
+ } from './attributes.js';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const { version: PACKAGE_VERSION } = require('../package.json');
28
+
29
+ // Protocol-level JSON-RPC method name. Deliberately hardcoded rather than
30
+ // derived from CallToolRequestSchema's zod internals — see ADR 001. Reused
31
+ // as the mcp.method.name attribute value and span-name prefix (ADR 004).
32
+ const TOOLS_CALL_METHOD = MCP_METHOD_NAME_TOOLS_CALL;
33
+
34
+ // Symbol.for(): must be visible across duplicate installs of this package
35
+ // (e.g. monorepos with dedup issues), not just within one module instance.
36
+ const kInstrumented = Symbol.for('opentel-mcp/instrumented');
37
+
38
+ const UNSUPPORTED_INPUT_ERROR =
39
+ 'opentel-mcp: instrumentMcpServer() expects either a low-level Server ' +
40
+ 'instance (from @modelcontextprotocol/sdk/server/index.js) or a ' +
41
+ 'high-level McpServer instance (from @modelcontextprotocol/sdk/server/mcp.js).';
42
+
43
+ const INSTRUMENT_FIRST_ERROR =
44
+ 'opentel-mcp: instrumentMcpServer() must be called BEFORE registering ' +
45
+ 'tool handlers. Move instrumentMcpServer(server, options) to immediately ' +
46
+ 'after `new Server(...)`, before any ' +
47
+ 'server.setRequestHandler(CallToolRequestSchema, ...) calls (low-level ' +
48
+ 'Server) or .tool()/.registerTool() calls (McpServer).';
49
+
50
+ /**
51
+ * Detects whether `input` is a low-level Server or a high-level McpServer,
52
+ * without importing McpServer directly. Importing it would risk the same
53
+ * dual-package-hazard class of bug the hello-server example hit (two
54
+ * independently-installed copies of @modelcontextprotocol/sdk producing
55
+ * two distinct classes, so `instanceof` silently fails) — duck-typing
56
+ * sidesteps that and stays tolerant of SDK versions that shuffle McpServer's
57
+ * internals, since only its public, documented shape is checked: a `.server`
58
+ * object that itself looks like a low-level Server (has a `setRequestHandler`
59
+ * function), plus a `.tool` or `.registerTool` function on the outer object.
60
+ * The low-level Server case still uses `instanceof` since Server is already
61
+ * imported directly for other purposes (ADR 001).
62
+ *
63
+ * @param {unknown} input
64
+ * @returns {{ server: object, outer?: object } | null}
65
+ */
66
+ function detectServerKind(input) {
67
+ if (input instanceof Server) {
68
+ return { server: input };
69
+ }
70
+ if (
71
+ input &&
72
+ typeof input === 'object' &&
73
+ input.server &&
74
+ typeof input.server.setRequestHandler === 'function' &&
75
+ (typeof input.tool === 'function' || typeof input.registerTool === 'function')
76
+ ) {
77
+ return { server: input.server, outer: input };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Instruments an MCP server so every tool call emits an OpenTelemetry span.
84
+ *
85
+ * Accepts either a low-level Server or a high-level McpServer (see
86
+ * detectServerKind above); McpServer is unwrapped to its inner Server,
87
+ * which is what's actually patched. Must be called before any `tools/call`
88
+ * handler is registered — i.e. before any `server.setRequestHandler(CallToolRequestSchema, ...)`
89
+ * (low-level) or `.tool()`/`.registerTool()` (McpServer) calls (see ADR 001
90
+ * and ADR 002 in docs/adr/ for why). Idempotent: calling this more than
91
+ * once — on the same object, or on the outer McpServer and its inner
92
+ * Server interchangeably — is a no-op after the first call.
93
+ *
94
+ * @param {import('@modelcontextprotocol/sdk/server/index.js').Server | import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
95
+ * @param {import('./config.js').InstrumentOptions} options
96
+ * @returns {*} The same object that was passed in, for chaining. When
97
+ * `options.setupNodeSdk` is true, the inner Server (and, when instrumenting
98
+ * an McpServer, the outer object too) gets a `shutdown()` method that
99
+ * flushes and shuts down the NodeTracerProvider created for it — call it
100
+ * during your process's own shutdown sequence to avoid losing buffered
101
+ * spans. When `setupNodeSdk` is false (the default), no `shutdown()` is
102
+ * attached; lifecycle of the global provider belongs to whoever
103
+ * registered it.
104
+ */
105
+ export function instrumentMcpServer(input, options) {
106
+ const detected = detectServerKind(input);
107
+ if (!detected) {
108
+ throw new Error(UNSUPPORTED_INPUT_ERROR);
109
+ }
110
+
111
+ const { server, outer } = detected;
112
+
113
+ if ((outer && outer[kInstrumented]) || server[kInstrumented]) {
114
+ // Sync the guard onto both objects in case only one was marked so far
115
+ // (e.g. the inner Server was instrumented directly once before, and
116
+ // this call is the first time the outer McpServer wrapping it is seen).
117
+ server[kInstrumented] = true;
118
+ if (outer) outer[kInstrumented] = true;
119
+ return input;
120
+ }
121
+
122
+ const resolved = resolveOptions(options);
123
+
124
+ if (!resolved.enabled) {
125
+ server[kInstrumented] = true;
126
+ if (outer) outer[kInstrumented] = true;
127
+ return input;
128
+ }
129
+
130
+ assertInstrumentFirst(server);
131
+
132
+ const tracer = setupTracer(server, resolved);
133
+ if (outer && server.shutdown) {
134
+ outer.shutdown = server.shutdown;
135
+ }
136
+
137
+ const originalSetRequestHandler = server.setRequestHandler.bind(server);
138
+ server.setRequestHandler = (schema, handler) => {
139
+ if (schema === CallToolRequestSchema) {
140
+ handler = wrapToolCallHandler(handler, tracer);
141
+ }
142
+ return originalSetRequestHandler(schema, handler);
143
+ };
144
+
145
+ server[kInstrumented] = true;
146
+ if (outer) outer[kInstrumented] = true;
147
+ return input;
148
+ }
149
+
150
+ /**
151
+ * Throws INSTRUMENT_FIRST_ERROR if a tools/call handler is already
152
+ * registered on `server`. Uses the SDK's own public
153
+ * assertCanSetRequestHandler(method) — the same check McpServer uses
154
+ * internally — rather than reaching into the private _requestHandlers Map.
155
+ * Feature-detected so a future SDK version that removes this method
156
+ * degrades to relying on docs + the idempotency guard alone (see ADR 002).
157
+ * Works identically whether `server` came from a low-level Server or was
158
+ * unwrapped from an McpServer, since McpServer's .tool()/.registerTool()
159
+ * lazily call this same server's setRequestHandler(CallToolRequestSchema)
160
+ * on first registration.
161
+ *
162
+ * @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
163
+ */
164
+ function assertInstrumentFirst(server) {
165
+ if (typeof server.assertCanSetRequestHandler !== 'function') {
166
+ return;
167
+ }
168
+ try {
169
+ server.assertCanSetRequestHandler(TOOLS_CALL_METHOD);
170
+ } catch {
171
+ throw new Error(INSTRUMENT_FIRST_ERROR);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Resolves the Tracer to use for this server, optionally standing up an
177
+ * owned NodeTracerProvider first.
178
+ *
179
+ * @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
180
+ * @param {Required<import('./config.js').InstrumentOptions>} resolved
181
+ * @returns {import('@opentelemetry/api').Tracer}
182
+ */
183
+ function setupTracer(server, resolved) {
184
+ if (resolved.setupNodeSdk) {
185
+ // StderrSpanExporter, not ConsoleSpanExporter — stdio-transport MCP
186
+ // servers write JSON-RPC to stdout, so diagnostic span output must go
187
+ // to stderr instead or it corrupts the protocol stream. See ADR 003.
188
+ const spanProcessors = [new SimpleSpanProcessor(new StderrSpanExporter())];
189
+ if (resolved.exporterUrl) {
190
+ spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({ url: resolved.exporterUrl })));
191
+ }
192
+
193
+ const provider = new NodeTracerProvider({
194
+ resource: resourceFromAttributes({ 'service.name': resolved.serviceName }),
195
+ spanProcessors,
196
+ });
197
+ provider.register();
198
+
199
+ server.shutdown = () => provider.shutdown();
200
+ }
201
+
202
+ // Always the final step: when setupNodeSdk is false, this picks up
203
+ // whatever TracerProvider the host application has already registered
204
+ // globally (or the default no-op tracer if none has), rather than
205
+ // opentel-mcp ever overriding a host's own OpenTelemetry setup.
206
+ return trace.getTracer('opentel-mcp', PACKAGE_VERSION);
207
+ }
208
+
209
+ /**
210
+ * Wraps a tools/call handler in a span covering its execution. This sits as
211
+ * the innermost layer relative to Server's own request/response validation
212
+ * wrapping (see ADR 001), so the span times exactly the real handler logic.
213
+ *
214
+ * Span shape follows the MCP semantic conventions' server span (ADR 004):
215
+ * name `{mcp.method.name} {target}` (falling back to just the method name
216
+ * when no tool name is available), kind SERVER, and status ERROR whenever
217
+ * error.type is set — which happens either because the handler threw, or
218
+ * because it resolved successfully but returned a CallToolResult with
219
+ * isError: true (a JSON-RPC-level success carrying a tool-level failure;
220
+ * the spec calls this error.type value "tool_error"). In the isError case
221
+ * the result is returned unchanged and nothing is thrown — the JSON-RPC
222
+ * call itself succeeded.
223
+ *
224
+ * @param {Function} handler
225
+ * @param {import('@opentelemetry/api').Tracer} tracer
226
+ */
227
+ function wrapToolCallHandler(handler, tracer) {
228
+ return (request, extra) => {
229
+ const toolName = request?.params?.name;
230
+ const spanName = toolName ? `${TOOLS_CALL_METHOD} ${toolName}` : TOOLS_CALL_METHOD;
231
+
232
+ return tracer.startActiveSpan(spanName, { kind: SpanKind.SERVER }, async (span) => {
233
+ const argumentCount = Object.keys(request?.params?.arguments ?? {}).length;
234
+
235
+ span.setAttribute(ATTR_MCP_METHOD_NAME, TOOLS_CALL_METHOD);
236
+ span.setAttribute(ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_EXECUTE_TOOL);
237
+ span.setAttribute(ATTR_GEN_AI_TOOL_NAME, toolName);
238
+ span.setAttribute(ATTR_MCP_TOOL_ARGUMENT_COUNT, argumentCount);
239
+ if (extra?.requestId !== undefined && extra?.requestId !== null) {
240
+ span.setAttribute(ATTR_JSONRPC_REQUEST_ID, String(extra.requestId));
241
+ }
242
+
243
+ try {
244
+ const result = await handler(request, extra);
245
+ if (result?.isError === true) {
246
+ span.setAttribute(ATTR_ERROR_TYPE, ERROR_TYPE_TOOL_ERROR);
247
+ span.setStatus({ code: SpanStatusCode.ERROR });
248
+ } else {
249
+ span.setStatus({ code: SpanStatusCode.OK });
250
+ }
251
+ return result;
252
+ } catch (err) {
253
+ span.recordException(err);
254
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
255
+ span.setAttribute(ATTR_ERROR_TYPE, err?.name ?? 'Error');
256
+ throw err;
257
+ } finally {
258
+ span.end();
259
+ }
260
+ });
261
+ };
262
+ }