diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs index f3dd95215d03..0c50a79bbaca 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs @@ -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', }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json index 6482c7f3a8e0..91145f279d48 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -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", @@ -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)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/pnpm-workspace.yaml index a1c031fc3310..0706e381d258 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/pnpm-workspace.yaml +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/pnpm-workspace.yaml @@ -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 diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp-sdk-otel.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp-sdk-otel.test.ts new file mode 100644 index 000000000000..ae81a7445ed8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp-sdk-otel.test.ts @@ -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 { + 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(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts index c7e7e2dae2e2..826821dca3af 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts @@ -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 }) => { diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 4ca42839787d..954a91a49d62 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -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 @@ -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 = { - ...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); } } diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index ebea8edd74f6..1701bc6bb3f9 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -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'; /** @@ -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); } @@ -177,6 +182,7 @@ export function wrapMcpServerWithSentry(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[]) { @@ -187,7 +193,7 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, } isTransportInstrumented = true; - instrumentTransport(transport, wrapperOptions); + instrumentTransport(transport, wrapperOptions, upstreamInstrumented); }; const restoreStart = interceptTransportStart(transport, instrumentTransportOnce); diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index 9f370a5d1b98..337b1a7f4d5a 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -23,7 +23,7 @@ import type { ExtraHandlerData, JsonRpcNotification, JsonRpcRequest, - McpSpanConfig, + McpSpanType, MCPTransport, ResolvedMcpOptions, } from './types'; @@ -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 { +function buildSentryAttributes(type: McpSpanType): Record { let op: string; let origin: string; @@ -72,43 +72,45 @@ function buildSentryAttributes(type: McpSpanConfig['type']): Record } { 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 = { ...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; + return { name, attributes }; +} + +function startMcpNotificationSpan( + { name, attributes }: { name: string; attributes: Record }, + callback: () => unknown, +): unknown { return startSpan( { - name: spanName, + name, // oxlint-disable-next-line typescript/no-deprecated forceTransaction: true, attributes, @@ -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, - }); + ); } /** @@ -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, - }); + ); } /** diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index df0703128aef..965fff242bfc 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -5,20 +5,32 @@ * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports */ -import { MCP_AUTH_CLIENT_NAME, MCP_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; +import { MCP_AUTH_CLIENT_NAME, MCP_METHOD_NAME, MCP_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; import { getClient, getIsolationScope, withIsolationScope } from '../../currentScopes'; import { DEBUG_BUILD } from '../../debug-build'; import { withActiveSpan } from '../../tracing'; import { startInactiveSpan } from '../../tracing/trace'; +import { getActiveSpan, spanToJSON } from '../../utils/spanUtils'; import { isObjectLike } from '../../utils/is'; import { fill } from '../../utils/object'; import { safeCallback } from '../../utils/safeCallback'; -import { cleanupPendingSpansForTransport, completeSpanWithResults, storeSpanForRequest } from './correlation'; +import { + applyResponseToSpan, + cleanupPendingSpansForTransport, + completeSpanWithResults, + storeSpanForRequest, +} from './correlation'; import { captureError, isJsonRpcServerError } from './errorCapture'; import { buildClientAttributesFromInfo, extractSessionDataFromInitializeRequest } from './sessionExtraction'; import { cleanupSessionDataForTransport, updateSessionDataForTransport } from './sessionManagement'; -import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; +import { + buildMcpNotificationSpanConfig, + buildMcpServerSpanConfig, + createMcpNotificationSpan, + createMcpOutgoingNotificationSpan, +} from './spans'; import type { ExtraHandlerData, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; +import { hasGlobalTracerProvider, withInboundHandoff } from './upstreamInstrumentation'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse } from './validation'; function resolveMcpOptions(options: McpServerWrapperOptions): ResolvedMcpOptions { @@ -66,8 +78,13 @@ function resolveOAuthClientName(options: McpServerWrapperOptions, extra?: ExtraH * Modern request metadata is read directly when building each message's span. * @param transport - MCP transport instance to wrap * @param options - MCP instrumentation options + * @param upstreamInstrumented - Whether the server has the MCP SDK's built-in instrumentation */ -export function wrapTransportOnMessage(transport: MCPTransport, options: McpServerWrapperOptions): void { +export function wrapTransportOnMessage( + transport: MCPTransport, + options: McpServerWrapperOptions, + upstreamInstrumented = false, +): void { if (transport.onmessage) { fill(transport, 'onmessage', originalOnMessage => { return function (this: MCPTransport, message: unknown, extra?: unknown) { @@ -96,30 +113,63 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ if (oauthClientName) { spanConfig.attributes[MCP_AUTH_CLIENT_NAME] = oauthClientName; } - const span = startInactiveSpan(spanConfig); - if (request.method === 'initialize' && messageSessionData) { - span.setAttributes({ - ...buildClientAttributesFromInfo(messageSessionData.clientInfo), - ...(messageSessionData.protocolVersion && { - [MCP_PROTOCOL_VERSION]: messageSessionData.protocolVersion, - }), - }); + Object.assign(spanConfig.attributes, buildClientAttributesFromInfo(messageSessionData.clientInfo)); + if (messageSessionData.protocolVersion) { + spanConfig.attributes[MCP_PROTOCOL_VERSION] = messageSessionData.protocolVersion; + } } + const callOriginal = (): unknown => + (originalOnMessage as (...args: unknown[]) => unknown).call(this, request, extra); + const startOwnSpan = (fn: () => unknown): unknown => { + const span = startInactiveSpan(spanConfig); + storeSpanForRequest(transport, request.id, span, request.method, resolvedOptions); + return withActiveSpan(span, fn); + }; - storeSpanForRequest(transport, request.id, span, request.method, resolvedOptions); + if (upstreamInstrumented && hasGlobalTracerProvider()) { + return withInboundHandoff( + { + method: request.method, + id: request.id, + attributes: spanConfig.attributes, + onResponse: (span, result, error) => + applyResponseToSpan(transport, span, request.method, result, error, resolvedOptions), + startOwnSpan, + }, + callOriginal, + ); + } - return withActiveSpan(span, () => { - return (originalOnMessage as (...args: unknown[]) => unknown).call(this, request, extra); - }); + return startOwnSpan(callOriginal); }); } if (notification) { const resolvedOptions = resolveMcpOptions(options); - return createMcpNotificationSpan(notification, transport, extra as ExtraHandlerData, resolvedOptions, () => { - return (originalOnMessage as (...args: unknown[]) => unknown).call(this, notification, extra); - }); + const callOriginal = (): unknown => + (originalOnMessage as (...args: unknown[]) => unknown).call(this, notification, extra); + const startOwnSpan = (fn: () => unknown): unknown => + createMcpNotificationSpan(notification, transport, extra as ExtraHandlerData, resolvedOptions, fn); + + if (upstreamInstrumented && hasGlobalTracerProvider()) { + return withInboundHandoff( + { + method: notification.method, + attributes: buildMcpNotificationSpanConfig( + 'notification-incoming', + notification, + transport, + resolvedOptions, + extra as ExtraHandlerData, + ).attributes, + startOwnSpan, + }, + callOriginal, + ); + } + + return startOwnSpan(callOriginal); } return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); @@ -134,8 +184,13 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ * the corresponding request span. * @param transport - MCP transport instance to wrap * @param options - MCP capture overrides + * @param upstreamInstrumented - Whether the server has the MCP SDK's built-in instrumentation */ -export function wrapTransportSend(transport: MCPTransport, options: McpServerWrapperOptions): void { +export function wrapTransportSend( + transport: MCPTransport, + options: McpServerWrapperOptions, + upstreamInstrumented = false, +): void { if (transport.send) { fill(transport, 'send', originalSend => { return async function (this: MCPTransport, ...args: unknown[]) { @@ -143,6 +198,14 @@ export function wrapTransportSend(transport: MCPTransport, options: McpServerWra if (isJsonRpcNotification(message)) { const resolvedOptions = resolveMcpOptions(options); + // The MCP SDK sends a notification inside its own scope, so its span is the active one. + const upstreamSpan = upstreamInstrumented && hasGlobalTracerProvider() ? getActiveSpan() : undefined; + if (upstreamSpan && spanToJSON(upstreamSpan).attributes[MCP_METHOD_NAME] === message.method) { + upstreamSpan.setAttributes( + buildMcpNotificationSpanConfig('notification-outgoing', message, transport, resolvedOptions).attributes, + ); + return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); + } return createMcpOutgoingNotificationSpan(message, transport, resolvedOptions, () => { return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); }); diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 5a7ed20c92c3..7585dae9476f 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -159,19 +159,6 @@ export interface ExtraHandlerData { /** Types of MCP spans */ export type McpSpanType = 'request' | 'notification-incoming' | 'notification-outgoing'; -/** - * Configuration for creating MCP spans - * @internal - */ -export interface McpSpanConfig { - type: McpSpanType; - message: JsonRpcRequest | JsonRpcNotification; - transport: MCPTransport; - extra?: ExtraHandlerData; - callback: () => unknown; - options?: ResolvedMcpOptions; -} - export type SessionId = string; export type RequestId = string | number; diff --git a/packages/core/src/integrations/mcp-server/upstreamInstrumentation.ts b/packages/core/src/integrations/mcp-server/upstreamInstrumentation.ts new file mode 100644 index 000000000000..91a3de27ea5e --- /dev/null +++ b/packages/core/src/integrations/mcp-server/upstreamInstrumentation.ts @@ -0,0 +1,122 @@ +/** + * Newer `@modelcontextprotocol/server` versions start their own OpenTelemetry span for every inbound + * message once a tracer provider is registered. In that case we add our attributes to that span + * instead of starting a second one. + */ + +import { MCP_METHOD_NAME } from '@sentry/conventions/attributes'; +import { getClient } from '../../currentScopes'; +import type { Span } from '../../types/span'; +import { isObjectLike } from '../../utils/is'; +import { spanToJSON } from '../../utils/spanUtils'; +import { GLOBAL_OBJ } from '../../utils/worldwide'; +import type { JsonRpcError, RequestId } from './types'; + +interface UpstreamOutcome { + type: string; + result?: unknown; + error?: JsonRpcError; +} + +interface UpstreamScope { + run(fn: () => T): T; + end(outcome: UpstreamOutcome): void; +} + +interface UpstreamInstrumentation { + receive?: (operation: { method: string; id?: RequestId }) => UpstreamScope | undefined; +} + +export interface InboundHandoff { + method: string; + id?: RequestId; + attributes: Record; + onResponse?: (span: Span, result: unknown, error: JsonRpcError | undefined) => void; + /** Used when the MCP SDK's span does not reach Sentry, e.g. because a non-Sentry tracer provider is registered. */ + startOwnSpan: (fn: () => unknown) => unknown; +} + +let pendingHandoff: InboundHandoff | undefined; + +/** Whether the MCP SDK's built-in instrumentation records spans. */ +export function hasGlobalTracerProvider(): boolean { + const otelApi = (GLOBAL_OBJ as unknown as Record)[ + Symbol.for('opentelemetry.js.api.1') + ]; + return !!otelApi?.trace; +} + +/** + * Hooks into the MCP SDK's built-in instrumentation (the package-internal `_instrumentation`). + * Returns `false` when the server has none. + */ +export function decorateUpstreamInstrumentation(serverInstance: object): boolean { + const inner = (serverInstance as { server?: unknown }).server; + const protocol = (isObjectLike(inner) ? inner : serverInstance) as { _instrumentation?: UpstreamInstrumentation }; + const original = protocol._instrumentation; + if (typeof original?.receive !== 'function') { + return false; + } + + protocol._instrumentation = { + ...original, + receive: operation => { + const handoff = pendingHandoff; + if (handoff?.method !== operation.method || handoff.id !== operation.id) { + return original.receive?.(operation); + } + pendingHandoff = undefined; + return receiveWithHandoff(handoff, () => original.receive?.(operation)); + }, + }; + return true; +} + +/** Runs the original `transport.onmessage`, during which the MCP SDK starts its scope synchronously. */ +export function withInboundHandoff(handoff: InboundHandoff, callback: () => T): T { + pendingHandoff = handoff; + try { + return callback(); + } finally { + pendingHandoff = undefined; + } +} + +function receiveWithHandoff( + handoff: InboundHandoff, + receive: () => UpstreamScope | undefined, +): UpstreamScope | undefined { + let span: Span | undefined; + const unsubscribe = getClient()?.on('spanStart', startedSpan => { + if (!span && spanToJSON(startedSpan).attributes[MCP_METHOD_NAME]) { + span = startedSpan; + } + }); + let scope: UpstreamScope | undefined; + try { + scope = receive(); + } finally { + unsubscribe?.(); + } + + if (!scope || !span) { + return { + run: fn => handoff.startOwnSpan(() => (scope ? scope.run(fn) : fn())) as ReturnType, + end: outcome => scope?.end(outcome), + }; + } + + const upstreamSpan = span; + const upstreamScope = scope; + upstreamSpan.setAttributes(handoff.attributes); + return { + run: fn => upstreamScope.run(fn), + // The MCP SDK ends its span before the response is sent, so the response is recorded here. + end: outcome => { + if (outcome.type === 'result' || outcome.type === 'error') { + handoff.onResponse?.(upstreamSpan, outcome.result, outcome.error); + } + upstreamScope.end(outcome); + }, + }; +} diff --git a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts index 73cff92f2481..b6f8d5225acb 100644 --- a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts @@ -121,8 +121,7 @@ describe('MCP Server Capture Policy', () => { } function expectToolResult(span: MockSpan, content?: string): void { - expect(span.setAttributes).toHaveBeenCalledOnce(); - expect(span.setAttributes).toHaveBeenCalledWith({ + expect(span.setAttributes).toHaveBeenLastCalledWith({ 'mcp.tool.result.content_count': 1, 'mcp.tool.result.content_type': 'text', ...(content !== undefined && { 'mcp.tool.result.content': content }), diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index a4e9f24407aa..81b89cf96448 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -343,6 +343,7 @@ describe('MCP Server Transport Instrumentation', () => { it('should set span status to error when JSON-RPC error response is sent', async () => { const mockSpan = { + setAttribute: vi.fn(), setAttributes: vi.fn(), setStatus: vi.fn(), end: vi.fn(), @@ -375,6 +376,7 @@ describe('MCP Server Transport Instrumentation', () => { it('should not set error span status for successful JSON-RPC responses', async () => { const mockSpan = { + setAttribute: vi.fn(), setAttributes: vi.fn(), setStatus: vi.fn(), end: vi.fn(), @@ -874,7 +876,7 @@ describe('MCP Server Transport Instrumentation', () => { await wrappedMcpServer.connect(transport); - const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan); transport.onmessage?.( @@ -887,11 +889,13 @@ describe('MCP Server Transport Instrumentation', () => { {}, ); - expect(mockSpan.setAttributes).toHaveBeenCalledWith( + expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ - 'mcp.client.name': 'test-client', - 'mcp.client.version': '1.0.0', - 'mcp.protocol.version': '2025-06-18', + attributes: expect.objectContaining({ + 'mcp.client.name': 'test-client', + 'mcp.client.version': '1.0.0', + 'mcp.protocol.version': '2025-06-18', + }), }), ); }); @@ -903,7 +907,7 @@ describe('MCP Server Transport Instrumentation', () => { await wrappedMcpServer.connect(transport); - const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan as any); transport.onmessage?.( @@ -975,7 +979,7 @@ describe('MCP Server Transport Instrumentation', () => { const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); const transport = createMockTransport(); transport.sessionId = ''; - const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan as any); await wrappedMcpServer.connect(transport); @@ -1002,7 +1006,10 @@ describe('MCP Server Transport Instrumentation', () => { }); expect(getSessionDataForTransport(transport)).toBeUndefined(); - expect(mockSpan.setAttributes).not.toHaveBeenCalled(); + expect(mockSpan.setAttributes).not.toHaveBeenCalledWith( + expect.objectContaining({ 'mcp.server.name': 'application-server' }), + ); + expect(mockSpan.setAttribute).not.toHaveBeenCalled(); expect(mockSpan.end).toHaveBeenCalledOnce(); }); @@ -1054,7 +1061,7 @@ describe('MCP Server Transport Instrumentation', () => { const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); const transport = createMockTransport(); transport.sessionId = ''; - const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan as any); await wrappedMcpServer.connect(transport); @@ -1202,7 +1209,7 @@ describe('MCP Server Transport Instrumentation', () => { // Connect using the wrapper transport (what users do) await wrappedMcpServer.connect(wrapper); - const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan as any); // Simulate incoming request - due to getter/setter, onmessage runs on inner transport @@ -1244,7 +1251,7 @@ describe('MCP Server Transport Instrumentation', () => { await wrappedMcpServer.connect(wrapper); - const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan as any); inner.onmessage?.call( @@ -1331,7 +1338,7 @@ describe('MCP Server Transport Instrumentation', () => { await wrappedMcpServer.connect(wrapper); - const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan as any); // Initialize request @@ -1360,10 +1367,12 @@ describe('MCP Server Transport Instrumentation', () => { }); // Span should have client and server info attributes - expect(mockSpan.setAttributes).toHaveBeenCalledWith( + expect(startInactiveSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ - 'mcp.client.name': 'test-client', - 'mcp.client.version': '1.0.0', + attributes: expect.objectContaining({ + 'mcp.client.name': 'test-client', + 'mcp.client.version': '1.0.0', + }), }), ); expect(mockSpan.setAttributes).toHaveBeenCalledWith( @@ -1382,7 +1391,7 @@ describe('MCP Server Transport Instrumentation', () => { await wrappedMcpServer.connect(wrapper); - const mockSpan = { setAttributes: vi.fn(), end: vi.fn(), setStatus: vi.fn() }; + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn(), setStatus: vi.fn() }; startInactiveSpanSpy.mockReturnValue(mockSpan as any); // Start a request but don't complete it diff --git a/packages/core/test/lib/integrations/mcp-server/upstreamInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/upstreamInstrumentation.test.ts new file mode 100644 index 000000000000..ca4eed280ab0 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/upstreamInstrumentation.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MCP_CLIENT_NAME, + MCP_METHOD_NAME, + MCP_PROTOCOL_VERSION, + MCP_REQUEST_ARGUMENT_KEY_BASE, + MCP_SERVER_NAME, + MCP_TOOL_NAME, + MCP_TOOL_RESULT_CONTENT_COUNT, + SENTRY_KIND, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { + MCP_NOTIFICATION_CLIENT_TO_SERVER, + MCP_NOTIFICATION_SERVER_TO_CLIENT, + MCP_SERVER, +} from '@sentry/conventions/op'; +import { setCurrentClient } from '../../../../src'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { MCP_FUNCTION_ORIGIN_VALUE } from '../../../../src/integrations/mcp-server/attributes'; +import { withActiveSpan } from '../../../../src/tracing'; +import { startInactiveSpan, startSpan } from '../../../../src/tracing/trace'; +import type { Span, StreamedSpanJSON } from '../../../../src/types/span'; +import { getActiveSpan, spanToJSON } from '../../../../src/utils/spanUtils'; +import { GLOBAL_OBJ } from '../../../../src/utils/worldwide'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; +import { resetGlobals } from '../../../testutils'; + +const OTEL_API_GLOBAL_KEY = Symbol.for('opentelemetry.js.api.1'); + +type Outcome = { type: 'result'; result: unknown } | { type: 'error'; error: { code: number; message: string } }; + +interface Scope { + run(fn: () => T): T; + end(outcome: Outcome): void; +} + +interface Operation { + method: string; + id?: string | number; +} + +interface Instrumentation { + send?: (operation: Operation) => Scope | undefined; + receive?: (operation: Operation) => Scope | undefined; +} + +// Mirrors the MCP SDK instrumentation under the Sentry tracer provider: one span per operation, +// ended before the response is sent. +function sentryTracedInstrumentation(): Instrumentation { + const start = (operation: Operation, kind: 'client' | 'server'): Scope => { + const span = startInactiveSpan({ + name: operation.method, + attributes: { [MCP_METHOD_NAME]: operation.method, [SENTRY_KIND]: kind }, + }); + return { run: fn => withActiveSpan(span, fn), end: () => span.end() }; + }; + return { send: operation => start(operation, 'client'), receive: operation => start(operation, 'server') }; +} + +function foreignTracedInstrumentation(): Instrumentation { + const scope: Scope = { run: fn => fn(), end: () => undefined }; + return { send: () => scope, receive: () => scope }; +} + +class InMemoryTransport { + public onmessage?: (message: unknown, extra?: unknown) => void; + public onclose?: () => void; + public onerror?: (error: Error) => void; + public sent: unknown[] = []; + + public async start(): Promise {} + + public async send(message: unknown): Promise { + this.sent.push(message); + } +} + +type ToolHandler = (args: Record) => Promise; + +// Follows the dispatch order of the MCP SDK's `Protocol`: the scope starts synchronously in +// `transport.onmessage`, the handler runs inside it, and the scope ends before the response is sent. +function createServer(instrumentation: Instrumentation | undefined) { + const tools: Record = {}; + const protocol: { _instrumentation?: Instrumentation; transport?: InMemoryTransport } = { + _instrumentation: instrumentation, + }; + + const mcpServer = { + server: protocol, + registerTool(name: string, _config: unknown, handler: ToolHandler) { + tools[name] = handler; + }, + registerResource() {}, + registerPrompt() {}, + async connect(transport: InMemoryTransport) { + protocol.transport = transport; + transport.onmessage = (message: unknown) => { + const { id, method, params } = message as { + id?: number; + method: string; + params?: { name?: string; arguments?: Record }; + }; + const scope = protocol._instrumentation?.receive?.({ method, ...(id !== undefined && { id }) }); + const run = (fn: () => T): T => (scope ? scope.run(fn) : fn()); + + if (id === undefined) { + scope?.end({ type: 'result', result: undefined }); + return; + } + + const handler = (): Promise => + method === 'tools/call' + ? tools[params!.name!]!(params!.arguments ?? {}) + : Promise.resolve({ serverInfo: { name: 'test-server', version: '1.0.0' }, protocolVersion: '2025-06-18' }); + + // Unlike the real `Protocol`, the handler starts synchronously: these tests run without an + // async context strategy, so the active span does not carry across a microtask. + void run(handler).then(async result => { + scope?.end({ type: 'result', result }); + await transport.send({ jsonrpc: '2.0', id, result }); + }); + }; + await transport.start(); + }, + async notify(method: string) { + const scope = protocol._instrumentation?.send?.({ method }); + const send = () => protocol.transport!.send({ jsonrpc: '2.0', method, params: {} }); + await (scope ? scope.run(send) : send()); + scope?.end({ type: 'result', result: undefined }); + }, + }; + + return mcpServer; +} + +async function callTool(transport: InMemoryTransport, name: string, args: Record): Promise { + const responses = transport.sent.length; + transport.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }, {}); + await vi.waitFor(() => expect(transport.sent.length).toBeGreaterThan(responses)); +} + +describe('MCP server hand-off to the MCP SDK instrumentation', () => { + let client: TestClient; + let endedSpans: StreamedSpanJSON[]; + let originalOtelRegistry: unknown; + + const mcpSpans = (): StreamedSpanJSON[] => endedSpans.filter(span => span.attributes[MCP_METHOD_NAME] !== undefined); + const registerTracerProvider = (): void => { + (GLOBAL_OBJ as unknown as Record)[OTEL_API_GLOBAL_KEY] = { version: '1.9.0', trace: {} }; + }; + + beforeEach(() => { + resetGlobals(); + originalOtelRegistry = (GLOBAL_OBJ as unknown as Record)[OTEL_API_GLOBAL_KEY]; + client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + endedSpans = []; + client.on('spanEnd', span => { + endedSpans.push(spanToJSON(span)); + }); + }); + + afterEach(() => { + (GLOBAL_OBJ as unknown as Record)[OTEL_API_GLOBAL_KEY] = originalOtelRegistry; + }); + + it('enriches the span the MCP SDK starts instead of starting a second one', async () => { + registerTracerProvider(); + const server = wrapMcpServerWithSentry(createServer(sentryTracedInstrumentation())); + let handlerParent: Span | undefined; + server.registerTool('echo', {}, async ({ message }) => { + handlerParent = getActiveSpan(); + startSpan({ name: 'inside-tool' }, () => undefined); + return { content: [{ type: 'text', text: `echo: ${message as string}` }] }; + }); + + const transport = new InMemoryTransport(); + await server.connect(transport); + await callTool(transport, 'echo', { message: 'hi' }); + + const spans = mcpSpans(); + expect(spans).toHaveLength(1); + const [toolSpan] = spans; + expect(toolSpan!.attributes).toMatchObject({ + [SENTRY_KIND]: 'server', + [SENTRY_OP]: MCP_SERVER, + [SENTRY_ORIGIN]: MCP_FUNCTION_ORIGIN_VALUE, + [MCP_TOOL_NAME]: 'echo', + [`${MCP_REQUEST_ARGUMENT_KEY_BASE}.message`]: '"hi"', + [MCP_TOOL_RESULT_CONTENT_COUNT]: 1, + }); + expect(spanToJSON(handlerParent!).span_id).toBe(toolSpan!.span_id); + expect(endedSpans.find(span => span.name === 'inside-tool')!.parent_span_id).toBe(toolSpan!.span_id); + }); + + it('records initialize session data on the MCP SDK span', async () => { + registerTracerProvider(); + const server = wrapMcpServerWithSentry(createServer(sentryTracedInstrumentation())); + const transport = new InMemoryTransport(); + await server.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', clientInfo: { name: 'test-client', version: '1.0.0' } }, + }, + {}, + ); + await vi.waitFor(() => expect(transport.sent).toHaveLength(1)); + + const spans = mcpSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.attributes).toMatchObject({ + [SENTRY_OP]: MCP_SERVER, + [MCP_CLIENT_NAME]: 'test-client', + [MCP_SERVER_NAME]: 'test-server', + [MCP_PROTOCOL_VERSION]: '2025-06-18', + }); + }); + + it('enriches the MCP SDK span of an incoming notification', async () => { + registerTracerProvider(); + const server = wrapMcpServerWithSentry(createServer(sentryTracedInstrumentation())); + const transport = new InMemoryTransport(); + await server.connect(transport); + + transport.onmessage?.({ jsonrpc: '2.0', method: 'notifications/initialized' }, {}); + + const spans = mcpSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.attributes).toMatchObject({ + [SENTRY_KIND]: 'server', + [SENTRY_OP]: MCP_NOTIFICATION_CLIENT_TO_SERVER, + }); + }); + + it('enriches the MCP SDK span of an outgoing notification', async () => { + registerTracerProvider(); + const server = wrapMcpServerWithSentry(createServer(sentryTracedInstrumentation())); + const transport = new InMemoryTransport(); + await server.connect(transport); + + await server.notify('notifications/tools/list_changed'); + + const spans = mcpSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.attributes).toMatchObject({ + [SENTRY_KIND]: 'client', + [SENTRY_OP]: MCP_NOTIFICATION_SERVER_TO_CLIENT, + }); + }); + + it('starts its own span when the MCP SDK span does not reach Sentry', async () => { + registerTracerProvider(); + const server = wrapMcpServerWithSentry(createServer(foreignTracedInstrumentation())); + let handlerSpan: Span | undefined; + server.registerTool('echo', {}, async () => { + handlerSpan = getActiveSpan(); + return { content: [{ type: 'text', text: 'echo' }] }; + }); + + const transport = new InMemoryTransport(); + await server.connect(transport); + await callTool(transport, 'echo', {}); + + const spans = mcpSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.attributes).toMatchObject({ [SENTRY_OP]: MCP_SERVER, [MCP_TOOL_RESULT_CONTENT_COUNT]: 1 }); + expect(spans[0]!.attributes[SENTRY_KIND]).toBeUndefined(); + expect(spanToJSON(handlerSpan!).span_id).toBe(spans[0]!.span_id); + }); + + it.each([ + ['no tracer provider is registered', false, foreignTracedInstrumentation()], + ['the MCP SDK has no instrumentation', true, undefined], + ])('keeps starting its own spans when %s', async (_, withTracerProvider, instrumentation) => { + if (withTracerProvider) { + registerTracerProvider(); + } + const server = wrapMcpServerWithSentry(createServer(instrumentation)); + server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'echo' }] })); + + const transport = new InMemoryTransport(); + await server.connect(transport); + await callTool(transport, 'echo', {}); + + const spans = mcpSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.attributes).toMatchObject({ [SENTRY_OP]: MCP_SERVER, [MCP_TOOL_RESULT_CONTENT_COUNT]: 1 }); + }); +});