diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts index a36e635685b6..3ca186e0aa83 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts @@ -18,7 +18,7 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { return transactionEvent.transaction === 'POST /messages'; }); const toolTransactionPromise = waitForTransaction('node-express', transactionEvent => { - return transactionEvent.transaction === 'mcp-server/tool:echo'; + return transactionEvent.transaction === 'tools/call echo'; }); const toolResult = await client.callTool({ @@ -39,11 +39,14 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { const postTransaction = await postTransactionPromise; expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); const toolTransaction = await toolTransactionPromise; expect(toolTransaction).toBeDefined(); + expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call'); + // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction - // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); await test.step('resource handler', async () => { @@ -51,7 +54,7 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { return transactionEvent.transaction === 'POST /messages'; }); const resourceTransactionPromise = waitForTransaction('node-express', transactionEvent => { - return transactionEvent.transaction === 'mcp-server/resource:echo'; + return transactionEvent.transaction === 'resources/read echo://foobar'; }); const resourceResult = await client.readResource({ @@ -64,10 +67,12 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { const postTransaction = await postTransactionPromise; expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); const resourceTransaction = await resourceTransactionPromise; expect(resourceTransaction).toBeDefined(); - + expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read'); // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); @@ -76,7 +81,7 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { return transactionEvent.transaction === 'POST /messages'; }); const promptTransactionPromise = waitForTransaction('node-express', transactionEvent => { - return transactionEvent.transaction === 'mcp-server/prompt:echo'; + return transactionEvent.transaction === 'prompts/get echo'; }); const promptResult = await client.getPrompt({ @@ -100,10 +105,12 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { const postTransaction = await postTransactionPromise; expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); const promptTransaction = await promptTransactionPromise; expect(promptTransaction).toBeDefined(); - + expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get'); // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e020fc6aa77..eddf3cddd45e 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..90924418963e --- /dev/null +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -0,0 +1,416 @@ +/** + * Attribute extraction and building functions for MCP server instrumentation + */ + +import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; +import { + CLIENT_ADDRESS_ATTRIBUTE, + CLIENT_PORT_ATTRIBUTE, + MCP_LOGGING_DATA_TYPE_ATTRIBUTE, + MCP_LOGGING_LEVEL_ATTRIBUTE, + MCP_LOGGING_LOGGER_ATTRIBUTE, + MCP_LOGGING_MESSAGE_ATTRIBUTE, + MCP_PROMPT_NAME_ATTRIBUTE, + MCP_REQUEST_ARGUMENT, + 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 + * @internal Maps method names to their extraction configuration + */ +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 + * @param method - MCP method name + * @param params - Method parameters + * @returns Target name and attributes for span instrumentation + */ +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 + * @param method - MCP method name + * @param params - Method parameters + * @returns Arguments as span attributes with mcp.request.argument prefix + */ +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; + } + + 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); + } + } + } + + 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 + * @param transport - MCP transport instance + * @returns Transport type mapping for span attributes + */ +export function getTransportTypes(transport: MCPTransport): { mcpTransport: string; networkTransport: string } { + const transportName = transport.constructor?.name?.toLowerCase() || ''; + + if (transportName.includes('stdio')) { + return { mcpTransport: 'stdio', networkTransport: 'pipe' }; + } + + if (transportName.includes('streamablehttp') || transportName.includes('streamable')) { + return { mcpTransport: 'http', networkTransport: 'tcp' }; + } + + if (transportName.includes('sse')) { + return { mcpTransport: 'sse', networkTransport: 'tcp' }; + } + + return { mcpTransport: 'unknown', networkTransport: 'unknown' }; +} + +/** + * Extracts additional attributes for specific notification types + * @param method - Notification method name + * @param params - Notification parameters + * @returns Method-specific attributes for span instrumentation + */ +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_ATTRIBUTE] = String(params.level); + } + if (params?.logger) { + attributes[MCP_LOGGING_LOGGER_ATTRIBUTE] = String(params.logger); + } + if (params?.data !== undefined) { + attributes[MCP_LOGGING_DATA_TYPE_ATTRIBUTE] = typeof params.data; + if (typeof params.data === 'string') { + attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = params.data; + } else { + attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = 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_ATTRIBUTE] = String(params.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 + * @param extra - Extra handler data containing connection info + * @returns Client address and port information + */ +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 + * @param transport - MCP transport instance + * @param extra - Optional extra handler data + * @returns Transport attributes for span instrumentation + */ +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 + * @param type - Span type (request or notification) + * @param message - JSON-RPC message + * @param params - Optional parameters for attribute extraction + * @returns Type-specific attributes for span instrumentation + */ +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 || {}), + }; + } + + return getNotificationAttributes(message.method, params || {}); +} + +/** + * Get metadata about tool result content array + * @internal + */ +function getContentMetadata(content: unknown[]): Record { + return { + [MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length, + }; +} + +/** + * Build attributes from a single content item + * @internal + */ +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 + * @internal + */ +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 tool result + * @internal + */ +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 + * @param result - Tool execution result + * @returns Attributes extracted from tool result content + */ +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..c54a09eb38bd --- /dev/null +++ b/packages/core/src/integrations/mcp-server/attributes.ts @@ -0,0 +1,115 @@ +/** + * 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 */ +export const MCP_METHOD_NAME_ATTRIBUTE = 'mcp.method.name'; + +/** JSON-RPC request identifier for the request. Unique within the MCP session. */ +export const MCP_REQUEST_ID_ATTRIBUTE = 'mcp.request.id'; + +/** Identifies the MCP session */ +export const MCP_SESSION_ID_ATTRIBUTE = 'mcp.session.id'; + +/** Transport method used for MCP communication */ +export const MCP_TRANSPORT_ATTRIBUTE = 'mcp.transport'; + +// ============================================================================= +// METHOD-SPECIFIC ATTRIBUTES +// ============================================================================= + +/** Name of the tool being called */ +export const MCP_TOOL_NAME_ATTRIBUTE = 'mcp.tool.name'; + +/** The resource URI being accessed */ +export const MCP_RESOURCE_URI_ATTRIBUTE = 'mcp.resource.uri'; + +/** Name of the prompt template */ +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'; + +// ============================================================================= +// REQUEST ARGUMENT ATTRIBUTES +// ============================================================================= + +/** Prefix for MCP request argument prefix for each argument */ +export const MCP_REQUEST_ARGUMENT = 'mcp.request.argument'; + +// ============================================================================= +// LOGGING ATTRIBUTES +// ============================================================================= + +/** Log level for MCP logging operations */ +export const MCP_LOGGING_LEVEL_ATTRIBUTE = 'mcp.logging.level'; + +/** Logger name for MCP logging operations */ +export const MCP_LOGGING_LOGGER_ATTRIBUTE = 'mcp.logging.logger'; + +/** Data type of the logged message */ +export const MCP_LOGGING_DATA_TYPE_ATTRIBUTE = 'mcp.logging.data_type'; + +/** Log message content */ +export const MCP_LOGGING_MESSAGE_ATTRIBUTE = 'mcp.logging.message'; + +// ============================================================================= +// NETWORK ATTRIBUTES (OpenTelemetry Standard) +// ============================================================================= + +/** OSI transport layer protocol */ +export const NETWORK_TRANSPORT_ATTRIBUTE = 'network.transport'; + +/** The version of JSON RPC protocol used */ +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 */ +export const CLIENT_ADDRESS_ATTRIBUTE = 'client.address'; + +/** Client port number */ +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..7f00341bdd5a --- /dev/null +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -0,0 +1,100 @@ +/** + * Request-span correlation system for MCP server instrumentation + * + * Handles mapping requestId to span data for correlation with handler execution. + * Uses WeakMap to scope correlation maps per transport instance, preventing + * request ID collisions between different MCP sessions. + */ + +import { getClient } from '../../currentScopes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; +import { extractToolResultAttributes } from './attributeExtraction'; +import { filterMcpPiiFromSpanData } from './piiFiltering'; +import type { MCPTransport, RequestId, RequestSpanMapValue } from './types'; + +/** + * Transport-scoped correlation system that prevents collisions between different MCP sessions + * @internal Each transport instance gets its own correlation map, eliminating request ID conflicts + */ +const transportToSpanMap = new WeakMap>(); + +/** + * Gets or creates the span map for a specific transport instance + * @internal + * @param transport - MCP transport instance + * @returns Span map for the transport + */ +function getOrCreateSpanMap(transport: MCPTransport): Map { + let spanMap = transportToSpanMap.get(transport); + if (!spanMap) { + spanMap = new Map(); + transportToSpanMap.set(transport, spanMap); + } + return spanMap; +} + +/** + * Stores span context for later correlation with handler execution + * @param transport - MCP transport instance + * @param requestId - Request identifier + * @param span - Active span to correlate + * @param method - MCP method name + */ +export function storeSpanForRequest(transport: MCPTransport, requestId: RequestId, span: Span, method: string): void { + const spanMap = getOrCreateSpanMap(transport); + spanMap.set(requestId, { + span, + method, + startTime: Date.now(), + }); +} + +/** + * Completes span with tool results and cleans up correlation + * @param transport - MCP transport instance + * @param requestId - Request identifier + * @param result - Tool execution result for attribute extraction + */ +export function completeSpanWithResults(transport: MCPTransport, requestId: RequestId, result: unknown): void { + const spanMap = getOrCreateSpanMap(transport); + const spanData = spanMap.get(requestId); + if (spanData) { + const { span, method } = spanData; + + if (method === 'tools/call') { + const rawToolAttributes = extractToolResultAttributes(result); + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + const toolAttributes = filterMcpPiiFromSpanData(rawToolAttributes, sendDefaultPii); + + span.setAttributes(toolAttributes); + } + + span.end(); + spanMap.delete(requestId); + } +} + +/** + * Cleans up pending spans for a specific transport (when that transport closes) + * @param transport - MCP transport instance + * @returns Number of pending spans that were cleaned up + */ +export function cleanupPendingSpansForTransport(transport: MCPTransport): number { + const spanMap = transportToSpanMap.get(transport); + if (!spanMap) return 0; + + const pendingCount = spanMap.size; + + for (const [, spanData] of spanMap) { + spanData.span.setStatus({ + code: SPAN_STATUS_ERROR, + message: 'cancelled', + }); + spanData.span.end(); + } + + spanMap.clear(); + return pendingCount; +} 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..544d61cf71ad --- /dev/null +++ b/packages/core/src/integrations/mcp-server/errorCapture.ts @@ -0,0 +1,50 @@ +/** + * Safe error capture utilities for MCP server instrumentation + * + * Ensures error reporting never interferes with MCP server operation. + * All capture operations are wrapped in try-catch to prevent side effects. + */ + +import { getClient } from '../../currentScopes'; +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { getActiveSpan } from '../../utils/spanUtils'; +import type { McpErrorType } from './types'; + +/** + * Captures an error without affecting MCP server operation. + * + * The active span already contains all MCP context (method, tool, arguments, etc.) + * @param error - Error to capture + * @param errorType - Classification of error type for filtering + * @param extraData - Additional context data to include + */ +export function captureError(error: Error, errorType?: McpErrorType, extraData?: Record): void { + try { + const client = getClient(); + if (!client) { + return; + } + + const activeSpan = getActiveSpan(); + if (activeSpan?.isRecording()) { + activeSpan.setStatus({ + code: SPAN_STATUS_ERROR, + message: 'internal_error', + }); + } + + captureException(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: errorType || 'handler_execution', + ...extraData, + }, + }, + }); + } catch { + // noop + } +} 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..9816d607b7c1 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/handlers.ts @@ -0,0 +1,161 @@ +/** + * Handler method wrapping for MCP server instrumentation + * + * Provides automatic error capture and span correlation for tool, resource, + * and prompt handlers. + */ + +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import { fill } from '../../utils/object'; +import { captureError } from './errorCapture'; +import type { MCPHandler, MCPServerInstance } from './types'; + +/** + * Generic function to wrap MCP server method handlers + * @internal + * @param serverInstance - MCP server instance + * @param methodName - Method name to wrap (tool, resource, prompt) + */ +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 + * @internal + * @param originalHandler - Original handler function + * @param methodName - MCP method name + * @param handlerName - Handler identifier + * @returns Wrapped handler function + */ +function createWrappedHandler(originalHandler: MCPHandler, methodName: keyof MCPServerInstance, handlerName: string) { + return function (this: unknown, ...handlerArgs: unknown[]): unknown { + try { + return createErrorCapturingHandler.call(this, originalHandler, methodName, handlerName, handlerArgs); + } catch (error) { + DEBUG_BUILD && debug.warn('MCP handler wrapping failed:', error); + return originalHandler.apply(this, handlerArgs); + } + }; +} + +/** + * Creates an error-capturing wrapper for handler execution + * @internal + * @param originalHandler - Original handler function + * @param methodName - MCP method name + * @param handlerName - Handler identifier + * @param handlerArgs - Handler arguments + * @param extraHandlerData - Additional handler context + * @returns Handler execution result + */ +function createErrorCapturingHandler( + this: MCPServerInstance, + originalHandler: MCPHandler, + methodName: keyof MCPServerInstance, + handlerName: string, + handlerArgs: unknown[], +): unknown { + try { + const result = originalHandler.apply(this, handlerArgs); + + if (result && typeof result === 'object' && typeof (result as { then?: unknown }).then === 'function') { + return Promise.resolve(result).catch(error => { + captureHandlerError(error, methodName, handlerName); + throw error; + }); + } + + return result; + } catch (error) { + captureHandlerError(error as Error, methodName, handlerName); + throw error; + } +} + +/** + * Captures handler execution errors based on handler type + * @internal + * @param error - Error to capture + * @param methodName - MCP method name + * @param handlerName - Handler identifier + */ +function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, handlerName: string): void { + try { + const extraData: Record = {}; + + if (methodName === 'tool') { + extraData.tool_name = handlerName; + + if ( + error.name === 'ProtocolValidationError' || + error.message.includes('validation') || + error.message.includes('protocol') + ) { + captureError(error, 'validation', extraData); + } else if ( + error.name === 'ServerTimeoutError' || + error.message.includes('timed out') || + error.message.includes('timeout') + ) { + captureError(error, 'timeout', extraData); + } else { + captureError(error, 'tool_execution', extraData); + } + } else if (methodName === 'resource') { + extraData.resource_uri = handlerName; + captureError(error, 'resource_execution', extraData); + } else if (methodName === 'prompt') { + extraData.prompt_name = handlerName; + captureError(error, 'prompt_execution', extraData); + } + } catch (captureErr) { + // noop + } +} + +/** + * Wraps tool handlers to associate them with request spans + * @param serverInstance - MCP server instance + */ +export function wrapToolHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'tool'); +} + +/** + * Wraps resource handlers to associate them with request spans + * @param serverInstance - MCP server instance + */ +export function wrapResourceHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'resource'); +} + +/** + * Wraps prompt handlers to associate them with request spans + * @param serverInstance - MCP server instance + */ +export function wrapPromptHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'prompt'); +} + +/** + * Wraps all MCP handler types (tool, resource, prompt) for span correlation + * @param serverInstance - MCP server instance + */ +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..1e16eaf202f3 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -0,0 +1,68 @@ +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'; + +/** + * Tracks wrapped MCP server instances to prevent double-wrapping + * @internal + */ +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. + * Automatically instruments transport methods and handler functions for comprehensive monitoring. + * + * @example + * ```typescript + * import * as Sentry from '@sentry/core'; + * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + * import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + * + * const server = Sentry.wrapMcpServerWithSentry( + * new McpServer({ name: "my-server", version: "1.0.0" }) + * ); + * + * const transport = new StreamableHTTPServerTransport(); + * await server.connect(transport); + * ``` + * + * @param mcpServerInstance - MCP server instance to instrument + * @returns Instrumented server instance (same reference) + */ +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, + ); + + wrapTransportOnMessage(transport); + wrapTransportSend(transport); + wrapTransportOnClose(transport); + wrapTransportError(transport); + + return result; + }; + }); + + 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..654427ca2d6d --- /dev/null +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -0,0 +1,60 @@ +/** + * PII filtering for MCP server spans + * + * Removes sensitive data when sendDefaultPii is false. + * Uses configurable attribute filtering to protect user privacy. + */ +import type { SpanAttributeValue } from '../../types-hoist/span'; +import { + CLIENT_ADDRESS_ATTRIBUTE, + CLIENT_PORT_ATTRIBUTE, + MCP_LOGGING_MESSAGE_ATTRIBUTE, + MCP_REQUEST_ARGUMENT, + MCP_RESOURCE_URI_ATTRIBUTE, + MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, +} from './attributes'; + +/** + * PII attributes that should be removed when sendDefaultPii is false + * @internal + */ +const PII_ATTRIBUTES = new Set([ + CLIENT_ADDRESS_ATTRIBUTE, + CLIENT_PORT_ATTRIBUTE, + MCP_LOGGING_MESSAGE_ATTRIBUTE, + MCP_RESOURCE_URI_ATTRIBUTE, + MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, +]); + +/** + * Checks if an attribute key should be considered PII + * @internal + */ +function isPiiAttribute(key: string): boolean { + return PII_ATTRIBUTES.has(key) || key.startsWith(`${MCP_REQUEST_ARGUMENT}.`); +} + +/** + * Removes PII attributes from span data when sendDefaultPii is false + * @param spanData - Raw span attributes + * @param sendDefaultPii - Whether to include PII data + * @returns Filtered span attributes + */ +export function filterMcpPiiFromSpanData( + spanData: Record, + sendDefaultPii: boolean, +): Record { + if (sendDefaultPii) { + return spanData as Record; + } + + return Object.entries(spanData).reduce( + (acc, [key, value]) => { + if (!isPiiAttribute(key)) { + acc[key] = value as SpanAttributeValue; + } + return acc; + }, + {} as Record, + ); +} 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..50d2ef51853f --- /dev/null +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -0,0 +1,196 @@ +/** + * Span creation and management functions for MCP server instrumentation + * + * Provides unified span creation following OpenTelemetry MCP semantic conventions and our opinitionated take on MCP. + * Handles both request and notification spans with attribute extraction. + */ + +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 + * @internal + * @param method - MCP method name + * @param target - Optional target identifier + * @returns Formatted span name + */ +function createSpanName(method: string, target?: string): string { + return target ? `${method} ${target}` : method; +} + +/** + * Build Sentry-specific attributes based on span type + * @internal + * @param type - Span type configuration + * @returns Sentry-specific attributes + */ +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 + * @internal + * @param config - Span configuration + * @returns Created span + */ +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; + } + + const rawAttributes: Record = { + ...buildTransportAttributes(transport, extra), + [MCP_METHOD_NAME_ATTRIBUTE]: method, + ...buildTypeSpecificAttributes(type, message, params), + ...buildSentryAttributes(type), + }; + + 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 + * @param jsonRpcMessage - Notification message + * @param transport - MCP transport instance + * @param extra - Extra handler data + * @param callback - Span execution callback + * @returns Span execution result + */ +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 + * @param jsonRpcMessage - Notification message + * @param transport - MCP transport instance + * @param callback - Span execution callback + * @returns Span execution result + */ +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 + * @param jsonRpcMessage - Request message + * @param transport - MCP transport instance + * @param extra - Optional extra handler data + * @returns Span configuration object + */ +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; + + const targetInfo = extractTargetInfo(method, params || {}); + const spanName = createSpanName(method, targetInfo.target); + + const rawAttributes: Record = { + ...buildTransportAttributes(transport, extra), + [MCP_METHOD_NAME_ATTRIBUTE]: method, + ...buildTypeSpecificAttributes('request', jsonRpcMessage, params), + ...buildSentryAttributes('request'), + }; + + 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..f6b4b70a0b38 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -0,0 +1,154 @@ +/** + * Transport layer instrumentation for MCP server + * + * Handles message interception and response correlation. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports + */ + +import { getIsolationScope, withIsolationScope } from '../../currentScopes'; +import { startInactiveSpan, withActiveSpan } from '../../tracing'; +import { fill } from '../../utils/object'; +import { cleanupPendingSpansForTransport, 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 + * @param transport - MCP transport instance to wrap + */ +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 }; + + const isolationScope = getIsolationScope().clone(); + + return withIsolationScope(isolationScope, () => { + const spanConfig = buildMcpServerSpanConfig(jsonRpcMessage, this, extra as ExtraHandlerData); + const span = startInactiveSpan(spanConfig); + + storeSpanForRequest(this, messageTyped.id, span, messageTyped.method); + + 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 + * @param transport - MCP transport instance to wrap + */ +export function wrapTransportSend(transport: MCPTransport): void { + if (transport.send) { + fill(transport, 'send', originalSend => { + return async function (this: MCPTransport, message: unknown) { + 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); + } + + completeSpanWithResults(this, messageTyped.id, messageTyped.result); + } + } + + return (originalSend as (...args: unknown[]) => unknown).call(this, message); + }; + }); + } +} + +/** + * Wraps transport.onclose to clean up pending spans for this transport only + * @param transport - MCP transport instance to wrap + */ +export function wrapTransportOnClose(transport: MCPTransport): void { + if (transport.onclose) { + fill(transport, 'onclose', originalOnClose => { + return function (this: MCPTransport, ...args: unknown[]) { + cleanupPendingSpansForTransport(this); + + return (originalOnClose as (...args: unknown[]) => unknown).call(this, ...args); + }; + }); + } +} + +/** + * Wraps transport error handlers to capture connection errors + * @param transport - MCP transport instance to wrap + */ +export function wrapTransportError(transport: MCPTransport): void { + if (transport.onerror) { + fill(transport, 'onerror', (originalOnError: (error: Error) => void) => { + return function (this: MCPTransport, error: Error) { + captureTransportError(error); + return originalOnError.call(this, error); + }; + }); + } +} + +/** + * Captures JSON-RPC error responses for server-side errors. + * @see https://www.jsonrpc.org/specification#error_object + * @internal + * @param errorResponse - JSON-RPC error response + */ +function captureJsonRpcErrorResponse(errorResponse: unknown): void { + try { + if (errorResponse && typeof errorResponse === 'object' && 'code' in errorResponse && 'message' in errorResponse) { + const jsonRpcError = errorResponse as { code: number; message: string; data?: unknown }; + + const isServerError = + jsonRpcError.code === -32603 || (jsonRpcError.code >= -32099 && jsonRpcError.code <= -32000); + + if (isServerError) { + const error = new Error(jsonRpcError.message); + error.name = `JsonRpcError_${jsonRpcError.code}`; + + captureError(error, 'protocol'); + } + } + } catch { + // noop + } +} + +/** + * Captures transport connection errors + * @internal + * @param error - Transport error + */ +function captureTransportError(error: Error): void { + try { + captureError(error, 'transport'); + } catch { + // noop + } +} 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..348e4c06e872 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -0,0 +1,165 @@ +import type { Span } from '../../types-hoist/span'; + +/** Types for MCP server instrumentation */ + +/** + * Configuration for extracting attributes from MCP methods + * @internal + */ +export type MethodConfig = { + targetField: string; + targetAttribute: string; + captureArguments?: boolean; + argumentsField?: string; + captureUri?: boolean; + captureName?: boolean; +}; + +/** + * JSON-RPC 2.0 request object + * @see https://www.jsonrpc.org/specification#request_object + */ +export interface JsonRpcRequest { + jsonrpc: '2.0'; + method: string; + id: string | number; + params?: Record; +} + +/** + * JSON-RPC 2.0 response object + * @see https://www.jsonrpc.org/specification#response_object + */ +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number | null; + result?: unknown; + error?: JsonRpcError; +} + +/** + * JSON-RPC 2.0 error object + * @see https://www.jsonrpc.org/specification#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 + * @see https://www.jsonrpc.org/specification#notification + */ +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: Record; +} + +/** + * MCP transport interface + * @description Abstraction for MCP communication transport layer + */ +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?: SessionId; +} + +/** Union type for all JSON-RPC message types */ +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +/** + * MCP server instance interface + * @description MCP server methods for registering handlers + */ +export interface MCPServerInstance { + /** Register a resource handler */ + resource: (name: string, ...args: unknown[]) => void; + + /** Register a tool handler */ + tool: (name: string, ...args: unknown[]) => void; + + /** Register a prompt handler */ + prompt: (name: string, ...args: unknown[]) => void; + + /** Connect the server to a transport */ + connect(transport: MCPTransport): Promise; +} + +/** Client connection information for handlers */ +export interface ExtraHandlerData { + requestInfo?: { remoteAddress?: string; remotePort?: number }; + clientAddress?: string; + clientPort?: number; + request?: { + ip?: string; + connection?: { remoteAddress?: string; remotePort?: number }; + }; +} + +/** Types of MCP spans */ +export type McpSpanType = 'request' | 'notification-incoming' | 'notification-outgoing'; + +/** + * Configuration for creating MCP spans + * @internal + */ +export interface McpSpanConfig { + type: McpSpanType; + message: JsonRpcRequest | JsonRpcNotification; + transport: MCPTransport; + extra?: ExtraHandlerData; + callback: () => unknown; +} + +export type SessionId = string; +export type RequestId = string | number; + +/** + * Request-to-span correlation data + * @internal + */ +export type RequestSpanMapValue = { + span: Span; + method: string; + startTime: number; +}; + +/** Generic MCP handler function */ +export type MCPHandler = (...args: unknown[]) => unknown; + +/** + * Extra data passed to MCP handlers + * @internal + */ +export interface HandlerExtraData { + sessionId?: SessionId; + requestId: RequestId; +} + +/** Error types for MCP operations */ +export type McpErrorType = + | 'tool_execution' + | 'resource_execution' + | 'prompt_execution' + | 'transport' + | 'protocol' + | 'validation' + | 'timeout'; 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..e7bfbc679b41 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/validation.ts @@ -0,0 +1,79 @@ +/** + * Message validation functions for MCP server instrumentation + * + * Provides JSON-RPC 2.0 message type validation and MCP server instance validation. + */ + +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import type { JsonRpcNotification, JsonRpcRequest } from './types'; + +/** + * Validates if a message is a JSON-RPC request + * @param message - Message to validate + * @returns True if 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 + * @param message - Message to validate + * @returns True if 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 + * @param message - Message to validate + * @returns True if 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 + * @param instance - Object to validate as MCP server instance + * @returns True if instance has required MCP server methods + */ +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 ded30dd50928..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/debug-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..bd5af090b0d9 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts @@ -0,0 +1,187 @@ +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 error type', () => { + const error = new Error('Tool execution failed'); + + captureError(error, 'tool_execution'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'tool_execution', + }, + }, + }); + }); + + it('should capture transport errors', () => { + const error = new Error('Connection failed'); + + captureError(error, 'transport'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'transport', + }, + }, + }); + }); + + it('should capture protocol errors', () => { + const error = new Error('Invalid JSON-RPC request'); + + captureError(error, 'protocol'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'protocol', + }, + }, + }); + }); + + it('should capture validation errors', () => { + const error = new Error('Invalid parameters'); + + captureError(error, 'validation'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'validation', + }, + }, + }); + }); + + it('should capture timeout errors', () => { + const error = new Error('Operation timed out'); + + captureError(error, 'timeout'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'timeout', + }, + }, + }); + }); + + it('should capture errors with MCP data for filtering', () => { + const error = new Error('Tool failed'); + + captureError(error, 'tool_execution', { tool_name: 'my-tool' }); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'tool_execution', + tool_name: 'my-tool', + }, + }, + }); + }); + + 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..7b110a0b2756 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -0,0 +1,438 @@ +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 unknown as ReturnType); + }); + + 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 = startSpanSpy.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 setAttributesSpy = vi.fn(); + const setStatusSpy = vi.fn(); + const endSpy = vi.fn(); + const mockSpan = { + setAttributes: setAttributesSpy, + setStatus: setStatusSpy, + end: endSpy, + }; + startInactiveSpanSpy.mockReturnValueOnce( + mockSpan as unknown as ReturnType, + ); + + 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(setAttributesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + 'mcp.tool.result.is_error': false, + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + 'mcp.tool.result.content': 'The weather in San Francisco is 18°C with partly cloudy skies.', + }), + ); + + // Verify span was completed successfully (no error status set) + expect(setStatusSpy).not.toHaveBeenCalled(); + expect(endSpy).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(); - }); - }); -});