Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 --config.block-exotic-subdeps=false 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 @@ -28,6 +30,13 @@
},
"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
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
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.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