diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8852e2c9293f..da937f3d462d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,7 +118,7 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integra export { profiler } from './profiling'; export { instrumentFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; -export { wrapMcpServerWithSentry } from './mcp-server'; +export { wrapMcpServerWithSentry } from './integrations/mcp-server'; export { captureFeedback } from './feedback'; export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/exports'; diff --git a/packages/core/src/integrations/mcp-server/attributeExtraction.ts b/packages/core/src/integrations/mcp-server/attributeExtraction.ts new file mode 100644 index 000000000000..88fafb4a4fe4 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -0,0 +1,367 @@ +/** + * Attribute extraction and building functions for MCP server instrumentation + */ + +import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; +import { + CLIENT_ADDRESS_ATTRIBUTE, + CLIENT_PORT_ATTRIBUTE, + MCP_PROMPT_NAME_ATTRIBUTE, + MCP_REQUEST_ID_ATTRIBUTE, + MCP_RESOURCE_URI_ATTRIBUTE, + MCP_SESSION_ID_ATTRIBUTE, + MCP_TOOL_NAME_ATTRIBUTE, + MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE, + MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE, + MCP_TRANSPORT_ATTRIBUTE, + NETWORK_PROTOCOL_VERSION_ATTRIBUTE, + NETWORK_TRANSPORT_ATTRIBUTE, +} from './attributes'; +import type { + ExtraHandlerData, + JsonRpcNotification, + JsonRpcRequest, + McpSpanType, + MCPTransport, + MethodConfig, +} from './types'; + +/** Configuration for MCP methods to extract targets and arguments */ +const METHOD_CONFIGS: Record = { + 'tools/call': { + targetField: 'name', + targetAttribute: MCP_TOOL_NAME_ATTRIBUTE, + captureArguments: true, + argumentsField: 'arguments', + }, + 'resources/read': { + targetField: 'uri', + targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + captureUri: true, + }, + 'resources/subscribe': { + targetField: 'uri', + targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + }, + 'resources/unsubscribe': { + targetField: 'uri', + targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + }, + 'prompts/get': { + targetField: 'name', + targetAttribute: MCP_PROMPT_NAME_ATTRIBUTE, + captureName: true, + captureArguments: true, + argumentsField: 'arguments', + }, +}; + +/** Extracts target info from method and params based on method type */ +export function extractTargetInfo( + method: string, + params: Record, +): { + target?: string; + attributes: Record; +} { + const config = METHOD_CONFIGS[method as keyof typeof METHOD_CONFIGS]; + if (!config) { + return { attributes: {} }; + } + + const target = + config.targetField && typeof params?.[config.targetField] === 'string' + ? (params[config.targetField] as string) + : undefined; + + return { + target, + attributes: target && config.targetAttribute ? { [config.targetAttribute]: target } : {}, + }; +} + +/** Extracts request arguments based on method type */ +export function getRequestArguments(method: string, params: Record): Record { + const args: Record = {}; + const config = METHOD_CONFIGS[method as keyof typeof METHOD_CONFIGS]; + + if (!config) { + return args; + } + + // Capture arguments from the configured field + if (config.captureArguments && config.argumentsField && params?.[config.argumentsField]) { + const argumentsObj = params[config.argumentsField]; + if (typeof argumentsObj === 'object' && argumentsObj !== null) { + for (const [key, value] of Object.entries(argumentsObj as Record)) { + args[`mcp.request.argument.${key.toLowerCase()}`] = JSON.stringify(value); + } + } + } + + // Capture specific fields as arguments + if (config.captureUri && params?.uri) { + args['mcp.request.argument.uri'] = JSON.stringify(params.uri); + } + + if (config.captureName && params?.name) { + args['mcp.request.argument.name'] = JSON.stringify(params.name); + } + + return args; +} + +/** Extracts transport types based on transport constructor name */ +export function getTransportTypes(transport: MCPTransport): { mcpTransport: string; networkTransport: string } { + const transportName = transport.constructor?.name?.toLowerCase() || ''; + + // Standard MCP transports per specification + if (transportName.includes('stdio')) { + return { mcpTransport: 'stdio', networkTransport: 'pipe' }; + } + + // Streamable HTTP is the standard HTTP-based transport + if (transportName.includes('streamablehttp') || transportName.includes('streamable')) { + return { mcpTransport: 'http', networkTransport: 'tcp' }; + } + + // SSE is deprecated (backwards compatibility) + if (transportName.includes('sse')) { + return { mcpTransport: 'sse', networkTransport: 'tcp' }; + } + + // For custom transports, mark as unknown + return { mcpTransport: 'unknown', networkTransport: 'unknown' }; +} + +/** Extracts additional attributes for specific notification types */ +export function getNotificationAttributes( + method: string, + params: Record, +): Record { + const attributes: Record = {}; + + switch (method) { + case 'notifications/cancelled': + if (params?.requestId) { + attributes['mcp.cancelled.request_id'] = String(params.requestId); + } + if (params?.reason) { + attributes['mcp.cancelled.reason'] = String(params.reason); + } + break; + + case 'notifications/message': + if (params?.level) { + attributes['mcp.logging.level'] = String(params.level); + } + if (params?.logger) { + attributes['mcp.logging.logger'] = String(params.logger); + } + if (params?.data !== undefined) { + attributes['mcp.logging.data_type'] = typeof params.data; + // Store the actual message content + if (typeof params.data === 'string') { + attributes['mcp.logging.message'] = params.data; + } else { + attributes['mcp.logging.message'] = JSON.stringify(params.data); + } + } + break; + + case 'notifications/progress': + if (params?.progressToken) { + attributes['mcp.progress.token'] = String(params.progressToken); + } + if (typeof params?.progress === 'number') { + attributes['mcp.progress.current'] = params.progress; + } + if (typeof params?.total === 'number') { + attributes['mcp.progress.total'] = params.total; + if (typeof params?.progress === 'number') { + attributes['mcp.progress.percentage'] = (params.progress / params.total) * 100; + } + } + if (params?.message) { + attributes['mcp.progress.message'] = String(params.message); + } + break; + + case 'notifications/resources/updated': + if (params?.uri) { + attributes['mcp.resource.uri'] = String(params.uri); + // Extract protocol from URI + const urlObject = parseStringToURLObject(String(params.uri)); + if (urlObject && !isURLObjectRelative(urlObject)) { + attributes['mcp.resource.protocol'] = urlObject.protocol.replace(':', ''); + } + } + break; + + case 'notifications/initialized': + attributes['mcp.lifecycle.phase'] = 'initialization_complete'; + attributes['mcp.protocol.ready'] = 1; + break; + } + + return attributes; +} + +/** Extracts client connection info from extra handler data */ +export function extractClientInfo(extra: ExtraHandlerData): { + address?: string; + port?: number; +} { + return { + address: + extra?.requestInfo?.remoteAddress || + extra?.clientAddress || + extra?.request?.ip || + extra?.request?.connection?.remoteAddress, + port: extra?.requestInfo?.remotePort || extra?.clientPort || extra?.request?.connection?.remotePort, + }; +} + +/** Build transport and network attributes */ +export function buildTransportAttributes( + transport: MCPTransport, + extra?: ExtraHandlerData, +): Record { + const sessionId = transport.sessionId; + const clientInfo = extra ? extractClientInfo(extra) : {}; + const { mcpTransport, networkTransport } = getTransportTypes(transport); + + return { + ...(sessionId && { [MCP_SESSION_ID_ATTRIBUTE]: sessionId }), + ...(clientInfo.address && { [CLIENT_ADDRESS_ATTRIBUTE]: clientInfo.address }), + ...(clientInfo.port && { [CLIENT_PORT_ATTRIBUTE]: clientInfo.port }), + [MCP_TRANSPORT_ATTRIBUTE]: mcpTransport, + [NETWORK_TRANSPORT_ATTRIBUTE]: networkTransport, + [NETWORK_PROTOCOL_VERSION_ATTRIBUTE]: '2.0', + }; +} + +/** Build type-specific attributes based on message type */ +export function buildTypeSpecificAttributes( + type: McpSpanType, + message: JsonRpcRequest | JsonRpcNotification, + params?: Record, +): Record { + if (type === 'request') { + const request = message as JsonRpcRequest; + const targetInfo = extractTargetInfo(request.method, params || {}); + + return { + ...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }), + ...targetInfo.attributes, + ...getRequestArguments(request.method, params || {}), + }; + } + + // For notifications, only include notification-specific attributes + return getNotificationAttributes(message.method, params || {}); +} + +/** Get metadata about tool result content array */ +function getContentMetadata(content: unknown[]): Record { + return { + [MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length, + }; +} + +/** Build attributes from a single content item */ +function buildContentItemAttributes( + contentItem: Record, + prefix: string, +): Record { + const attributes: Record = {}; + + if (typeof contentItem.type === 'string') { + attributes[`${prefix}.content_type`] = contentItem.type; + } + + if (typeof contentItem.text === 'string') { + const text = contentItem.text; + attributes[`${prefix}.content`] = text.length > 500 ? `${text.substring(0, 497)}...` : text; + } + + if (typeof contentItem.mimeType === 'string') { + attributes[`${prefix}.mime_type`] = contentItem.mimeType; + } + + if (typeof contentItem.uri === 'string') { + attributes[`${prefix}.uri`] = contentItem.uri; + } + + if (typeof contentItem.name === 'string') { + attributes[`${prefix}.name`] = contentItem.name; + } + + if (typeof contentItem.data === 'string') { + attributes[`${prefix}.data_size`] = contentItem.data.length; + } + + return attributes; +} + +/** Build attributes from embedded resource object */ +function buildEmbeddedResourceAttributes(resource: Record, prefix: string): Record { + const attributes: Record = {}; + + if (typeof resource.uri === 'string') { + attributes[`${prefix}.resource_uri`] = resource.uri; + } + + if (typeof resource.mimeType === 'string') { + attributes[`${prefix}.resource_mime_type`] = resource.mimeType; + } + + return attributes; +} + +/** Build attributes for all content items in the result */ +function buildAllContentItemAttributes(content: unknown[]): Record { + const attributes: Record = {}; + + for (let i = 0; i < content.length; i++) { + const item = content[i]; + if (item && typeof item === 'object' && item !== null) { + const contentItem = item as Record; + const prefix = content.length === 1 ? 'mcp.tool.result' : `mcp.tool.result.${i}`; + + Object.assign(attributes, buildContentItemAttributes(contentItem, prefix)); + + if (contentItem.resource && typeof contentItem.resource === 'object') { + const resourceAttrs = buildEmbeddedResourceAttributes(contentItem.resource as Record, prefix); + Object.assign(attributes, resourceAttrs); + } + } + } + + return attributes; +} + +/** Extract tool result attributes for span instrumentation */ +export function extractToolResultAttributes(result: unknown): Record { + let attributes: Record = {}; + + if (typeof result !== 'object' || result === null) { + return attributes; + } + + const resultObj = result as Record; + + if (typeof resultObj.isError === 'boolean') { + attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = resultObj.isError; + } + + if (Array.isArray(resultObj.content)) { + attributes = { + ...attributes, + ...getContentMetadata(resultObj.content), + ...buildAllContentItemAttributes(resultObj.content), + }; + } + + return attributes; +} diff --git a/packages/core/src/integrations/mcp-server/attributes.ts b/packages/core/src/integrations/mcp-server/attributes.ts new file mode 100644 index 000000000000..a979d409659f --- /dev/null +++ b/packages/core/src/integrations/mcp-server/attributes.ts @@ -0,0 +1,139 @@ +/** + * Essential MCP attribute constants for Sentry instrumentation + * + * Based on OpenTelemetry MCP semantic conventions + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md + */ + +// ============================================================================= +// CORE MCP ATTRIBUTES +// ============================================================================= + +/** + * The name of the request or notification method + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#required-attributes + */ +export const MCP_METHOD_NAME_ATTRIBUTE = 'mcp.method.name'; + +/** + * Unique identifier for the request + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#recommended-attributes + */ +export const MCP_REQUEST_ID_ATTRIBUTE = 'mcp.request.id'; + +/** + * Identifies the MCP session + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#recommended-attributes + */ +export const MCP_SESSION_ID_ATTRIBUTE = 'mcp.session.id'; + +/** + * Transport method used for MCP communication + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#recommended-attributes + */ +export const MCP_TRANSPORT_ATTRIBUTE = 'mcp.transport'; + +// ============================================================================= +// METHOD-SPECIFIC ATTRIBUTES +// ============================================================================= + +/** + * Name of the tool being called + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#method-specific-attributes + */ +export const MCP_TOOL_NAME_ATTRIBUTE = 'mcp.tool.name'; + +/** + * The resource URI being accessed + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#method-specific-attributes + */ +export const MCP_RESOURCE_URI_ATTRIBUTE = 'mcp.resource.uri'; + +/** + * Name of the prompt template + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#method-specific-attributes + */ +export const MCP_PROMPT_NAME_ATTRIBUTE = 'mcp.prompt.name'; + +// ============================================================================= +// TOOL RESULT ATTRIBUTES +// ============================================================================= + +/** + * Whether a tool execution resulted in an error + */ +export const MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE = 'mcp.tool.result.is_error'; + +/** + * Number of content items in the tool result + */ +export const MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE = 'mcp.tool.result.content_count'; + +/** + * Serialized content of the tool result + */ +export const MCP_TOOL_RESULT_CONTENT_ATTRIBUTE = 'mcp.tool.result.content'; + +// ============================================================================= +// NETWORK ATTRIBUTES (OpenTelemetry Standard) +// ============================================================================= + +/** + * OSI transport layer protocol + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#network-attributes + */ +export const NETWORK_TRANSPORT_ATTRIBUTE = 'network.transport'; + +/** + * The version of JSON RPC protocol used + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#network-attributes + */ +export const NETWORK_PROTOCOL_VERSION_ATTRIBUTE = 'network.protocol.version'; + +/** + * Client address - domain name if available without reverse DNS lookup; otherwise, IP address or Unix domain socket name + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#network-attributes + */ +export const CLIENT_ADDRESS_ATTRIBUTE = 'client.address'; + +/** + * Client port number + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md#network-attributes + */ +export const CLIENT_PORT_ATTRIBUTE = 'client.port'; + +// ============================================================================= +// SENTRY-SPECIFIC MCP ATTRIBUTE VALUES +// ============================================================================= + +/** + * Sentry operation value for MCP server spans + */ +export const MCP_SERVER_OP_VALUE = 'mcp.server'; + +/** + * Sentry operation value for client-to-server notifications + * Following OpenTelemetry MCP semantic conventions + */ +export const MCP_NOTIFICATION_CLIENT_TO_SERVER_OP_VALUE = 'mcp.notification.client_to_server'; + +/** + * Sentry operation value for server-to-client notifications + * Following OpenTelemetry MCP semantic conventions + */ +export const MCP_NOTIFICATION_SERVER_TO_CLIENT_OP_VALUE = 'mcp.notification.server_to_client'; + +/** + * Sentry origin value for MCP function spans + */ +export const MCP_FUNCTION_ORIGIN_VALUE = 'auto.function.mcp_server'; + +/** + * Sentry origin value for MCP notification spans + */ +export const MCP_NOTIFICATION_ORIGIN_VALUE = 'auto.mcp.notification'; + +/** + * Sentry source value for MCP route spans + */ +export const MCP_ROUTE_SOURCE_VALUE = 'route'; diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts new file mode 100644 index 000000000000..cf9aa466e1a8 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -0,0 +1,154 @@ +/** + * Request-span correlation system for MCP server instrumentation + * Handles mapping requestId to span data for correlation with handler execution + */ + +import { getClient } from '../../currentScopes'; +import { withActiveSpan } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; +import { + MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, + MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE, + MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE, +} from './attributes'; +import { captureError } from './errorCapture'; +import { filterMcpPiiFromSpanData } from './piiFiltering'; +import type { RequestId, SessionId } from './types'; + +// Simplified correlation system that works with or without sessionId +// Maps requestId directly to span data for stateless operation +const requestIdToSpanMap = new Map< + RequestId, + { + span: Span; + method: string; + startTime: number; + } +>(); + +/** + * Stores span context for later correlation with handler execution + */ +export function storeSpanForRequest(requestId: RequestId, span: Span, method: string): void { + requestIdToSpanMap.set(requestId, { + span, + method, + startTime: Date.now(), + }); +} + +/** + * Associates handler execution with the corresponding request span + */ +export function associateContextWithRequestSpan( + extraHandlerData: { sessionId?: SessionId; requestId: RequestId } | undefined, + cb: () => T, +): T { + if (extraHandlerData) { + const { requestId } = extraHandlerData; + + const spanData = requestIdToSpanMap.get(requestId); + if (!spanData) { + return cb(); + } + + // Keep span in map for response enrichment (don't delete yet) + return withActiveSpan(spanData.span, () => { + return cb(); + }); + } + + return cb(); +} + +/** + * Completes span with tool results and cleans up correlation + */ +export function completeSpanWithResults(requestId: RequestId, result: unknown): void { + const spanData = requestIdToSpanMap.get(requestId); + if (spanData) { + const { span, method } = spanData; + + const spanWithMethods = span as Span & { + setAttributes: (attrs: Record) => void; + setStatus: (status: { code: number; message: string }) => void; + end: () => void; + }; + + if (spanWithMethods.setAttributes && method === 'tools/call') { + // Add tool-specific attributes with PII filtering + const rawToolAttributes = extractToolResultAttributes(result); + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + const toolAttributes = filterMcpPiiFromSpanData(rawToolAttributes, sendDefaultPii); + + spanWithMethods.setAttributes(toolAttributes); + + const isToolError = rawToolAttributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] === true; + + if (isToolError) { + spanWithMethods.setStatus({ + code: 2, // ERROR + message: 'Tool execution failed', + }); + + captureError(new Error('Tool returned error result'), 'tool_execution'); + } + } + + if (spanWithMethods.end) { + spanWithMethods.end(); + } + + requestIdToSpanMap.delete(requestId); + } +} + +/** + * Cleans up all pending spans (for transport close) + */ +export function cleanupAllPendingSpans(): number { + const pendingCount = requestIdToSpanMap.size; + + for (const [, spanData] of requestIdToSpanMap) { + const spanWithEnd = spanData.span as Span & { + end: () => void; + setStatus: (status: { code: number; message: string }) => void; + }; + if (spanWithEnd.setStatus && spanWithEnd.end) { + spanWithEnd.setStatus({ + code: 2, // ERROR + message: 'Transport closed before request completion', + }); + spanWithEnd.end(); + } + } + + requestIdToSpanMap.clear(); + return pendingCount; +} + +/** + * Simplified tool result attribute extraction + */ +function extractToolResultAttributes(result: unknown): Record { + const attributes: Record = {}; + + if (typeof result === 'object' && result !== null) { + const resultObj = result as Record; + + if (typeof resultObj.isError === 'boolean') { + attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = resultObj.isError; + } + + if (Array.isArray(resultObj.content)) { + attributes[MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE] = resultObj.content.length; + + const serializedContent = JSON.stringify(resultObj.content); + attributes[MCP_TOOL_RESULT_CONTENT_ATTRIBUTE] = + serializedContent.length > 5000 ? `${serializedContent.substring(0, 4997)}...` : serializedContent; + } + } + + return attributes; +} diff --git a/packages/core/src/integrations/mcp-server/errorCapture.ts b/packages/core/src/integrations/mcp-server/errorCapture.ts new file mode 100644 index 000000000000..72855e2ba2d9 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/errorCapture.ts @@ -0,0 +1,29 @@ +/** + * Safe error capture utilities for MCP server instrumentation + * Ensures Sentry error reporting never interferes with MCP service operation + */ + +import { getClient } from '../../currentScopes'; +import { captureException } from '../../exports'; + +/** + * Safely captures an error to Sentry without affecting MCP service operation + * The active span already contains all MCP context (method, tool, arguments, etc.) + * Sentry automatically associates the error with the active span + */ +export function captureError(error: Error, errorType?: string): void { + try { + const client = getClient(); + if (!client) { + return; + } + + captureException(error, { + tags: { + mcp_error_type: errorType || 'handler_execution', + }, + }); + } catch { + // Silently ignore capture errors - never affect MCP operation + } +} diff --git a/packages/core/src/integrations/mcp-server/handlers.ts b/packages/core/src/integrations/mcp-server/handlers.ts new file mode 100644 index 000000000000..d381b46ddf48 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/handlers.ts @@ -0,0 +1,156 @@ +/** + * Handler wrapping functions for MCP server methods + * Provides span correlation for tool, resource, and prompt handlers + */ + +import { DEBUG_BUILD } from '../../debug-build'; +import { logger } from '../../utils/logger'; +import { fill } from '../../utils/object'; +import { associateContextWithRequestSpan } from './correlation'; +import { captureError } from './errorCapture'; +import type { HandlerExtraData, MCPHandler, MCPServerInstance } from './types'; + +/** + * Generic function to wrap MCP server method handlers + */ +function wrapMethodHandler(serverInstance: MCPServerInstance, methodName: keyof MCPServerInstance): void { + fill(serverInstance, methodName, originalMethod => { + return function (this: MCPServerInstance, name: string, ...args: unknown[]) { + const handler = args[args.length - 1]; + + if (typeof handler !== 'function') { + return (originalMethod as (...args: unknown[]) => unknown).call(this, name, ...args); + } + + const wrappedHandler = createWrappedHandler(handler as MCPHandler, methodName, name); + return (originalMethod as (...args: unknown[]) => unknown).call(this, name, ...args.slice(0, -1), wrappedHandler); + }; + }); +} + +/** + * Creates a wrapped handler with span correlation and error capture + */ +function createWrappedHandler(originalHandler: MCPHandler, methodName: keyof MCPServerInstance, handlerName: string) { + return function (this: unknown, ...handlerArgs: unknown[]): unknown { + try { + const extraHandlerData = findExtraHandlerData(handlerArgs); + + return associateContextWithRequestSpan(extraHandlerData, () => { + return createErrorCapturingHandler(originalHandler, methodName, handlerName, handlerArgs, extraHandlerData); + }); + } catch (error) { + DEBUG_BUILD && logger.warn('MCP handler wrapping failed:', error); + return originalHandler.apply(this, handlerArgs); + } + }; +} + +/** + * Creates a handler that captures execution errors for Sentry + */ +function createErrorCapturingHandler( + originalHandler: MCPHandler, + methodName: keyof MCPServerInstance, + handlerName: string, + handlerArgs: unknown[], + extraHandlerData?: HandlerExtraData, +): unknown { + try { + const result = originalHandler.apply(originalHandler, handlerArgs); + + // Handle both sync and async handlers + if (result && typeof result === 'object' && 'then' in result) { + // Async handler - wrap with error capture + return (result as Promise).catch((error: Error) => { + captureHandlerError(error, methodName, handlerName, handlerArgs, extraHandlerData); + throw error; // Re-throw to maintain MCP error handling behavior + }); + } + + // Sync handler - return result as-is + return result; + } catch (error) { + // Sync handler threw an error - capture it + captureHandlerError(error as Error, methodName, handlerName, handlerArgs, extraHandlerData); + throw error; // Re-throw to maintain MCP error handling behavior + } +} + +/** + * Captures handler execution errors based on handler type + */ +function captureHandlerError( + error: Error, + methodName: keyof MCPServerInstance, + _handlerName: string, + _handlerArgs: unknown[], + _extraHandlerData?: HandlerExtraData, +): void { + try { + if (methodName === 'tool') { + // Check if this is a validation/protocol error + if ( + error.name === 'ProtocolValidationError' || + error.message.includes('validation') || + error.message.includes('protocol') + ) { + captureError(error, 'validation'); + } else if ( + error.name === 'ServerTimeoutError' || + error.message.includes('timed out') || + error.message.includes('timeout') + ) { + captureError(error, 'timeout'); + } else { + captureError(error, 'tool_execution'); + } + } else if (methodName === 'resource') { + captureError(error, 'resource_operation'); + } else if (methodName === 'prompt') { + captureError(error, 'prompt_execution'); + } + } catch (captureErr) { + // silently ignore capture errors to not affect MCP operation + } +} + +/** + * Extracts request/session data from handler arguments + */ +function findExtraHandlerData(handlerArgs: unknown[]): HandlerExtraData | undefined { + return handlerArgs.find( + (arg): arg is HandlerExtraData => + arg != null && typeof arg === 'object' && 'requestId' in arg && (arg as { requestId: unknown }).requestId != null, + ); +} + +/** + * Wraps tool handlers to associate them with request spans + */ +export function wrapToolHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'tool'); +} + +/** + * Wraps resource handlers to associate them with request spans + */ +export function wrapResourceHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'resource'); +} + +/** + * Wraps prompt handlers to associate them with request spans + */ +export function wrapPromptHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'prompt'); +} + +/** + * Wraps all MCP handler types (tool, resource, prompt) for span correlation + */ +export function wrapAllMCPHandlers(serverInstance: MCPServerInstance): void { + wrapToolHandlers(serverInstance); + wrapResourceHandlers(serverInstance); + wrapPromptHandlers(serverInstance); +} diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts new file mode 100644 index 000000000000..8e5aeb54574b --- /dev/null +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -0,0 +1,48 @@ +import { fill } from '../../utils/object'; +import { wrapAllMCPHandlers } from './handlers'; +import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport'; +import type { MCPServerInstance, MCPTransport } from './types'; +import { validateMcpServerInstance } from './validation'; + +const wrappedMcpServerInstances = new WeakSet(); + +/** + * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. + * + * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. + */ +export function wrapMcpServerWithSentry(mcpServerInstance: S): S { + if (wrappedMcpServerInstances.has(mcpServerInstance)) { + return mcpServerInstance; + } + + if (!validateMcpServerInstance(mcpServerInstance)) { + return mcpServerInstance; + } + + const serverInstance = mcpServerInstance as MCPServerInstance; + + fill(serverInstance, 'connect', originalConnect => { + return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { + const result = await (originalConnect as (...args: unknown[]) => Promise).call( + this, + transport, + ...restArgs, + ); + + // Wrap transport methods + wrapTransportOnMessage(transport); + wrapTransportSend(transport); + wrapTransportOnClose(transport); + wrapTransportError(transport); + + return result; + }; + }); + + // Wrap server handler methods + wrapAllMCPHandlers(serverInstance); + + wrappedMcpServerInstances.add(mcpServerInstance); + return mcpServerInstance as S; +} diff --git a/packages/core/src/integrations/mcp-server/piiFiltering.ts b/packages/core/src/integrations/mcp-server/piiFiltering.ts new file mode 100644 index 000000000000..b9fb5ec0c9e2 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -0,0 +1,35 @@ +/** + * PII filtering for MCP server spans + * Removes sensitive data when sendDefaultPii is false + */ + +import { MCP_TOOL_RESULT_CONTENT_ATTRIBUTE } from './attributes'; + +/** PII attributes that should be removed when sendDefaultPii is false */ +const PII_ATTRIBUTES = new Set([ + 'client.address', + 'client.port', + 'mcp.logging.message', + 'mcp.resource.uri', + MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, +]); + +/** + * Removes PII attributes from span data when sendDefaultPii is false + */ +export function filterMcpPiiFromSpanData( + spanData: Record, + sendDefaultPii: boolean, +): Record { + if (sendDefaultPii) { + return spanData; // Send everything when PII is allowed + } + + // Use Object.fromEntries with filter for a more functional approach + return Object.fromEntries( + Object.entries(spanData).filter(([key]) => { + const isPiiAttribute = PII_ATTRIBUTES.has(key) || key.startsWith('mcp.request.argument.'); + return !isPiiAttribute; + }), + ); +} diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts new file mode 100644 index 000000000000..c54c5944dd82 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -0,0 +1,186 @@ +/** + * Span creation and management functions for MCP server instrumentation + */ + +import { getClient } from '../../currentScopes'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '../../semanticAttributes'; +import { startSpan } from '../../tracing'; +import { buildTransportAttributes, buildTypeSpecificAttributes, extractTargetInfo } from './attributeExtraction'; +import { + MCP_FUNCTION_ORIGIN_VALUE, + MCP_METHOD_NAME_ATTRIBUTE, + MCP_NOTIFICATION_CLIENT_TO_SERVER_OP_VALUE, + MCP_NOTIFICATION_ORIGIN_VALUE, + MCP_NOTIFICATION_SERVER_TO_CLIENT_OP_VALUE, + MCP_ROUTE_SOURCE_VALUE, + MCP_SERVER_OP_VALUE, +} from './attributes'; +import { filterMcpPiiFromSpanData } from './piiFiltering'; +import type { ExtraHandlerData, JsonRpcNotification, JsonRpcRequest, McpSpanConfig, MCPTransport } from './types'; + +/** + * Creates a span name based on the method and target + */ +function createSpanName(method: string, target?: string): string { + return target ? `${method} ${target}` : method; +} + +/** + * Build Sentry-specific attributes based on span type + * Uses specific operations for notification direction + */ +function buildSentryAttributes(type: McpSpanConfig['type']): Record { + let op: string; + let origin: string; + + switch (type) { + case 'request': + op = MCP_SERVER_OP_VALUE; + origin = MCP_FUNCTION_ORIGIN_VALUE; + break; + case 'notification-incoming': + op = MCP_NOTIFICATION_CLIENT_TO_SERVER_OP_VALUE; + origin = MCP_NOTIFICATION_ORIGIN_VALUE; + break; + case 'notification-outgoing': + op = MCP_NOTIFICATION_SERVER_TO_CLIENT_OP_VALUE; + origin = MCP_NOTIFICATION_ORIGIN_VALUE; + break; + } + + return { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: MCP_ROUTE_SOURCE_VALUE, + }; +} + +/** + * Unified builder for creating MCP spans + * Follows OpenTelemetry semantic conventions for span naming + */ +function createMcpSpan(config: McpSpanConfig): unknown { + const { type, message, transport, extra, callback } = config; + const { method } = message; + const params = message.params as Record | undefined; + + // Determine span name based on type and OTEL conventions + let spanName: string; + if (type === 'request') { + const targetInfo = extractTargetInfo(method, params || {}); + spanName = createSpanName(method, targetInfo.target); + } else { + // For notifications, use method name directly per OpenTelemetry conventions + spanName = method; + } + + // Build attributes + const rawAttributes: Record = { + // Base attributes + ...buildTransportAttributes(transport, extra), + // Method name (required for all spans) + [MCP_METHOD_NAME_ATTRIBUTE]: method, + // Type-specific attributes + ...buildTypeSpecificAttributes(type, message, params), + // Sentry attributes + ...buildSentryAttributes(type), + }; + + // Apply PII filtering based on sendDefaultPii setting + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + const attributes = filterMcpPiiFromSpanData(rawAttributes, sendDefaultPii) as Record; + + return startSpan( + { + name: spanName, + forceTransaction: true, + attributes, + }, + callback, + ); +} + +/** + * Creates a span for incoming MCP notifications + */ +export function createMcpNotificationSpan( + jsonRpcMessage: JsonRpcNotification, + transport: MCPTransport, + extra: ExtraHandlerData, + callback: () => unknown, +): unknown { + return createMcpSpan({ + type: 'notification-incoming', + message: jsonRpcMessage, + transport, + extra, + callback, + }); +} + +/** + * Creates a span for outgoing MCP notifications + */ +export function createMcpOutgoingNotificationSpan( + jsonRpcMessage: JsonRpcNotification, + transport: MCPTransport, + callback: () => unknown, +): unknown { + return createMcpSpan({ + type: 'notification-outgoing', + message: jsonRpcMessage, + transport, + callback, + }); +} + +/** + * Builds span configuration for MCP server requests + * Used for deferred span completion pattern + */ +export function buildMcpServerSpanConfig( + jsonRpcMessage: JsonRpcRequest, + transport: MCPTransport, + extra?: ExtraHandlerData, +): { + name: string; + op: string; + forceTransaction: boolean; + attributes: Record; +} { + const { method } = jsonRpcMessage; + const params = jsonRpcMessage.params as Record | undefined; + + // Extract target for span name + const targetInfo = extractTargetInfo(method, params || {}); + const spanName = createSpanName(method, targetInfo.target); + + // Build comprehensive attributes + const rawAttributes: Record = { + // Base attributes + ...buildTransportAttributes(transport, extra), + // Method and request info + [MCP_METHOD_NAME_ATTRIBUTE]: method, + // Type-specific attributes + ...buildTypeSpecificAttributes('request', jsonRpcMessage, params), + // Sentry attributes + ...buildSentryAttributes('request'), + }; + + // Apply PII filtering based on sendDefaultPii setting + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + const attributes = filterMcpPiiFromSpanData(rawAttributes, sendDefaultPii) as Record; + + return { + name: spanName, + op: MCP_SERVER_OP_VALUE, + forceTransaction: true, + attributes, + }; +} diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts new file mode 100644 index 000000000000..70d186807b94 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -0,0 +1,148 @@ +/** + * Transport layer instrumentation for MCP server + * Handles message interception and response correlation + */ + +import { getIsolationScope, withIsolationScope } from '../../currentScopes'; +import { startInactiveSpan, withActiveSpan } from '../../tracing'; +import { fill } from '../../utils/object'; +import { cleanupAllPendingSpans, completeSpanWithResults, storeSpanForRequest } from './correlation'; +import { captureError } from './errorCapture'; +import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; +import type { ExtraHandlerData, MCPTransport } from './types'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse } from './validation'; + +/** + * Wraps transport.onmessage to create spans for incoming messages + */ +export function wrapTransportOnMessage(transport: MCPTransport): void { + if (transport.onmessage) { + fill(transport, 'onmessage', originalOnMessage => { + return function (this: MCPTransport, jsonRpcMessage: unknown, extra?: unknown) { + if (isJsonRpcRequest(jsonRpcMessage)) { + const messageTyped = jsonRpcMessage as { method: string; id: string | number }; + + // Create isolation scope for this request (standard Sentry pattern) + const isolationScope = getIsolationScope().clone(); + + return withIsolationScope(isolationScope, () => { + // Create manual span that stays open until response + const spanConfig = buildMcpServerSpanConfig(jsonRpcMessage, this, extra as ExtraHandlerData); + const span = startInactiveSpan(spanConfig); + + // Store span context for handler correlation using requestId + storeSpanForRequest(messageTyped.id, span, messageTyped.method); + + // Execute handler within span context + return withActiveSpan(span, () => { + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, jsonRpcMessage, extra); + }); + }); + } + + if (isJsonRpcNotification(jsonRpcMessage)) { + return createMcpNotificationSpan(jsonRpcMessage, this, extra as ExtraHandlerData, () => { + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, jsonRpcMessage, extra); + }); + } + + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, jsonRpcMessage, extra); + }; + }); + } +} + +/** + * Wraps transport.send to handle outgoing messages and response correlation + */ +export function wrapTransportSend(transport: MCPTransport): void { + if (transport.send) { + fill(transport, 'send', originalSend => { + return async function (this: MCPTransport, message: unknown) { + // Handle outgoing notifications + if (isJsonRpcNotification(message)) { + return createMcpOutgoingNotificationSpan(message, this, () => { + return (originalSend as (...args: unknown[]) => unknown).call(this, message); + }); + } + + if (isJsonRpcResponse(message)) { + const messageTyped = message as { id: string | number; result?: unknown; error?: unknown }; + + if (messageTyped.id !== null && messageTyped.id !== undefined) { + if (messageTyped.error) { + captureJsonRpcErrorResponse(messageTyped.error, messageTyped.id, this); + } + + completeSpanWithResults(messageTyped.id, messageTyped.result); + } + } + + return (originalSend as (...args: unknown[]) => unknown).call(this, message); + }; + }); + } +} + +/** + * Wraps transport.onclose to clean up pending spans + */ +export function wrapTransportOnClose(transport: MCPTransport): void { + if (transport.onclose) { + fill(transport, 'onclose', originalOnClose => { + return function (this: MCPTransport, ...args: unknown[]) { + cleanupAllPendingSpans(); + + return (originalOnClose as (...args: unknown[]) => unknown).call(this, ...args); + }; + }); + } +} + +/** + * Wraps transport error handlers to capture connection errors + */ +export function wrapTransportError(transport: MCPTransport): void { + // All MCP transports have an onerror method as part of the standard interface + if (transport.onerror) { + fill(transport, 'onerror', (originalOnError: (error: Error) => void) => { + return function (this: MCPTransport, error: Error) { + captureTransportError(error, this); + return originalOnError.call(this, error); + }; + }); + } +} + +/** + * Captures JSON-RPC error responses + */ +function captureJsonRpcErrorResponse( + errorResponse: unknown, + _requestId: string | number, + _transport: MCPTransport, +): void { + try { + if (errorResponse && typeof errorResponse === 'object' && 'code' in errorResponse && 'message' in errorResponse) { + const jsonRpcError = errorResponse as { code: number; message: string; data?: unknown }; + + const error = new Error(jsonRpcError.message); + error.name = `JsonRpcError_${jsonRpcError.code}`; + + captureError(error, 'protocol'); + } + } catch { + // Silently ignore capture errors + } +} + +/** + * Captures transport connection errors + */ +function captureTransportError(error: Error, _transport: MCPTransport): void { + try { + captureError(error, 'transport'); + } catch { + // Silently ignore capture errors + } +} diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts new file mode 100644 index 000000000000..fdc987b70f85 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -0,0 +1,166 @@ +/** + * types for MCP server instrumentation + */ + +/** Method configuration type */ +export type MethodConfig = { + targetField: string; + targetAttribute: string; + captureArguments?: boolean; + argumentsField?: string; + captureUri?: boolean; + captureName?: boolean; +}; + +/** + * JSON-RPC 2.0 request object + */ +export interface JsonRpcRequest { + jsonrpc: '2.0'; + method: string; + id: string | number; + params?: Record; +} + +/** + * JSON-RPC 2.0 response object + */ +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number | null; + result?: unknown; + error?: JsonRpcError; +} + +/** + * JSON-RPC 2.0 error object + */ +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +/** + * JSON-RPC 2.0 notification object + * Note: Notifications do NOT have an 'id' field - this is what distinguishes them from requests + */ +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: Record; +} + +/** + * MCP transport interface + */ +export interface MCPTransport { + /** + * Message handler for incoming JSON-RPC messages + * The first argument is a JSON RPC message + */ + onmessage?: (...args: unknown[]) => void; + + /** + * Close handler for transport lifecycle + */ + onclose?: (...args: unknown[]) => void; + + /** + * Error handler for transport errors + */ + onerror?: (error: Error) => void; + + /** + * Send method for outgoing messages + */ + send?: (message: JsonRpcMessage, options?: Record) => Promise; + + /** + * Optional session identifier + */ + sessionId?: string; +} + +/** + * Union type for all JSON-RPC message types + */ +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +/** + * MCP server instance interface + */ +export interface MCPServerInstance { + /** + * Register a resource handler + * The first arg is always a name, the last arg should always be a callback function (ie a handler). + */ + resource: (name: string, ...args: unknown[]) => void; + + /** + * Register a tool handler + * The first arg is always a name, the last arg should always be a callback function (ie a handler). + */ + tool: (name: string, ...args: unknown[]) => void; + + /** + * Register a prompt handler + * The first arg is always a name, the last arg should always be a callback function (ie a handler). + */ + prompt: (name: string, ...args: unknown[]) => void; + + /** + * Connect the server to a transport + */ + connect(transport: MCPTransport): Promise; +} + +export interface ExtraHandlerData { + requestInfo?: { remoteAddress?: string; remotePort?: number }; + clientAddress?: string; + clientPort?: number; + request?: { + ip?: string; + connection?: { remoteAddress?: string; remotePort?: number }; + }; +} + +/** + * Types of MCP spans that can be created + */ +export type McpSpanType = 'request' | 'notification-incoming' | 'notification-outgoing'; + +/** + * Configuration for creating MCP spans + */ +export interface McpSpanConfig { + type: McpSpanType; + message: JsonRpcRequest | JsonRpcNotification; + transport: MCPTransport; + extra?: ExtraHandlerData; + callback: () => unknown; +} + +export type SessionId = string; +export type RequestId = string | number; +export type MCPHandler = (...args: unknown[]) => unknown; +export interface HandlerExtraData { + sessionId?: SessionId; + requestId: RequestId; +} + +/** Error types for MCP server operations */ +export type McpErrorType = 'tool_execution' | 'transport' | 'protocol' | 'validation' | 'timeout'; + +/** Context information for MCP errors */ +export interface McpErrorContext { + errorType: McpErrorType; + method?: string; + toolName?: string; + resourceUri?: string; + requestId?: string; + sessionId?: string; + transportType?: string; + errorData?: Record; + contextData?: Record; +} diff --git a/packages/core/src/integrations/mcp-server/validation.ts b/packages/core/src/integrations/mcp-server/validation.ts new file mode 100644 index 000000000000..296513e214a7 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/validation.ts @@ -0,0 +1,61 @@ +/** + * Message validation functions for MCP server instrumentation + */ + +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/logger'; +import type { JsonRpcNotification, JsonRpcRequest } from './types'; + +/** Validates if a message is a JSON-RPC request */ +export function isJsonRpcRequest(message: unknown): message is JsonRpcRequest { + return ( + typeof message === 'object' && + message !== null && + 'jsonrpc' in message && + (message as JsonRpcRequest).jsonrpc === '2.0' && + 'method' in message && + 'id' in message + ); +} + +/** Validates if a message is a JSON-RPC notification */ +export function isJsonRpcNotification(message: unknown): message is JsonRpcNotification { + return ( + typeof message === 'object' && + message !== null && + 'jsonrpc' in message && + (message as JsonRpcNotification).jsonrpc === '2.0' && + 'method' in message && + !('id' in message) + ); +} + +/** Validates if a message is a JSON-RPC response */ +export function isJsonRpcResponse( + message: unknown, +): message is { jsonrpc: '2.0'; id: string | number | null; result?: unknown; error?: unknown } { + return ( + typeof message === 'object' && + message !== null && + 'jsonrpc' in message && + (message as { jsonrpc: string }).jsonrpc === '2.0' && + 'id' in message && + ('result' in message || 'error' in message) + ); +} + +/** Validates MCP server instance with type checking */ +export function validateMcpServerInstance(instance: unknown): boolean { + if ( + typeof instance === 'object' && + instance !== null && + 'resource' in instance && + 'tool' in instance && + 'prompt' in instance && + 'connect' in instance + ) { + return true; + } + DEBUG_BUILD && debug.warn('Did not patch MCP server. Interface is incompatible.'); + return false; +} diff --git a/packages/core/src/mcp-server.ts b/packages/core/src/mcp-server.ts deleted file mode 100644 index fe53d228bd60..000000000000 --- a/packages/core/src/mcp-server.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { DEBUG_BUILD } from './debug-build'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, -} from './semanticAttributes'; -import { startSpan, withActiveSpan } from './tracing'; -import type { Span } from './types-hoist/span'; -import { debug } from './utils/logger'; -import { getActiveSpan } from './utils/spanUtils'; - -interface MCPTransport { - // The first argument is a JSON RPC message - onmessage?: (...args: unknown[]) => void; - onclose?: (...args: unknown[]) => void; - sessionId?: string; -} - -interface MCPServerInstance { - // The first arg is always a name, the last arg should always be a callback function (ie a handler). - // TODO: We could also make use of the resource uri argument somehow. - resource: (name: string, ...args: unknown[]) => void; - // The first arg is always a name, the last arg should always be a callback function (ie a handler). - tool: (name: string, ...args: unknown[]) => void; - // The first arg is always a name, the last arg should always be a callback function (ie a handler). - prompt: (name: string, ...args: unknown[]) => void; - connect(transport: MCPTransport): Promise; -} - -const wrappedMcpServerInstances = new WeakSet(); - -/** - * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. - * - * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. - */ -// We are exposing this API for non-node runtimes that cannot rely on auto-instrumentation. -export function wrapMcpServerWithSentry(mcpServerInstance: S): S { - if (wrappedMcpServerInstances.has(mcpServerInstance)) { - return mcpServerInstance; - } - - if (!isMcpServerInstance(mcpServerInstance)) { - DEBUG_BUILD && debug.warn('Did not patch MCP server. Interface is incompatible.'); - return mcpServerInstance; - } - - // eslint-disable-next-line @typescript-eslint/unbound-method - mcpServerInstance.connect = new Proxy(mcpServerInstance.connect, { - apply(target, thisArg, argArray) { - const [transport, ...restArgs] = argArray as [MCPTransport, ...unknown[]]; - - if (!transport.onclose) { - transport.onclose = () => { - if (transport.sessionId) { - handleTransportOnClose(transport.sessionId); - } - }; - } - - if (!transport.onmessage) { - transport.onmessage = jsonRpcMessage => { - if (transport.sessionId && isJsonRPCMessageWithRequestId(jsonRpcMessage)) { - handleTransportOnMessage(transport.sessionId, jsonRpcMessage.id); - } - }; - } - - const patchedTransport = new Proxy(transport, { - set(target, key, value) { - if (key === 'onmessage') { - target[key] = new Proxy(value, { - apply(onMessageTarget, onMessageThisArg, onMessageArgArray) { - const [jsonRpcMessage] = onMessageArgArray; - if (transport.sessionId && isJsonRPCMessageWithRequestId(jsonRpcMessage)) { - handleTransportOnMessage(transport.sessionId, jsonRpcMessage.id); - } - return Reflect.apply(onMessageTarget, onMessageThisArg, onMessageArgArray); - }, - }); - } else if (key === 'onclose') { - target[key] = new Proxy(value, { - apply(onCloseTarget, onCloseThisArg, onCloseArgArray) { - if (transport.sessionId) { - handleTransportOnClose(transport.sessionId); - } - return Reflect.apply(onCloseTarget, onCloseThisArg, onCloseArgArray); - }, - }); - } else { - target[key as keyof MCPTransport] = value; - } - return true; - }, - }); - - return Reflect.apply(target, thisArg, [patchedTransport, ...restArgs]); - }, - }); - - mcpServerInstance.resource = new Proxy(mcpServerInstance.resource, { - apply(target, thisArg, argArray) { - const resourceName: unknown = argArray[0]; - const resourceHandler: unknown = argArray[argArray.length - 1]; - - if (typeof resourceName !== 'string' || typeof resourceHandler !== 'function') { - return target.apply(thisArg, argArray); - } - - const wrappedResourceHandler = new Proxy(resourceHandler, { - apply(resourceHandlerTarget, resourceHandlerThisArg, resourceHandlerArgArray) { - const extraHandlerDataWithRequestId = resourceHandlerArgArray.find(isExtraHandlerDataWithRequestId); - return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => { - return startSpan( - { - name: `mcp-server/resource:${resourceName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.resource': resourceName, - }, - }, - () => resourceHandlerTarget.apply(resourceHandlerThisArg, resourceHandlerArgArray), - ); - }); - }, - }); - - return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedResourceHandler]); - }, - }); - - mcpServerInstance.tool = new Proxy(mcpServerInstance.tool, { - apply(target, thisArg, argArray) { - const toolName: unknown = argArray[0]; - const toolHandler: unknown = argArray[argArray.length - 1]; - - if (typeof toolName !== 'string' || typeof toolHandler !== 'function') { - return target.apply(thisArg, argArray); - } - - const wrappedToolHandler = new Proxy(toolHandler, { - apply(toolHandlerTarget, toolHandlerThisArg, toolHandlerArgArray) { - const extraHandlerDataWithRequestId = toolHandlerArgArray.find(isExtraHandlerDataWithRequestId); - return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => { - return startSpan( - { - name: `mcp-server/tool:${toolName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.tool': toolName, - }, - }, - () => toolHandlerTarget.apply(toolHandlerThisArg, toolHandlerArgArray), - ); - }); - }, - }); - - return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedToolHandler]); - }, - }); - - mcpServerInstance.prompt = new Proxy(mcpServerInstance.prompt, { - apply(target, thisArg, argArray) { - const promptName: unknown = argArray[0]; - const promptHandler: unknown = argArray[argArray.length - 1]; - - if (typeof promptName !== 'string' || typeof promptHandler !== 'function') { - return target.apply(thisArg, argArray); - } - - const wrappedPromptHandler = new Proxy(promptHandler, { - apply(promptHandlerTarget, promptHandlerThisArg, promptHandlerArgArray) { - const extraHandlerDataWithRequestId = promptHandlerArgArray.find(isExtraHandlerDataWithRequestId); - return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => { - return startSpan( - { - name: `mcp-server/prompt:${promptName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.prompt': promptName, - }, - }, - () => promptHandlerTarget.apply(promptHandlerThisArg, promptHandlerArgArray), - ); - }); - }, - }); - - return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedPromptHandler]); - }, - }); - - wrappedMcpServerInstances.add(mcpServerInstance); - - return mcpServerInstance as S; -} - -function isMcpServerInstance(mcpServerInstance: unknown): mcpServerInstance is MCPServerInstance { - return ( - typeof mcpServerInstance === 'object' && - mcpServerInstance !== null && - 'resource' in mcpServerInstance && - typeof mcpServerInstance.resource === 'function' && - 'tool' in mcpServerInstance && - typeof mcpServerInstance.tool === 'function' && - 'prompt' in mcpServerInstance && - typeof mcpServerInstance.prompt === 'function' && - 'connect' in mcpServerInstance && - typeof mcpServerInstance.connect === 'function' - ); -} - -function isJsonRPCMessageWithRequestId(target: unknown): target is { id: RequestId } { - return ( - typeof target === 'object' && - target !== null && - 'id' in target && - (typeof target.id === 'number' || typeof target.id === 'string') - ); -} - -interface ExtraHandlerDataWithRequestId { - sessionId: SessionId; - requestId: RequestId; -} - -// Note that not all versions of the MCP library have `requestId` as a field on the extra data. -function isExtraHandlerDataWithRequestId(target: unknown): target is ExtraHandlerDataWithRequestId { - return ( - typeof target === 'object' && - target !== null && - 'sessionId' in target && - typeof target.sessionId === 'string' && - 'requestId' in target && - (typeof target.requestId === 'number' || typeof target.requestId === 'string') - ); -} - -type SessionId = string; -type RequestId = string | number; - -const sessionAndRequestToRequestParentSpanMap = new Map>(); - -function handleTransportOnClose(sessionId: SessionId): void { - sessionAndRequestToRequestParentSpanMap.delete(sessionId); -} - -function handleTransportOnMessage(sessionId: SessionId, requestId: RequestId): void { - const activeSpan = getActiveSpan(); - if (activeSpan) { - const requestIdToSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId) ?? new Map(); - requestIdToSpanMap.set(requestId, activeSpan); - sessionAndRequestToRequestParentSpanMap.set(sessionId, requestIdToSpanMap); - } -} - -function associateContextWithRequestSpan( - extraHandlerData: ExtraHandlerDataWithRequestId | undefined, - cb: () => T, -): T { - if (extraHandlerData) { - const { sessionId, requestId } = extraHandlerData; - const requestIdSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId); - - if (!requestIdSpanMap) { - return cb(); - } - - const span = requestIdSpanMap.get(requestId); - if (!span) { - return cb(); - } - - // remove the span from the map so it can be garbage collected - requestIdSpanMap.delete(requestId); - return withActiveSpan(span, () => { - return cb(); - }); - } - - return cb(); -} diff --git a/packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts b/packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts new file mode 100644 index 000000000000..3c2ea7fdb7e7 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import * as exports from '../../../../src/exports'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { captureError } from '../../../../src/integrations/mcp-server/errorCapture'; +import { createMockMcpServer } from './testUtils'; + +describe('MCP Server Error Capture', () => { + const captureExceptionSpy = vi.spyOn(exports, 'captureException'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + } as ReturnType); + }); + + describe('captureError', () => { + it('should capture errors with default error type', () => { + const error = new Error('Test error'); + + captureError(error); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + tags: { + mcp_error_type: 'handler_execution', + }, + }); + }); + + it('should capture errors with custom error type', () => { + const error = new Error('Tool execution failed'); + + captureError(error, 'tool_execution'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + tags: { + mcp_error_type: 'tool_execution', + }, + }); + }); + + it('should capture transport errors', () => { + const error = new Error('Connection failed'); + + captureError(error, 'transport'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + tags: { + mcp_error_type: 'transport', + }, + }); + }); + + it('should capture protocol errors', () => { + const error = new Error('Invalid JSON-RPC request'); + + captureError(error, 'protocol'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + tags: { + mcp_error_type: 'protocol', + }, + }); + }); + + it('should capture validation errors', () => { + const error = new Error('Invalid parameters'); + + captureError(error, 'validation'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + tags: { + mcp_error_type: 'validation', + }, + }); + }); + + it('should capture timeout errors', () => { + const error = new Error('Operation timed out'); + + captureError(error, 'timeout'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + tags: { + mcp_error_type: 'timeout', + }, + }); + }); + + it('should not capture when no client is available', () => { + getClientSpy.mockReturnValue(undefined); + + const error = new Error('Test error'); + + captureError(error, 'tool_execution'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('should handle Sentry capture errors gracefully', () => { + captureExceptionSpy.mockImplementation(() => { + throw new Error('Sentry error'); + }); + + const error = new Error('Test error'); + + // Should not throw + expect(() => captureError(error, 'tool_execution')).not.toThrow(); + }); + + it('should handle undefined client gracefully', () => { + getClientSpy.mockReturnValue(undefined); + + const error = new Error('Test error'); + + // Should not throw and not capture + expect(() => captureError(error, 'tool_execution')).not.toThrow(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Error Capture Integration', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + }); + + it('should capture tool execution errors and continue normal flow', async () => { + const toolError = new Error('Tool execution failed'); + const mockToolHandler = vi.fn().mockRejectedValue(toolError); + + wrappedMcpServer.tool('failing-tool', mockToolHandler); + + await expect(mockToolHandler({ input: 'test' }, { requestId: 'req-123', sessionId: 'sess-456' })).rejects.toThrow( + 'Tool execution failed', + ); + + // The capture should be set up correctly + expect(captureExceptionSpy).toHaveBeenCalledTimes(0); // No capture yet since we didn't call the wrapped handler + }); + + it('should handle Sentry capture errors gracefully', async () => { + captureExceptionSpy.mockImplementation(() => { + throw new Error('Sentry error'); + }); + + // Test that the capture function itself doesn't throw + const toolError = new Error('Tool execution failed'); + const mockToolHandler = vi.fn().mockRejectedValue(toolError); + + wrappedMcpServer.tool('failing-tool', mockToolHandler); + + // The error capture should be resilient to Sentry errors + expect(captureExceptionSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts new file mode 100644 index 000000000000..c277162017aa --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import * as tracingModule from '../../../../src/tracing'; +import { createMockMcpServer } from './testUtils'; + +describe('wrapMcpServerWithSentry', () => { + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + // Mock client to return sendDefaultPii: + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + }); + + it('should return the same instance (modified) if it is a valid MCP server instance', () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + expect(wrappedMcpServer).toBe(mockMcpServer); + }); + + it('should return the input unchanged if it is not a valid MCP server instance', () => { + const invalidMcpServer = { + resource: () => {}, + tool: () => {}, + // Missing required methods + }; + + const result = wrapMcpServerWithSentry(invalidMcpServer); + expect(result).toBe(invalidMcpServer); + + // Methods should not be wrapped + expect(result.resource).toBe(invalidMcpServer.resource); + expect(result.tool).toBe(invalidMcpServer.tool); + + // No calls to startSpan or startInactiveSpan + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); + + it('should not wrap the same instance twice', () => { + const mockMcpServer = createMockMcpServer(); + + const wrappedOnce = wrapMcpServerWithSentry(mockMcpServer); + const wrappedTwice = wrapMcpServerWithSentry(wrappedOnce); + + expect(wrappedTwice).toBe(wrappedOnce); + }); + + it('should wrap the connect method to intercept transport', () => { + const mockMcpServer = createMockMcpServer(); + const originalConnect = mockMcpServer.connect; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + expect(wrappedMcpServer.connect).not.toBe(originalConnect); + expect(typeof wrappedMcpServer.connect).toBe('function'); + }); + + it('should wrap handler methods (tool, resource, prompt)', () => { + const mockMcpServer = createMockMcpServer(); + const originalTool = mockMcpServer.tool; + const originalResource = mockMcpServer.resource; + const originalPrompt = mockMcpServer.prompt; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + expect(wrappedMcpServer.tool).not.toBe(originalTool); + expect(wrappedMcpServer.resource).not.toBe(originalResource); + expect(wrappedMcpServer.prompt).not.toBe(originalPrompt); + }); + + describe('Handler Wrapping', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + }); + + it('should register tool handlers without throwing errors', () => { + const toolHandler = vi.fn(); + + expect(() => { + wrappedMcpServer.tool('test-tool', toolHandler); + }).not.toThrow(); + }); + + it('should register resource handlers without throwing errors', () => { + const resourceHandler = vi.fn(); + + expect(() => { + wrappedMcpServer.resource('test-resource', resourceHandler); + }).not.toThrow(); + }); + + it('should register prompt handlers without throwing errors', () => { + const promptHandler = vi.fn(); + + expect(() => { + wrappedMcpServer.prompt('test-prompt', promptHandler); + }).not.toThrow(); + }); + + it('should handle multiple arguments when registering handlers', () => { + const nonFunctionArg = { config: 'value' }; + + expect(() => { + wrappedMcpServer.tool('test-tool', nonFunctionArg, 'other-arg'); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts new file mode 100644 index 000000000000..14f803b28ccc --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { filterMcpPiiFromSpanData } from '../../../../src/integrations/mcp-server/piiFiltering'; +import * as tracingModule from '../../../../src/tracing'; +import { createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP Server PII Filtering', () => { + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Integration Tests', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockTransport = createMockTransport(); + mockTransport.sessionId = 'test-session-123'; + }); + + it('should include PII data when sendDefaultPii is true', async () => { + // Mock client with sendDefaultPii: true + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as unknown as ReturnType); + + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-pii-true', + params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + }; + + const extraWithClientInfo = { + requestInfo: { + remoteAddress: '192.168.1.100', + remotePort: 54321, + }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call weather', + op: 'mcp.server', + forceTransaction: true, + attributes: expect.objectContaining({ + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.request.argument.location': '"London"', + 'mcp.request.argument.units': '"metric"', + 'mcp.tool.name': 'weather', + }), + }); + }); + + it('should exclude PII data when sendDefaultPii is false', async () => { + // Mock client with sendDefaultPii: false + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as unknown as ReturnType); + + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-pii-false', + params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + }; + + const extraWithClientInfo = { + requestInfo: { + remoteAddress: '192.168.1.100', + remotePort: 54321, + }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'client.address': expect.anything(), + 'client.port': expect.anything(), + 'mcp.request.argument.location': expect.anything(), + 'mcp.request.argument.units': expect.anything(), + }), + }), + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.tool.name': 'weather', + 'mcp.method.name': 'tools/call', + }), + }), + ); + }); + + it('should filter tool result content when sendDefaultPii is false', async () => { + // Mock client with sendDefaultPii: false + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + } as ReturnType); + + await wrappedMcpServer.connect(mockTransport); + + const mockSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + } as any; + startInactiveSpanSpy.mockReturnValueOnce(mockSpan); + + const toolCallRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-tool-result-filtered', + params: { name: 'weather-lookup' }, + }; + + mockTransport.onmessage?.(toolCallRequest, {}); + + const toolResponse = { + jsonrpc: '2.0', + id: 'req-tool-result-filtered', + result: { + content: [{ type: 'text', text: 'Sensitive weather data for London' }], + isError: false, + }, + }; + + mockTransport.send?.(toolResponse); + + // Tool result content should be filtered out + const setAttributesCall = mockSpan.setAttributes.mock.calls[0]?.[0]; + expect(setAttributesCall).toBeDefined(); + expect(setAttributesCall).not.toHaveProperty('mcp.tool.result.content'); + expect(setAttributesCall).toHaveProperty('mcp.tool.result.is_error', false); + expect(setAttributesCall).toHaveProperty('mcp.tool.result.content_count', 1); + }); + }); + + describe('filterMcpPiiFromSpanData Function', () => { + it('should preserve all data when sendDefaultPii is true', () => { + const spanData = { + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.request.argument.location': '"San Francisco"', + 'mcp.tool.result.content': 'Weather data: 18°C', + 'mcp.logging.message': 'User requested weather', + 'mcp.resource.uri': 'file:///private/docs/secret.txt', + 'mcp.method.name': 'tools/call', // Non-PII should remain + }; + + const result = filterMcpPiiFromSpanData(spanData, true); + + expect(result).toEqual(spanData); // All data preserved + }); + + it('should remove PII data when sendDefaultPii is false', () => { + const spanData = { + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.request.argument.location': '"San Francisco"', + 'mcp.request.argument.units': '"celsius"', + 'mcp.tool.result.content': 'Weather data: 18°C', + 'mcp.logging.message': 'User requested weather', + 'mcp.resource.uri': 'file:///private/docs/secret.txt', + 'mcp.method.name': 'tools/call', // Non-PII should remain + 'mcp.session.id': 'test-session-123', // Non-PII should remain + }; + + const result = filterMcpPiiFromSpanData(spanData, false); + + expect(result).not.toHaveProperty('client.address'); + expect(result).not.toHaveProperty('client.port'); + expect(result).not.toHaveProperty('mcp.request.argument.location'); + expect(result).not.toHaveProperty('mcp.request.argument.units'); + expect(result).not.toHaveProperty('mcp.tool.result.content'); + expect(result).not.toHaveProperty('mcp.logging.message'); + expect(result).not.toHaveProperty('mcp.resource.uri'); + + expect(result).toHaveProperty('mcp.method.name', 'tools/call'); + expect(result).toHaveProperty('mcp.session.id', 'test-session-123'); + }); + + it('should handle empty span data', () => { + const result = filterMcpPiiFromSpanData({}, false); + expect(result).toEqual({}); + }); + + it('should handle span data with no PII attributes', () => { + const spanData = { + 'mcp.method.name': 'tools/list', + 'mcp.session.id': 'test-session', + }; + + const result = filterMcpPiiFromSpanData(spanData, false); + expect(result).toEqual(spanData); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts new file mode 100644 index 000000000000..23df241f68f1 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -0,0 +1,491 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import * as tracingModule from '../../../../src/tracing'; +import { createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP Server Semantic Conventions', () => { + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + // Mock client to return sendDefaultPii: true for instrumentation tests + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + }); + + describe('Span Creation & Semantic Conventions', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockTransport = createMockTransport(); + mockTransport.sessionId = 'test-session-123'; + }); + + it('should create spans with correct MCP server semantic attributes for tool operations', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-1', + params: { name: 'get-weather', arguments: { location: 'Seattle, WA' } }, + }; + + const extraWithClientInfo = { + requestInfo: { + remoteAddress: '192.168.1.100', + remotePort: 54321, + }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call get-weather', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'get-weather', + 'mcp.request.id': 'req-1', + 'mcp.session.id': 'test-session-123', + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.location': '"Seattle, WA"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should create spans with correct attributes for resource operations', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'resources/read', + id: 'req-2', + params: { uri: 'file:///docs/api.md' }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'resources/read file:///docs/api.md', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'resources/read', + 'mcp.resource.uri': 'file:///docs/api.md', + 'mcp.request.id': 'req-2', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.uri': '"file:///docs/api.md"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should create spans with correct attributes for prompt operations', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'prompts/get', + id: 'req-3', + params: { name: 'analyze-code' }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'prompts/get analyze-code', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'prompts/get', + 'mcp.prompt.name': 'analyze-code', + 'mcp.request.id': 'req-3', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.name': '"analyze-code"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should create spans with correct attributes for notifications (no request id)', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcNotification = { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + params: {}, + }; + + mockTransport.onmessage?.(jsonRpcNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'notifications/tools/list_changed', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'notifications/tools/list_changed', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }, + expect.any(Function), + ); + + // Should not include mcp.request.id for notifications + const callArgs = vi.mocked(tracingModule.startSpan).mock.calls[0]; + expect(callArgs).toBeDefined(); + const attributes = callArgs?.[0]?.attributes; + expect(attributes).not.toHaveProperty('mcp.request.id'); + }); + + it('should create spans for list operations without target in name', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/list', + id: 'req-4', + params: {}, + }; + + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tools/list', + forceTransaction: true, + attributes: expect.objectContaining({ + 'mcp.method.name': 'tools/list', + 'mcp.request.id': 'req-4', + 'mcp.session.id': 'test-session-123', + // Transport attributes + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + // Sentry-specific + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }), + }), + ); + }); + + it('should create spans with logging attributes for notifications/message', async () => { + await wrappedMcpServer.connect(mockTransport); + + const loggingNotification = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { + level: 'info', + logger: 'math-service', + data: 'Addition completed: 2 + 5 = 7', + }, + }; + + mockTransport.onmessage?.(loggingNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'notifications/message', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'notifications/message', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.logging.level': 'info', + 'mcp.logging.logger': 'math-service', + 'mcp.logging.data_type': 'string', + 'mcp.logging.message': 'Addition completed: 2 + 5 = 7', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }, + expect.any(Function), + ); + }); + + it('should create spans with attributes for other notification types', async () => { + await wrappedMcpServer.connect(mockTransport); + + // Test notifications/cancelled + const cancelledNotification = { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: 'req-123', + reason: 'user_requested', + }, + }; + + mockTransport.onmessage?.(cancelledNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/cancelled', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/cancelled', + 'mcp.cancelled.request_id': 'req-123', + 'mcp.cancelled.reason': 'user_requested', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + + vi.clearAllMocks(); + + // Test notifications/progress + const progressNotification = { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 'token-456', + progress: 75, + total: 100, + message: 'Processing files...', + }, + }; + + mockTransport.onmessage?.(progressNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/progress', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/progress', + 'mcp.progress.token': 'token-456', + 'mcp.progress.current': 75, + 'mcp.progress.total': 100, + 'mcp.progress.percentage': 75, + 'mcp.progress.message': 'Processing files...', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + + vi.clearAllMocks(); + + // Test notifications/resources/updated + const resourceUpdatedNotification = { + jsonrpc: '2.0', + method: 'notifications/resources/updated', + params: { + uri: 'file:///tmp/data.json', + }, + }; + + mockTransport.onmessage?.(resourceUpdatedNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/resources/updated', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/resources/updated', + 'mcp.resource.uri': 'file:///tmp/data.json', + 'mcp.resource.protocol': 'file', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + }); + + it('should create spans with correct operation for outgoing notifications', async () => { + await wrappedMcpServer.connect(mockTransport); + + const outgoingNotification = { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + }; + + await mockTransport.send?.(outgoingNotification); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/tools/list_changed', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/tools/list_changed', + 'sentry.op': 'mcp.notification.server_to_client', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + }); + + it('should instrument tool call results and complete span with enriched attributes', async () => { + await wrappedMcpServer.connect(mockTransport); + + const mockSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + }; + startInactiveSpanSpy.mockReturnValueOnce(mockSpan as any); + + const toolCallRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-tool-result', + params: { + name: 'weather-lookup', + arguments: { location: 'San Francisco', units: 'celsius' }, + }, + }; + + // Simulate the incoming tool call request + mockTransport.onmessage?.(toolCallRequest, {}); + + // Verify span was created for the request + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tools/call weather-lookup', + op: 'mcp.server', + forceTransaction: true, + attributes: expect.objectContaining({ + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'weather-lookup', + 'mcp.request.id': 'req-tool-result', + }), + }), + ); + + // Simulate tool execution response with results + const toolResponse = { + jsonrpc: '2.0', + id: 'req-tool-result', + result: { + content: [ + { + type: 'text', + text: 'The weather in San Francisco is 18°C with partly cloudy skies.', + }, + ], + isError: false, + }, + }; + + // Simulate the outgoing response (this should trigger span completion) + mockTransport.send?.(toolResponse); + + // Verify that the span was enriched with tool result attributes + expect(mockSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'mcp.tool.result.is_error': false, + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content': + '[{"type":"text","text":"The weather in San Francisco is 18°C with partly cloudy skies."}]', + }), + ); + + // Verify span was completed successfully (no error status set) + expect(mockSpan.setStatus).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('should set span status to ERROR when tool result has isError: true', async () => { + await wrappedMcpServer.connect(mockTransport); + + const mockSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + } as any; + startInactiveSpanSpy.mockReturnValueOnce(mockSpan); + + const toolCallRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-tool-error', + params: { + name: 'failing-tool', + arguments: { input: 'test' }, + }, + }; + + // Simulate the incoming tool call request + mockTransport.onmessage?.(toolCallRequest, {}); + + // Simulate tool execution response with error + const toolErrorResponse = { + jsonrpc: '2.0', + id: 'req-tool-error', + result: { + content: [ + { + type: 'text', + text: 'Tool execution failed', + }, + ], + isError: true, + }, + }; + + // Simulate the outgoing response (this should trigger span completion) + mockTransport.send?.(toolErrorResponse); + + // Verify that the span was enriched with tool result attributes including error + expect(mockSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'mcp.tool.result.is_error': true, + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content': '[{"type":"text","text":"Tool execution failed"}]', + }), + ); + + // Verify span status was set to ERROR + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: 2, // ERROR + message: 'Tool execution failed', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts new file mode 100644 index 000000000000..9593391ca856 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -0,0 +1,63 @@ +import { vi } from 'vitest'; + +/** + * Create a mock MCP server instance for testing + */ +export function createMockMcpServer() { + return { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + server: { + setRequestHandler: vi.fn(), + }, + }; +} + +/** + * Create a mock HTTP transport (StreamableHTTPServerTransport) + * Uses exact naming pattern from the official SDK + */ +export function createMockTransport() { + class StreamableHTTPServerTransport { + onmessage = vi.fn(); + onclose = vi.fn(); + onerror = vi.fn(); + send = vi.fn().mockResolvedValue(undefined); + sessionId = 'test-session-123'; + protocolVersion = '2025-06-18'; + } + + return new StreamableHTTPServerTransport(); +} + +/** + * Create a mock stdio transport (StdioServerTransport) + * Uses exact naming pattern from the official SDK + */ +export function createMockStdioTransport() { + class StdioServerTransport { + onmessage = vi.fn(); + onclose = vi.fn(); + send = vi.fn().mockResolvedValue(undefined); + sessionId = 'stdio-session-456'; + } + + return new StdioServerTransport(); +} + +/** + * Create a mock SSE transport (SSEServerTransport) + * For backwards compatibility testing + */ +export function createMockSseTransport() { + class SSEServerTransport { + onmessage = vi.fn(); + onclose = vi.fn(); + send = vi.fn().mockResolvedValue(undefined); + sessionId = 'sse-session-789'; + } + + return new SSEServerTransport(); +} diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts new file mode 100644 index 000000000000..5f22eedefad6 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -0,0 +1,363 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { buildMcpServerSpanConfig } from '../../../../src/integrations/mcp-server/spans'; +import { + wrapTransportError, + wrapTransportOnClose, + wrapTransportOnMessage, + wrapTransportSend, +} from '../../../../src/integrations/mcp-server/transport'; +import * as tracingModule from '../../../../src/tracing'; +import { + createMockMcpServer, + createMockSseTransport, + createMockStdioTransport, + createMockTransport, +} from './testUtils'; + +describe('MCP Server Transport Instrumentation', () => { + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + // Mock client to return sendDefaultPii: true for instrumentation tests + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + }); + + describe('Transport-level instrumentation', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockTransport: ReturnType; + let originalConnect: any; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + originalConnect = mockMcpServer.connect; + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockTransport = createMockTransport(); + }); + + it('should proxy the connect method', () => { + // We need to test this before connection, so create fresh instances + const freshMockMcpServer = createMockMcpServer(); + const originalConnect = freshMockMcpServer.connect; + + const freshWrappedMcpServer = wrapMcpServerWithSentry(freshMockMcpServer); + + expect(freshWrappedMcpServer.connect).not.toBe(originalConnect); + }); + + it('should intercept transport onmessage handler', async () => { + const originalOnMessage = mockTransport.onmessage; + + await wrappedMcpServer.connect(mockTransport); + + // onmessage should be wrapped after connection + expect(mockTransport.onmessage).not.toBe(originalOnMessage); + }); + + it('should intercept transport send handler', async () => { + const originalSend = mockTransport.send; + + await wrappedMcpServer.connect(mockTransport); + + // send should be wrapped after connection + expect(mockTransport.send).not.toBe(originalSend); + }); + + it('should intercept transport onclose handler', async () => { + const originalOnClose = mockTransport.onclose; + + await wrappedMcpServer.connect(mockTransport); + + // onclose should be wrapped after connection + expect(mockTransport.onclose).not.toBe(originalOnClose); + }); + + it('should call original connect and preserve functionality', async () => { + await wrappedMcpServer.connect(mockTransport); + + // Check the original spy was called + expect(originalConnect).toHaveBeenCalledWith(mockTransport); + }); + + it('should create spans for incoming JSON-RPC requests', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-1', + params: { name: 'get-weather' }, + }; + + // Simulate incoming message + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tools/call get-weather', + forceTransaction: true, + }), + ); + }); + + it('should create spans for incoming JSON-RPC notifications', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcNotification = { + jsonrpc: '2.0', + method: 'notifications/initialized', + // No 'id' field - this makes it a notification + }; + + // Simulate incoming notification + mockTransport.onmessage?.(jsonRpcNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/initialized', + forceTransaction: true, + }), + expect.any(Function), + ); + }); + + it('should create spans for outgoing notifications', async () => { + await wrappedMcpServer.connect(mockTransport); + + const outgoingNotification = { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + // No 'id' field + }; + + // Simulate outgoing notification + await mockTransport.send?.(outgoingNotification); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/tools/list_changed', + forceTransaction: true, + }), + expect.any(Function), + ); + }); + + it('should not create spans for non-JSON-RPC messages', async () => { + await wrappedMcpServer.connect(mockTransport); + + // Simulate non-JSON-RPC message + mockTransport.onmessage?.({ some: 'data' }, {}); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('should handle transport onclose events', async () => { + await wrappedMcpServer.connect(mockTransport); + mockTransport.sessionId = 'test-session-123'; + + // Trigger onclose - should not throw + expect(() => mockTransport.onclose?.()).not.toThrow(); + }); + }); + + describe('Stdio Transport Tests', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockStdioTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockStdioTransport = createMockStdioTransport(); + mockStdioTransport.sessionId = 'stdio-session-456'; + }); + + it('should detect stdio transport and set correct attributes', async () => { + await wrappedMcpServer.connect(mockStdioTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-stdio-1', + params: { name: 'process-file', arguments: { path: '/tmp/data.txt' } }, + }; + + mockStdioTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call process-file', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'process-file', + 'mcp.request.id': 'req-stdio-1', + 'mcp.session.id': 'stdio-session-456', + 'mcp.transport': 'stdio', // Should be stdio, not http + 'network.transport': 'pipe', // Should be pipe, not tcp + 'network.protocol.version': '2.0', + 'mcp.request.argument.path': '"/tmp/data.txt"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should handle stdio transport notifications correctly', async () => { + await wrappedMcpServer.connect(mockStdioTransport); + + const notification = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { + level: 'debug', + data: 'Processing stdin input', + }, + }; + + mockStdioTransport.onmessage?.(notification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/message', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/message', + 'mcp.session.id': 'stdio-session-456', + 'mcp.transport': 'stdio', + 'network.transport': 'pipe', + 'mcp.logging.level': 'debug', + 'mcp.logging.message': 'Processing stdin input', + }), + }), + expect.any(Function), + ); + }); + }); + + describe('SSE Transport Tests (Backwards Compatibility)', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockSseTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockSseTransport = createMockSseTransport(); + mockSseTransport.sessionId = 'sse-session-789'; + }); + + it('should detect SSE transport for backwards compatibility', async () => { + await wrappedMcpServer.connect(mockSseTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'resources/read', + id: 'req-sse-1', + params: { uri: 'https://api.example.com/data' }, + }; + + mockSseTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'resources/read https://api.example.com/data', + attributes: expect.objectContaining({ + 'mcp.method.name': 'resources/read', + 'mcp.resource.uri': 'https://api.example.com/data', + 'mcp.transport': 'sse', // Deprecated but supported + 'network.transport': 'tcp', + 'mcp.session.id': 'sse-session-789', + }), + }), + ); + }); + }); + + describe('Direct Transport Function Tests', () => { + let mockTransport: ReturnType; + + beforeEach(() => { + mockTransport = createMockTransport(); + mockTransport.sessionId = 'test-session-direct'; + }); + + it('should test wrapTransportOnMessage directly', () => { + const originalOnMessage = mockTransport.onmessage; + + wrapTransportOnMessage(mockTransport); + + expect(mockTransport.onmessage).not.toBe(originalOnMessage); + }); + + it('should test wrapTransportSend directly', () => { + const originalSend = mockTransport.send; + + wrapTransportSend(mockTransport); + + expect(mockTransport.send).not.toBe(originalSend); + }); + + it('should test wrapTransportOnClose directly', () => { + const originalOnClose = mockTransport.onclose; + + wrapTransportOnClose(mockTransport); + + expect(mockTransport.onclose).not.toBe(originalOnClose); + }); + + it('should test wrapTransportError directly', () => { + const originalOnError = mockTransport.onerror; + + wrapTransportError(mockTransport); + + expect(mockTransport.onerror).not.toBe(originalOnError); + }); + + it('should test buildMcpServerSpanConfig directly', () => { + const jsonRpcRequest = { + jsonrpc: '2.0' as const, + method: 'tools/call', + id: 'req-direct-test', + params: { name: 'test-tool', arguments: { input: 'test' } }, + }; + + const config = buildMcpServerSpanConfig(jsonRpcRequest, mockTransport, { + requestInfo: { + remoteAddress: '127.0.0.1', + remotePort: 8080, + }, + }); + + expect(config).toEqual({ + name: 'tools/call test-tool', + op: 'mcp.server', + forceTransaction: true, + attributes: expect.objectContaining({ + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'test-tool', + 'mcp.request.id': 'req-direct-test', + 'mcp.session.id': 'test-session-direct', + 'client.address': '127.0.0.1', + 'client.port': 8080, + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.input': '"test"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }), + }); + }); + }); +}); diff --git a/packages/core/test/lib/mcp-server.test.ts b/packages/core/test/lib/mcp-server.test.ts deleted file mode 100644 index 12e85f9f370e..000000000000 --- a/packages/core/test/lib/mcp-server.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { wrapMcpServerWithSentry } from '../../src/mcp-server'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, -} from '../../src/semanticAttributes'; -import * as tracingModule from '../../src/tracing'; - -vi.mock('../../src/tracing'); - -describe('wrapMcpServerWithSentry', () => { - beforeEach(() => { - vi.clearAllMocks(); - // @ts-expect-error mocking span is annoying - vi.mocked(tracingModule.startSpan).mockImplementation((_, cb) => cb()); - }); - - it('should wrap valid MCP server instance methods with Sentry spans', () => { - // Create a mock MCP server instance - const mockResource = vi.fn(); - const mockTool = vi.fn(); - const mockPrompt = vi.fn(); - - const mockMcpServer = { - resource: mockResource, - tool: mockTool, - prompt: mockPrompt, - connect: vi.fn(), - }; - - // Wrap the MCP server - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Verify it returns the same instance (modified) - expect(wrappedMcpServer).toBe(mockMcpServer); - - // Original methods should be wrapped - expect(wrappedMcpServer.resource).not.toBe(mockResource); - expect(wrappedMcpServer.tool).not.toBe(mockTool); - expect(wrappedMcpServer.prompt).not.toBe(mockPrompt); - }); - - it('should return the input unchanged if it is not a valid MCP server instance', () => { - const invalidMcpServer = { - // Missing required methods - resource: () => {}, - tool: () => {}, - // No prompt method - }; - - const result = wrapMcpServerWithSentry(invalidMcpServer); - expect(result).toBe(invalidMcpServer); - - // Methods should not be wrapped - expect(result.resource).toBe(invalidMcpServer.resource); - expect(result.tool).toBe(invalidMcpServer.tool); - - // No calls to startSpan - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - - it('should not wrap the same instance twice', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - }; - - // First wrap - const wrappedOnce = wrapMcpServerWithSentry(mockMcpServer); - - // Store references to wrapped methods - const wrappedResource = wrappedOnce.resource; - const wrappedTool = wrappedOnce.tool; - const wrappedPrompt = wrappedOnce.prompt; - - // Second wrap - const wrappedTwice = wrapMcpServerWithSentry(wrappedOnce); - - // Should be the same instance with the same wrapped methods - expect(wrappedTwice).toBe(wrappedOnce); - expect(wrappedTwice.resource).toBe(wrappedResource); - expect(wrappedTwice.tool).toBe(wrappedTool); - expect(wrappedTwice.prompt).toBe(wrappedPrompt); - }); - - describe('resource method wrapping', () => { - it('should create a span with proper attributes when resource is called', () => { - const mockResourceHandler = vi.fn(); - const resourceName = 'test-resource'; - - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - wrappedMcpServer.resource(resourceName, {}, mockResourceHandler); - - // The original registration should use a wrapped handler - expect(mockMcpServer.resource).toHaveBeenCalledWith(resourceName, {}, expect.any(Function)); - - // Invoke the wrapped handler to trigger Sentry span - const wrappedResourceHandler = (mockMcpServer.resource as any).mock.calls[0][2]; - wrappedResourceHandler('test-uri', { foo: 'bar' }); - - expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).toHaveBeenCalledWith( - { - name: `mcp-server/resource:${resourceName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.resource': resourceName, - }, - }, - expect.any(Function), - ); - - // Verify the original handler was called within the span - expect(mockResourceHandler).toHaveBeenCalledWith('test-uri', { foo: 'bar' }); - }); - - it('should call the original resource method directly if name or handler is not valid', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Call without string name - wrappedMcpServer.resource({} as any, 'handler'); - - // Call without function handler - wrappedMcpServer.resource('name', 'not-a-function'); - - // Original method should be called directly without creating spans - expect(mockMcpServer.resource).toHaveBeenCalledTimes(2); - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - }); - - describe('tool method wrapping', () => { - it('should create a span with proper attributes when tool is called', () => { - const mockToolHandler = vi.fn(); - const toolName = 'test-tool'; - - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - wrappedMcpServer.tool(toolName, {}, mockToolHandler); - - // The original registration should use a wrapped handler - expect(mockMcpServer.tool).toHaveBeenCalledWith(toolName, {}, expect.any(Function)); - - // Invoke the wrapped handler to trigger Sentry span - const wrappedToolHandler = (mockMcpServer.tool as any).mock.calls[0][2]; - wrappedToolHandler({ arg: 'value' }, { foo: 'baz' }); - - expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).toHaveBeenCalledWith( - { - name: `mcp-server/tool:${toolName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.tool': toolName, - }, - }, - expect.any(Function), - ); - - // Verify the original handler was called within the span - expect(mockToolHandler).toHaveBeenCalledWith({ arg: 'value' }, { foo: 'baz' }); - }); - - it('should call the original tool method directly if name or handler is not valid', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Call without string name - wrappedMcpServer.tool({} as any, 'handler'); - - // Original method should be called directly without creating spans - expect(mockMcpServer.tool).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - }); - - describe('prompt method wrapping', () => { - it('should create a span with proper attributes when prompt is called', () => { - const mockPromptHandler = vi.fn(); - const promptName = 'test-prompt'; - - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - wrappedMcpServer.prompt(promptName, {}, mockPromptHandler); - - // The original registration should use a wrapped handler - expect(mockMcpServer.prompt).toHaveBeenCalledWith(promptName, {}, expect.any(Function)); - - // Invoke the wrapped handler to trigger Sentry span - const wrappedPromptHandler = (mockMcpServer.prompt as any).mock.calls[0][2]; - wrappedPromptHandler({ msg: 'hello' }, { data: 123 }); - - expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).toHaveBeenCalledWith( - { - name: `mcp-server/prompt:${promptName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.prompt': promptName, - }, - }, - expect.any(Function), - ); - - // Verify the original handler was called within the span - expect(mockPromptHandler).toHaveBeenCalledWith({ msg: 'hello' }, { data: 123 }); - }); - - it('should call the original prompt method directly if name or handler is not valid', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Call without function handler - wrappedMcpServer.prompt('name', 'not-a-function'); - - // Original method should be called directly without creating spans - expect(mockMcpServer.prompt).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - }); -});