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 +21 -0
- package/README.md +144 -0
- package/package.json +60 -0
- package/src/attributes.js +65 -0
- package/src/config.js +40 -0
- package/src/exporters/stderr.js +40 -0
- package/src/index.js +1 -0
- package/src/instrument.js +262 -0
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
|
+
[](https://github.com/Thirumalaiboobathi/opentel-mcp/actions/workflows/ci.yml)
|
|
8
|
+
[](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
|
+
}
|