Skip to content

Commit dccd668

Browse files
committed
feat(node): Add mcp server instrumentation
1 parent 8046e14 commit dccd668

File tree

4 files changed

+212
-0
lines changed

4 files changed

+212
-0
lines changed

packages/core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export { thirdPartyErrorFilterIntegration } from './integrations/third-party-err
111111
export { profiler } from './profiling';
112112
export { instrumentFetchRequest } from './fetch';
113113
export { trpcMiddleware } from './trpc';
114+
export { wrapMcpServerWithSentry } from './mcp-server';
114115
export { captureFeedback } from './feedback';
115116
export type { ReportDialogOptions } from './report-dialog';
116117
export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from './logs/exports';

packages/core/src/mcp-server.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { DEBUG_BUILD } from './debug-build';
2+
import {
3+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
4+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
5+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
6+
} from './semanticAttributes';
7+
import { startSpan } from './tracing';
8+
import { logger } from './utils-hoist';
9+
10+
interface MCPServerInstance {
11+
// The first arg is always a name, the last arg should always be a callback function (ie a handler).
12+
// TODO: We could also make use of the resource uri argument somehow.
13+
resource: (name: string, ...args: unknown[]) => void;
14+
// The first arg is always a name, the last arg should always be a callback function (ie a handler).
15+
tool: (name: string, ...args: unknown[]) => void;
16+
// The first arg is always a name, the last arg should always be a callback function (ie a handler).
17+
prompt: (name: string, ...args: unknown[]) => void;
18+
}
19+
20+
const wrappedMcpServerInstances = new WeakSet();
21+
22+
/**
23+
* Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation.
24+
*
25+
* Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package.
26+
*/
27+
// We are exposing this API for non-node runtimes that cannot rely on auto-instrumentation.
28+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29+
export function wrapMcpServerWithSentry<S>(mcpServerInstance: any): S {
30+
if (wrappedMcpServerInstances.has(mcpServerInstance)) {
31+
return mcpServerInstance;
32+
}
33+
34+
if (!isMcpServerInstance(mcpServerInstance)) {
35+
DEBUG_BUILD && logger.warn('Did not patch MCP server. Interface is incompatible.');
36+
return mcpServerInstance;
37+
}
38+
39+
mcpServerInstance.resource = new Proxy(mcpServerInstance.resource, {
40+
apply(target, thisArg, argArray) {
41+
const resourceName: unknown = argArray[0];
42+
const resourceHandler: unknown = argArray[argArray.length - 1];
43+
44+
if (typeof resourceName !== 'string' || typeof resourceHandler !== 'function') {
45+
return target.apply(thisArg, argArray);
46+
}
47+
48+
return startSpan(
49+
{
50+
name: `mcp-server/resource:${resourceName}`,
51+
forceTransaction: true,
52+
attributes: {
53+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server',
54+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server',
55+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
56+
'mcp_server.resource': resourceName,
57+
},
58+
},
59+
() => target.apply(thisArg, argArray),
60+
);
61+
},
62+
});
63+
64+
mcpServerInstance.tool = new Proxy(mcpServerInstance.tool, {
65+
apply(target, thisArg, argArray) {
66+
const toolName: unknown = argArray[0];
67+
const toolHandler: unknown = argArray[argArray.length - 1];
68+
69+
if (typeof toolName !== 'string' || typeof toolHandler !== 'function') {
70+
return target.apply(thisArg, argArray);
71+
}
72+
73+
return startSpan(
74+
{
75+
name: `mcp-server/tool:${toolName}`,
76+
forceTransaction: true,
77+
attributes: {
78+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server',
79+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server',
80+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
81+
'mcp_server.tool': toolName,
82+
},
83+
},
84+
() => target.apply(thisArg, argArray),
85+
);
86+
},
87+
});
88+
89+
mcpServerInstance.prompt = new Proxy(mcpServerInstance.prompt, {
90+
apply(target, thisArg, argArray) {
91+
const promptName: unknown = argArray[0];
92+
const promptHandler: unknown = argArray[argArray.length - 1];
93+
94+
if (typeof promptName !== 'string' || typeof promptHandler !== 'function') {
95+
return target.apply(thisArg, argArray);
96+
}
97+
98+
return startSpan(
99+
{
100+
name: `mcp-server/resource:${promptName}`,
101+
forceTransaction: true,
102+
attributes: {
103+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server',
104+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server',
105+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
106+
'mcp_server.prompt': promptName,
107+
},
108+
},
109+
() => target.apply(thisArg, argArray),
110+
);
111+
},
112+
});
113+
114+
wrappedMcpServerInstances.add(mcpServerInstance);
115+
116+
return mcpServerInstance as S;
117+
}
118+
119+
function isMcpServerInstance(mcpServerInstance: unknown): mcpServerInstance is MCPServerInstance {
120+
return (
121+
typeof mcpServerInstance === 'object' &&
122+
mcpServerInstance !== null &&
123+
'resource' in mcpServerInstance &&
124+
typeof mcpServerInstance.resource === 'function' &&
125+
'tool' in mcpServerInstance &&
126+
typeof mcpServerInstance.tool === 'function' &&
127+
'prompt' in mcpServerInstance &&
128+
typeof mcpServerInstance.prompt === 'function'
129+
);
130+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
2+
import { isWrapped } from '@opentelemetry/instrumentation';
3+
import { InstrumentationNodeModuleFile } from '@opentelemetry/instrumentation';
4+
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
5+
import { SDK_VERSION } from '@sentry/core';
6+
import { generateInstrumentOnce } from '../otel/instrument';
7+
import { defineIntegration, wrapMcpServerWithSentry } from '@sentry/core';
8+
9+
const supportedVersions = ['>=1.9.0 <2'];
10+
11+
interface MCPServerInstance {
12+
tool: (toolName: string, toolSchema: unknown, handler: (...args: unknown[]) => unknown) => void;
13+
}
14+
15+
interface MCPSdkModuleDef {
16+
McpServer: new (...args: unknown[]) => MCPServerInstance;
17+
}
18+
19+
/**
20+
* Sentry instrumentation for MCP Servers (`@modelcontextprotocol/sdk` package)
21+
*/
22+
export class McpInstrumentation extends InstrumentationBase {
23+
public constructor(config: InstrumentationConfig = {}) {
24+
super('sentry-modelcontextprotocol-sdk', SDK_VERSION, config);
25+
}
26+
27+
/**
28+
* Initializes the instrumentation by defining the modules to be patched.
29+
*/
30+
public init(): InstrumentationNodeModuleDefinition[] {
31+
const moduleDef = new InstrumentationNodeModuleDefinition('@modelcontextprotocol/sdk', supportedVersions);
32+
33+
moduleDef.files.push(
34+
new InstrumentationNodeModuleFile(
35+
'@modelcontextprotocol/sdk/server/mcp.js',
36+
supportedVersions,
37+
(moduleExports: MCPSdkModuleDef) => {
38+
if (isWrapped(moduleExports.McpServer)) {
39+
this._unwrap(moduleExports, 'McpServer');
40+
}
41+
42+
this._wrap(moduleExports, 'McpServer', originalMcpServerClass => {
43+
return new Proxy(originalMcpServerClass, {
44+
construct(McpServerClass, mcpServerConstructorArgArray) {
45+
const mcpServerInstance = new McpServerClass(...mcpServerConstructorArgArray);
46+
47+
return wrapMcpServerWithSentry(mcpServerInstance);
48+
},
49+
});
50+
});
51+
52+
return moduleExports;
53+
},
54+
(moduleExports: MCPSdkModuleDef) => {
55+
this._unwrap(moduleExports, 'McpServer');
56+
},
57+
),
58+
);
59+
60+
return [moduleDef];
61+
}
62+
}
63+
const INTEGRATION_NAME = 'MCP';
64+
65+
const instrumentMcp = generateInstrumentOnce('MCP', () => {
66+
return new McpInstrumentation();
67+
});
68+
69+
/**
70+
* Integration capturing tracing data for MCP servers (via the `@modelcontextprotocol/sdk` package).
71+
*/
72+
export const mcpIntegration = defineIntegration(() => {
73+
return {
74+
name: INTEGRATION_NAME,
75+
setupOnce() {
76+
instrumentMcp();
77+
},
78+
};
79+
});

packages/node/src/sdk/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { envToBool } from '../utils/envToBool';
3939
import { defaultStackParser, getSentryRelease } from './api';
4040
import { NodeClient } from './client';
4141
import { initOpenTelemetry, maybeInitializeEsmLoader } from './initOtel';
42+
import { mcpIntegration } from '../integrations/mcp-server';
4243

4344
function getCjsOnlyIntegrations(): Integration[] {
4445
return isCjs() ? [modulesIntegration()] : [];
@@ -69,6 +70,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] {
6970
nodeContextIntegration(),
7071
childProcessIntegration(),
7172
processSessionIntegration(),
73+
mcpIntegration(),
7274
...getCjsOnlyIntegrations(),
7375
];
7476
}

0 commit comments

Comments
 (0)