diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts index aa0978a0ac76..6bdf6ed19c78 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts @@ -56,7 +56,7 @@ export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry( tracesSampleRate: 1.0, debug: true, dataCollection: { - genAI: { + mcp: { inputs: false, outputs: false, }, @@ -78,7 +78,7 @@ export default Sentry.withSentry( // The worker and the Durable Object share one cached client per isolate, so the entrypoint that // initializes first decides the data collection settings for both. dataCollection: { - genAI: { + mcp: { inputs: false, outputs: false, }, diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-static/src/app.ts index ec030f50e638..4fe40c8da5df 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-static/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-static/src/app.ts @@ -17,6 +17,8 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, dataCollection: { + // Covers the `genAI` fallback for MCP content. The node-express app covers `mcp`. + // TODO(v12): Switch to `mcp` once the `genAI` fallback is removed. genAI: { inputs: false, outputs: false, diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index 1189a70aef19..1f196b138dd8 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -16,7 +16,7 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, dataCollection: { - genAI: { + mcp: { inputs: false, outputs: false, }, diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 88a21e303f10..b205af292372 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -54,6 +54,7 @@ describe('sentryMiddleware', () => { urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, stackFrameVariables: true, frameContextLines: 5, @@ -160,6 +161,7 @@ describe('sentryMiddleware', () => { urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/core/src/integrations/mcp-server/piiFiltering.ts b/packages/core/src/integrations/mcp-server/piiFiltering.ts index fe9b91744cfb..2f6d029157ce 100644 --- a/packages/core/src/integrations/mcp-server/piiFiltering.ts +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -3,7 +3,7 @@ * * Removes network-level sensitive data when dataCollection.userInfo is false. * Input/output data (request arguments, tool/prompt results) is controlled - * separately via recordInputs/recordOutputs options. + * separately via dataCollection.mcp and the recordInputs/recordOutputs options. */ import { CLIENT_ADDRESS, CLIENT_PORT, MCP_RESOURCE_URI } from '@sentry/conventions/attributes'; import type { SpanAttributeValue } from '../../types/span'; diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index 92788b275f49..7c41f300d1c8 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -27,11 +27,15 @@ function resolveMcpOptions(options: McpServerWrapperOptions): ResolvedMcpOptions }; } - const genAI = getClient()?.getDataCollectionOptions().genAI; + const client = getClient(); + // TODO(v12): Drop the `genAI` fallback and read `client.getDataCollectionOptions().mcp`. Until then, the raw option + // is read because the resolved `mcp` always has defaults applied and would hide whether the user set it. + const userMcp = client?.getOptions().dataCollection?.mcp; + const genAI = client?.getDataCollectionOptions().genAI; return { - recordInputs: options.recordInputs ?? genAI?.inputs ?? true, - recordOutputs: options.recordOutputs ?? genAI?.outputs ?? true, + recordInputs: options.recordInputs ?? userMcp?.inputs ?? genAI?.inputs ?? true, + recordOutputs: options.recordOutputs ?? userMcp?.outputs ?? genAI?.outputs ?? true, }; } diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index dc8cceb7d896..88c17eaf7be4 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -231,9 +231,9 @@ export type SessionData = { * Options for configuring the MCP server wrapper. */ export type McpServerWrapperOptions = { - /** Whether to capture tool/prompt input arguments in spans. Defaults to `dataCollection.genAI.inputs`. */ + /** Whether to capture tool/prompt input arguments in spans. Defaults to `dataCollection.mcp.inputs`, then `dataCollection.genAI.inputs`. */ recordInputs?: boolean; - /** Whether to capture tool/prompt output results in spans. Defaults to `dataCollection.genAI.outputs`. */ + /** Whether to capture tool/prompt output results in spans. Defaults to `dataCollection.mcp.outputs`, then `dataCollection.genAI.outputs`. */ recordOutputs?: boolean; }; diff --git a/packages/core/src/types/datacollection.ts b/packages/core/src/types/datacollection.ts index 005e0a9fdd9d..fb19b0909b2a 100644 --- a/packages/core/src/types/datacollection.ts +++ b/packages/core/src/types/datacollection.ts @@ -85,6 +85,24 @@ export interface DataCollection { outputs?: boolean; }; + /** + * Allow MCP request/response content recording when using the SDK's MCP server instrumentation. + * + * - `inputs`: attach the content of incoming MCP requests, such as tool call arguments, prompt arguments, and the + * message payload of MCP logging notifications. + * - `outputs`: attach the content of MCP responses, such as tool results and prompt result messages. + * + * Metadata (method name, tool or prompt name, session and protocol version, content counts, error status) is always + * collected. MCP content is gated by this option, not `genAI`, even when it is stored on `gen_ai.*` attributes. + * The `recordInputs`/`recordOutputs` options of `wrapMcpServerWithSentry`, when set, take precedence over these values. + * When a field isn't set, the MCP server instrumentation falls back to the matching `genAI` field. + * @default { inputs: true, outputs: true } + */ + mcp?: { + inputs?: boolean; + outputs?: boolean; + }; + /** * Include data associated with database queries. This controls collection of bound query parameters, data payloads for write operations, and returned result data. * @@ -129,4 +147,5 @@ export type ResolvedDataCollection = Required; graphQL: Required>; genAI: Required>; + mcp: Required>; }; diff --git a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts index cb4ab2437799..44a5f69ba3bb 100644 --- a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts @@ -13,6 +13,7 @@ const DEFAULTS: ResolvedDataCollection = { urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, queues: true, stackFrameVariables: true, @@ -62,6 +63,10 @@ export function resolveDataCollectionOptions(options: { dataCollection?: DataCol inputs: dc.genAI?.inputs ?? DEFAULTS.genAI.inputs, outputs: dc.genAI?.outputs ?? DEFAULTS.genAI.outputs, }, + mcp: { + inputs: dc.mcp?.inputs ?? DEFAULTS.mcp.inputs, + outputs: dc.mcp?.outputs ?? DEFAULTS.mcp.outputs, + }, databaseQueryData: dc.databaseQueryData ?? DEFAULTS.databaseQueryData, queues: dc.queues ?? DEFAULTS.queues, stackFrameVariables: dc.stackFrameVariables ?? DEFAULTS.stackFrameVariables, diff --git a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts index 3371ebed35d7..1489a6846283 100644 --- a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts @@ -3,7 +3,13 @@ import { getCurrentScope, withScope } from '../../../../src/currentScopes'; import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; import { Scope } from '../../../../src/scope'; import * as tracingModule from '../../../../src/tracing/trace'; -import { createMockClient, createMockMcpServer, createMockTransport } from './testUtils'; +import type { DataCollection } from '../../../../src/types/datacollection'; +import { + createMockClient, + createMockClientWithDataCollection, + createMockMcpServer, + createMockTransport, +} from './testUtils'; describe('MCP Server Capture Policy', () => { type MockTransport = ReturnType; @@ -416,4 +422,50 @@ describe('MCP Server Capture Policy', () => { ); expectToolResult(span); }); + + describe('dataCollection precedence', () => { + it.each<[string, DataCollection, { inputs: boolean; outputs: boolean }]>([ + ['records by default', {}, { inputs: true, outputs: true }], + ['uses mcp', { mcp: { inputs: false, outputs: false } }, { inputs: false, outputs: false }], + // TODO(v12): Remove the genAI fallback cases. + [ + 'falls back to genAI when mcp is unset', + { genAI: { inputs: false, outputs: false } }, + { inputs: false, outputs: false }, + ], + [ + 'prefers mcp over genAI', + { mcp: { inputs: true, outputs: true }, genAI: { inputs: false, outputs: false } }, + { inputs: true, outputs: true }, + ], + [ + 'prefers a disabled mcp over an enabled genAI', + { mcp: { inputs: false, outputs: false }, genAI: { inputs: true, outputs: true } }, + { inputs: false, outputs: false }, + ], + [ + 'falls back to genAI per field', + { mcp: { inputs: true }, genAI: { inputs: false, outputs: false } }, + { inputs: true, outputs: false }, + ], + ])('%s', async (_, dataCollection, expected) => { + const sessionId = `data-collection-${JSON.stringify(dataCollection)}`; + const transport = await connectWrappedServer(sessionId); + const scope = new Scope(); + scope.setClient(createMockClientWithDataCollection(dataCollection)); + const span = queueInactiveSpan(); + + receiveToolCall(transport, scope, { id: 'precedence-request', location: 'Vienna, Austria' }); + await sendToolResult(transport, scope, { id: 'precedence-request', text: 'Forecast for Vienna' }); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ + id: 'precedence-request', + sessionId, + ...(expected.inputs && { location: 'Vienna, Austria' }), + }), + ); + expectToolResult(span, expected.outputs ? 'Forecast for Vienna' : undefined); + }); + }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts index e76bd5c7cfd8..27d81a65e5cb 100644 --- a/packages/core/test/lib/integrations/mcp-server/testUtils.ts +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -1,20 +1,22 @@ import type { Client } from '../../../../src/client'; +import type { DataCollection } from '../../../../src/types/datacollection'; +import { resolveDataCollectionOptions } from '../../../../src/utils/data-collection/resolveDataCollectionOptions'; import { vi } from 'vitest'; /** * Creates a mock Sentry client with getDataCollectionOptions for use in MCP server tests. * @param userInfo - Whether user identity data (IP, port) is collected. Default: true - * @param genAI - Whether AI inputs/outputs are recorded. Defaults to match userInfo. + * @param mcp - The `dataCollection.mcp` setting. Defaults to match userInfo. * @param traceLifecycle - Whether spans are streamed or sent as transactions. Default: 'static' */ export function createMockClient( userInfo = true, - genAI?: { inputs: boolean; outputs: boolean }, + mcp?: { inputs: boolean; outputs: boolean }, traceLifecycle: 'static' | 'stream' = 'static', ): Client { - const genAIOptions = genAI ?? { inputs: userInfo, outputs: userInfo }; + const mcpOptions = mcp ?? { inputs: userInfo, outputs: userInfo }; return { - getOptions: () => ({ traceLifecycle }), + getOptions: () => ({ traceLifecycle, dataCollection: { mcp: mcpOptions } }), getDataCollectionOptions: () => ({ userInfo, cookies: true, @@ -22,7 +24,8 @@ export function createMockClient( httpBodies: [], urlQueryParams: true, graphQL: { document: true, variables: true }, - genAI: genAIOptions, + genAI: { inputs: true, outputs: true }, + mcp: mcpOptions, databaseQueryData: true, stackFrameVariables: true, frameContextLines: 5, @@ -32,6 +35,18 @@ export function createMockClient( } as unknown as Client; } +/** + * Creates a mock Sentry client whose data collection options are resolved from `dataCollection`, as the real client does. + */ +export function createMockClientWithDataCollection(dataCollection: DataCollection): Client { + return { + getOptions: () => ({ traceLifecycle: 'static', dataCollection }), + getDataCollectionOptions: () => resolveDataCollectionOptions({ dataCollection }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as unknown as Client; +} + /** * Create a mock MCP server instance for testing (legacy API: tool/resource/prompt) */ diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index 6428b777bef3..d8962f4f6200 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -1095,7 +1095,7 @@ describe('MCP Server Transport Instrumentation', () => { }); describe('Wrapper Options', () => { - it('should NOT capture inputs/outputs when dataCollection.genAI.inputs/outputs are false', async () => { + it('should NOT capture inputs/outputs when dataCollection.mcp.inputs/outputs are false', async () => { getClientSpy.mockReturnValue(createMockClient(false)); const mockMcpServer = createMockMcpServer(); @@ -1123,7 +1123,7 @@ describe('MCP Server Transport Instrumentation', () => { ); }); - it('should capture inputs/outputs when dataCollection.genAI.inputs/outputs are true', async () => { + it('should capture inputs/outputs when dataCollection.mcp.inputs/outputs are true', async () => { getClientSpy.mockReturnValue(createMockClient(true)); const mockMcpServer = createMockMcpServer(); diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index 133ae35799dc..112d73d79461 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -10,6 +10,7 @@ describe('resolveDataCollectionOptions', () => { urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, queues: true, stackFrameVariables: true, @@ -53,6 +54,7 @@ describe('resolveDataCollectionOptions', () => { expect(result.urlQueryParams).toBe(true); expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.genAI).toEqual({ inputs: true, outputs: true }); + expect(result.mcp).toEqual({ inputs: true, outputs: true }); expect(result.databaseQueryData).toBe(true); expect(result.stackFrameVariables).toBe(true); expect(result.frameContextLines).toBe(5); @@ -133,6 +135,27 @@ describe('resolveDataCollectionOptions', () => { expect(result.genAI.outputs).toBe(true); }); + it('merges nested mcp partially', () => { + const result = resolveDataCollectionOptions({ + dataCollection: { + mcp: { outputs: false }, + }, + }); + + expect(result.mcp).toEqual({ inputs: true, outputs: false }); + }); + + it('resolves mcp independently of genAI', () => { + const result = resolveDataCollectionOptions({ + dataCollection: { + genAI: { inputs: false, outputs: false }, + }, + }); + + expect(result.genAI).toEqual({ inputs: false, outputs: false }); + expect(result.mcp).toEqual({ inputs: true, outputs: true }); + }); + it('merges nested graphQL partially', () => { const result = resolveDataCollectionOptions({ dataCollection: { @@ -205,7 +228,7 @@ describe('resolveDataCollectionOptions', () => { it('always returns all fields', () => { const result = resolveDataCollectionOptions({}); - expect(Object.keys(result)).toHaveLength(11); + expect(Object.keys(result)).toHaveLength(12); expect(result).toHaveProperty('userInfo'); expect(result).toHaveProperty('cookies'); expect(result).toHaveProperty('httpHeaders'); @@ -219,6 +242,9 @@ describe('resolveDataCollectionOptions', () => { expect(result).toHaveProperty('genAI'); expect(result).toHaveProperty('genAI.inputs'); expect(result).toHaveProperty('genAI.outputs'); + expect(result).toHaveProperty('mcp'); + expect(result).toHaveProperty('mcp.inputs'); + expect(result).toHaveProperty('mcp.outputs'); expect(result).toHaveProperty('databaseQueryData'); expect(result).toHaveProperty('queues'); expect(result).toHaveProperty('stackFrameVariables'); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 3266eab7a1e2..640e7c118f66 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -737,6 +737,7 @@ describe('request utils', () => { urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, stackFrameVariables: true, frameContextLines: 5, @@ -945,6 +946,7 @@ describe('request utils', () => { urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts index c91f26abf59b..5adc72abafc2 100644 --- a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts +++ b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts @@ -58,6 +58,7 @@ describe('wrapMiddlewareHandlerWithSentry', () => { urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/remix/test/server/errors.test.ts b/packages/remix/test/server/errors.test.ts index ee50d3d16902..d8cea575548c 100644 --- a/packages/remix/test/server/errors.test.ts +++ b/packages/remix/test/server/errors.test.ts @@ -22,6 +22,7 @@ function createMockClient( urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, + mcp: { inputs: true, outputs: true }, databaseQueryData: true, stackFrameVariables: true, frameContextLines: 5,