Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ Sentry.init({
debug: !!process.env.DEBUG,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1,
// Registers a tracer provider, which turns on the MCP SDK's built-in OpenTelemetry spans.
enableOpenTelemetrySetup: process.env.MCP_SDK_OTEL === '1',
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test"
"test:build-mcp-sdk-otel": "pnpm install && pnpm add https://pkg.pr.new/@modelcontextprotocol/server@919bb8e https://pkg.pr.new/@modelcontextprotocol/node@919bb8e https://pkg.pr.new/@modelcontextprotocol/client@919bb8e && pnpm build",
"test:assert": "pnpm test",
"test:assert-mcp-sdk-otel": "MCP_SDK_OTEL=1 pnpm test"
},
"dependencies": {
"@cfworker/json-schema": "^4.0.0",
Expand All @@ -24,10 +26,18 @@
"devDependencies": {
"@modelcontextprotocol/client": "^2.0.0",
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@sentry/conventions": "0.32.0"
},
"type": "module",
"sentryTest": {
"optional": true
"optional": true,
"optionalVariants": [
{
"build-command": "pnpm test:build-mcp-sdk-otel",
"assert-command": "pnpm test:assert-mcp-sdk-otel",
"label": "node-express-mcp-v2 (MCP SDK OpenTelemetry)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
minimumReleaseAge: 0
# The MCP SDK OpenTelemetry variant installs unreleased pkg.pr.new previews, which depend on
# @modelcontextprotocol/core through a pkg.pr.new tarball URL. Remove this once an npm release of
# @modelcontextprotocol/server ships the OpenTelemetry instrumentation and the variant pins that version.
blockExoticSubdeps: false
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import {
MCP_CLIENT_NAME,
MCP_METHOD_NAME,
MCP_PROMPT_NAME,
MCP_REQUEST_ARGUMENT_KEY_BASE,
MCP_SERVER_NAME,
MCP_TOOL_RESULT_CONTENT_COUNT,
SENTRY_KIND,
} from '@sentry/conventions/attributes';
import { HTTP_SERVER, MCP_SERVER } from '@sentry/conventions/op';
import { collectStreamedSpans, getSpanOp, type SerializedStreamedSpan } from '@sentry-internal/test-utils';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

test.skip(process.env.MCP_SDK_OTEL !== '1', 'Only runs in the MCP SDK OpenTelemetry variant');

/** Resolves with the single MCP span of the request whose span is named `spanName`, once its trace is complete. */
async function getMcpSpan(spanName: string): Promise<SerializedStreamedSpan> {
const spans = await collectStreamedSpans(
'node-express-mcp-v2',
spans => spans.some(span => span.is_segment) && spans.some(span => span.name === spanName),
);
const mcpSpans = spans.filter(span => span.attributes?.[MCP_METHOD_NAME]);
expect(mcpSpans).toHaveLength(1);
expect(getSpanOp(spans.find(span => span.is_segment)!)).toBe(HTTP_SERVER);

const [mcpSpan] = mcpSpans;
expect(mcpSpan!.name).toBe(spanName);
expect(mcpSpan!.is_segment).toBe(false);
expect(mcpSpan!.attributes?.[SENTRY_KIND]?.value).toBe('server');
expect(getSpanOp(mcpSpan!)).toBe(MCP_SERVER);
return mcpSpan!;
}

test('enriches the MCP SDK spans instead of duplicating them', async ({ baseURL }) => {
const client = new Client({ name: 'test-client-v2', version: '1.0.0' });

const initializeSpanPromise = getMcpSpan('initialize');
await client.connect(new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)));
const initializeSpan = await initializeSpanPromise;
expect(initializeSpan.attributes?.[MCP_CLIENT_NAME]?.value).toBe('test-client-v2');
expect(initializeSpan.attributes?.[MCP_SERVER_NAME]?.value).toBe('Echo-V2');

const toolSpanPromise = getMcpSpan('tools/call echo');
await client.callTool({ name: 'echo', arguments: { message: 'foobar' } });
const toolSpan = await toolSpanPromise;
expect(toolSpan.attributes?.[`${MCP_REQUEST_ARGUMENT_KEY_BASE}.message`]?.value).toBe('"foobar"');
expect(toolSpan.attributes?.[MCP_TOOL_RESULT_CONTENT_COUNT]?.value).toBe(1);

const promptSpanPromise = getMcpSpan('prompts/get echo');
await client.getPrompt({ name: 'echo', arguments: { message: 'foobar' } });
expect((await promptSpanPromise).attributes?.[MCP_PROMPT_NAME]?.value).toBe('echo');

const errorSpanPromise = getMcpSpan('tools/call always-error');
await client.callTool({ name: 'always-error', arguments: {} }).catch(() => undefined);
expect((await errorSpanPromise).status).toBe('error');

await client.close();
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

test.skip(process.env.MCP_SDK_OTEL === '1', 'Covered by mcp-sdk-otel.test.ts');

// The server in this app is never wrapped with `wrapMcpServerWithSentry` — spans are produced
// solely by the auto-instrumenting `mcpServer` integration.
test('auto-instruments a stable MCP SDK v2 server (no manual wrap)', async ({ baseURL }) => {
Expand Down
87 changes: 49 additions & 38 deletions packages/core/src/integrations/mcp-server/correlation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,53 @@ export function storeSpanForRequest(
});
}

/**
* Records a request's response on its span: session metadata from `initialize`, the JSON-RPC
* error, and tool or prompt results. Does not end the span.
* @param transport - MCP transport instance
* @param span - The request span
* @param method - MCP method name
* @param result - Execution result for attribute extraction
* @param error - The JSON-RPC error response, if any
* @param capturePolicy - Capture policy resolved when the request began
*/
export function applyResponseToSpan(
transport: MCPTransport,
span: Span,
method: string,
result: unknown,
error: JsonRpcError | undefined,
capturePolicy: ResolvedMcpOptions,
): void {
const responseSessionData =
method === 'initialize' ? extractSessionDataFromInitializeResponse(result) : extractSessionDataFromResponse(result);
if (method === 'initialize' && (responseSessionData.protocolVersion || responseSessionData.serverInfo)) {
updateSessionDataForTransport(transport, responseSessionData);
}
span.setAttributes(buildServerAttributesFromInfo(responseSessionData.serverInfo));
if (responseSessionData.protocolVersion) {
span.setAttribute(MCP_PROTOCOL_VERSION, responseSessionData.protocolVersion);
}

if (error) {
span.setAttribute(RPC_RESPONSE_STATUS_CODE, String(error.code));
if (isJsonRpcServerError(error.code)) {
span.setAttribute(ERROR_TYPE, String(error.code));
span.setStatus({ code: SPAN_STATUS_ERROR, message: error.message });
}
} else if (method === 'tools/call') {
const toolAttributes = extractToolResultAttributes(result, capturePolicy.recordOutputs);
span.setAttributes(toolAttributes);
// oxlint-disable-next-line typescript/no-deprecated -- Preserve classification based on the existing tool result attribute.
if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR] === true) {
span.setAttribute(ERROR_TYPE, 'tool_error');
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
}
} else if (method === 'prompts/get') {
span.setAttributes(extractPromptResultAttributes(result, capturePolicy.recordOutputs));
}
}

/**
* Completes span with results and cleans up correlation
* @param transport - MCP transport instance
Expand All @@ -110,44 +157,8 @@ export function completeSpanWithResults(
const spanMap = getOrCreateSpanMap(transport);
const spanData = spanMap.get(requestId);
if (spanData) {
const { span, method } = spanData;
const responseSessionData =
method === 'initialize'
? extractSessionDataFromInitializeResponse(result)
: extractSessionDataFromResponse(result);
if (method === 'initialize' && (responseSessionData.protocolVersion || responseSessionData.serverInfo)) {
updateSessionDataForTransport(transport, responseSessionData);
}
const responseAttributes: Record<string, string | number> = {
...buildServerAttributesFromInfo(responseSessionData.serverInfo),
};
if (responseSessionData.protocolVersion) {
responseAttributes[MCP_PROTOCOL_VERSION] = responseSessionData.protocolVersion;
}
if (Object.keys(responseAttributes).length > 0) {
span.setAttributes(responseAttributes);
}

if (error) {
span.setAttributes({ [RPC_RESPONSE_STATUS_CODE]: String(error.code) });
if (isJsonRpcServerError(error.code)) {
span.setAttributes({ [ERROR_TYPE]: String(error.code) });
span.setStatus({ code: SPAN_STATUS_ERROR, message: error.message });
}
} else if (method === 'tools/call') {
const toolAttributes = extractToolResultAttributes(result, spanData.capturePolicy.recordOutputs);
span.setAttributes(toolAttributes);
// oxlint-disable-next-line typescript/no-deprecated -- Preserve classification based on the existing tool result attribute.
if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR] === true) {
span.setAttributes({ [ERROR_TYPE]: 'tool_error' });
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
}
} else if (method === 'prompts/get') {
const promptAttributes = extractPromptResultAttributes(result, spanData.capturePolicy.recordOutputs);
span.setAttributes(promptAttributes);
}

span.end();
applyResponseToSpan(transport, spanData.span, spanData.method, result, error, spanData.capturePolicy);
spanData.span.end();
spanMap.delete(requestId);
}
}
Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/integrations/mcp-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fill } from '../../utils/object';
import { wrapAllMCPHandlers, wrapExistingHandlers } from './handlers';
import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport';
import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport } from './types';
import { decorateUpstreamInstrumentation } from './upstreamInstrumentation';
import { validateMcpServerInstance } from './validation';

/**
Expand Down Expand Up @@ -34,9 +35,13 @@ function applyMissingMcpOptions(target: McpServerWrapperOptions, source: McpServ
}
}

function instrumentTransport(transport: MCPTransport, options: McpServerWrapperOptions): void {
wrapTransportOnMessage(transport, options);
wrapTransportSend(transport, options);
function instrumentTransport(
transport: MCPTransport,
options: McpServerWrapperOptions,
upstreamInstrumented: boolean,
): void {
wrapTransportOnMessage(transport, options, upstreamInstrumented);
wrapTransportSend(transport, options, upstreamInstrumented);
wrapTransportOnClose(transport);
wrapTransportError(transport);
}
Expand Down Expand Up @@ -177,6 +182,7 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S,
const serverInstance = mcpServerInstance as MCPServerInstance;
const wrapperOptions: McpServerWrapperOptions = { ...options };
wrappedMcpServerOptions.set(mcpServerInstance, wrapperOptions);
const upstreamInstrumented = decorateUpstreamInstrumentation(serverInstance);

fill(serverInstance, 'connect', originalConnect => {
return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) {
Expand All @@ -187,7 +193,7 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S,
}

isTransportInstrumented = true;
instrumentTransport(transport, wrapperOptions);
instrumentTransport(transport, wrapperOptions, upstreamInstrumented);
};
const restoreStart = interceptTransportStart(transport, instrumentTransportOnce);

Expand Down
61 changes: 28 additions & 33 deletions packages/core/src/integrations/mcp-server/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
ExtraHandlerData,
JsonRpcNotification,
JsonRpcRequest,
McpSpanConfig,
McpSpanType,
MCPTransport,
ResolvedMcpOptions,
} from './types';
Expand All @@ -45,7 +45,7 @@ function createSpanName(method: string, target?: string): string {
* @param type - Span type configuration
* @returns Sentry-specific attributes
*/
function buildSentryAttributes(type: McpSpanConfig['type']): Record<string, string> {
function buildSentryAttributes(type: McpSpanType): Record<string, string> {
let op: string;
let origin: string;

Expand All @@ -72,43 +72,45 @@ function buildSentryAttributes(type: McpSpanConfig['type']): Record<string, stri
}

/**
* Unified builder for creating MCP spans
* Builds the name and attributes of an MCP notification span
* @internal
* @param config - Span configuration
* @returns Created span
*/
function createMcpSpan(config: McpSpanConfig): unknown {
const { type, message, transport, extra, callback, options } = config;
export function buildMcpNotificationSpanConfig(
type: 'notification-incoming' | 'notification-outgoing',
message: JsonRpcNotification,
transport: MCPTransport,
options: ResolvedMcpOptions,
extra?: ExtraHandlerData,
): { name: string; attributes: Record<string, string | number> } {
const { method } = message;
const params = message.params;
const client = getClient();
const spanStreamingEnabled = !!client && hasSpanStreamingEnabled(client);

// Determine span name based on type and OTEL conventions
let spanName: string;
if (type === 'request') {
const targetInfo = extractTargetInfo(method, params || {});
const target = spanStreamingEnabled && !targetInfo.targetIsLowCardinality ? undefined : targetInfo.target;
spanName = method ? createSpanName(method, target) : MCP_SERVER_SPAN_NAME_FALLBACK;
} else {
// For notifications, use method name directly per OpenTelemetry conventions.
// With span streaming, span names have to be low cardinality, so a message without a method name gets a static name.
spanName = method || (spanStreamingEnabled ? MCP_NOTIFICATION_SPAN_NAME_FALLBACK : method);
}
// For notifications, use method name directly per OpenTelemetry conventions.
// With span streaming, span names have to be low cardinality, so a message without a method name gets a static name.
const name = method || (spanStreamingEnabled ? MCP_NOTIFICATION_SPAN_NAME_FALLBACK : method);

const rawAttributes: Record<string, string | number> = {
...buildTransportAttributes(transport, extra, message),
[MCP_METHOD_NAME]: method,
...buildTypeSpecificAttributes(type, message, params, options?.recordInputs),
...buildTypeSpecificAttributes(type, message, params, options.recordInputs),
...buildSentryAttributes(type),
};

const userInfo = Boolean(client?.getDataCollectionOptions().userInfo);
const attributes = filterMcpPiiFromSpanData(rawAttributes, userInfo) as Record<string, string | number>;

return { name, attributes };
}

function startMcpNotificationSpan(
{ name, attributes }: { name: string; attributes: Record<string, string | number> },
callback: () => unknown,
): unknown {
return startSpan(
{
name: spanName,
name,
// oxlint-disable-next-line typescript/no-deprecated
forceTransaction: true,
attributes,
Expand All @@ -133,14 +135,10 @@ export function createMcpNotificationSpan(
options: ResolvedMcpOptions,
callback: () => unknown,
): unknown {
return createMcpSpan({
type: 'notification-incoming',
message: jsonRpcMessage,
transport,
extra,
return startMcpNotificationSpan(
buildMcpNotificationSpanConfig('notification-incoming', jsonRpcMessage, transport, options, extra),
callback,
options,
});
);
}

/**
Expand All @@ -157,13 +155,10 @@ export function createMcpOutgoingNotificationSpan(
options: ResolvedMcpOptions,
callback: () => unknown,
): unknown {
return createMcpSpan({
type: 'notification-outgoing',
message: jsonRpcMessage,
transport,
options,
return startMcpNotificationSpan(
buildMcpNotificationSpanConfig('notification-outgoing', jsonRpcMessage, transport, options),
callback,
});
);
}

/**
Expand Down
Loading
Loading