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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry(
tracesSampleRate: 1.0,
debug: true,
dataCollection: {
genAI: {
mcp: {
inputs: false,
outputs: false,
},
Expand All @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Sentry.init({
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1,
dataCollection: {
genAI: {
mcp: {
inputs: false,
outputs: false,
},
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/test/server/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integrations/mcp-server/piiFiltering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/integrations/mcp-server/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/integrations/mcp-server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/types/datacollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -129,4 +147,5 @@ export type ResolvedDataCollection = Required<Omit<DataCollection, 'httpHeaders'
httpHeaders: Required<HttpHeadersCollection>;
graphQL: Required<NonNullable<DataCollection['graphQL']>>;
genAI: Required<NonNullable<DataCollection['genAI']>>;
mcp: Required<NonNullable<DataCollection['mcp']>>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createMockTransport>;
Expand Down Expand Up @@ -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);
});
});
});
25 changes: 20 additions & 5 deletions packages/core/test/lib/integrations/mcp-server/testUtils.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
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,
httpHeaders: { request: true, response: true },
httpBodies: [],
urlQueryParams: true,
graphQL: { document: true, variables: true },
genAI: genAIOptions,
genAI: { inputs: true, outputs: true },
mcp: mcpOptions,
databaseQueryData: true,
stackFrameVariables: true,
frameContextLines: 5,
Expand All @@ -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)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/lib/utils/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/remix/test/server/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading