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 8687e9e9d80d..761ae503c893 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 @@ -4,13 +4,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import * as z from 'zod'; class MyMCPAgentBase extends McpAgent> { - #mcpServer = new McpServer({ - name: 'cloudflare-mcp-agent', - version: '1.0.0', - }); + #mcpServer = Sentry.wrapMcpServerWithSentry( + new McpServer({ + name: 'cloudflare-mcp-agent', + version: '1.0.0', + }), + ); get server() { - return Sentry.wrapMcpServerWithSentry(this.#mcpServer); + return this.#mcpServer; } async init(): Promise { @@ -31,7 +33,6 @@ class MyMCPAgentBase extends McpAgent> { if (span) { span.setAttribute('mcp.tool.name', 'my-tool'); span.setAttribute('mcp.tool.extra', 'from-mcpagent'); - span.setAttribute('mcp.tool.input', JSON.stringify({ message })); } return { @@ -55,6 +56,12 @@ export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry( tunnel: `http://localhost:3031/`, tracesSampleRate: 1.0, debug: true, + dataCollection: { + genAI: { + inputs: false, + outputs: false, + }, + }, transportOptions: { bufferSize: 1000, }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts index d3324d81dee8..7ea37fec65f3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForRequest } from '@sentry-internal/test-utils'; test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ baseURL }) => { + const privateMessage = 'cloudflare-agent-private-capture-policy-message'; const mcpToolWaiter = waitForRequest('cloudflare-mcp-agent', event => { const transaction = event.envelope[1][0][1]; return ( @@ -66,16 +67,18 @@ test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ bas params: { name: 'my-tool', arguments: { - message: 'hello from MCPAgent test', + message: privateMessage, }, }, }), }); expect(response.status).toBe(200); + await expect(response.text()).resolves.toContain(`Tool my-tool: ${privateMessage}`); const mcpData = await mcpToolWaiter; const mcpEvent = mcpData.envelope[1][0][1]; + const traceData = mcpEvent.contexts?.trace?.data; expect(mcpEvent.contexts?.trace?.trace_id).toBe(mcpData.envelope[0].trace.trace_id); expect(mcpEvent.contexts?.trace).toEqual({ @@ -91,7 +94,12 @@ test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ bas 'mcp.method.name': 'tools/call', 'mcp.tool.name': 'my-tool', 'mcp.tool.extra': 'from-mcpagent', - 'mcp.tool.input': '{"message":"hello from MCPAgent test"}', + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', }), }); + expect(traceData?.['mcp.request.argument.message']).toBeUndefined(); + expect(traceData?.['mcp.tool.result.content']).toBeUndefined(); + expect(traceData?.['mcp.tool.input']).toBeUndefined(); + expect(JSON.stringify(traceData)).not.toContain(privateMessage); }); 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 5c93ad9ce05f..587dd9ec73cf 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 @@ -1,4 +1,6 @@ import * as Sentry from '@sentry/node'; +// Keep the dedicated MCP server evaluation ahead of initialization without loading Express before its instrumentation. +import './mcpCapturePolicyServer'; declare global { namespace globalThis { @@ -14,6 +16,12 @@ Sentry.init({ debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, + dataCollection: { + genAI: { + inputs: false, + outputs: false, + }, + }, // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts index 72c4535a3d6f..116e63849adc 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts @@ -5,6 +5,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { wrapMcpServerWithSentry } from '@sentry/node'; +import { capturePolicyServer } from './mcpCapturePolicyServer'; // Helper to check if request is an initialize request (compatible with all MCP SDK versions) function isInitializeRequest(body: unknown): boolean { @@ -60,6 +61,26 @@ server.tool('always-error', {}, async () => { }); const transports: Record = {}; +const capturePolicyTransports: Record = {}; + +mcpRouter.get('/capture-policy/sse', async (_, res) => { + const transport = new SSEServerTransport('/capture-policy/messages', res); + capturePolicyTransports[transport.sessionId] = transport; + res.on('close', () => { + delete capturePolicyTransports[transport.sessionId]; + }); + await capturePolicyServer.connect(transport); +}); + +mcpRouter.post('/capture-policy/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = capturePolicyTransports[sessionId as string]; + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); mcpRouter.get('/sse', async (_, res) => { const transport = new SSEServerTransport('/messages', res); diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/mcpCapturePolicyServer.ts b/dev-packages/e2e-tests/test-applications/node-express/src/mcpCapturePolicyServer.ts new file mode 100644 index 000000000000..4a85031c8bd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express/src/mcpCapturePolicyServer.ts @@ -0,0 +1,16 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { wrapMcpServerWithSentry } from '@sentry/node'; +import { z } from 'zod'; + +export const capturePolicyServer = wrapMcpServerWithSentry( + new McpServer({ + name: 'Capture-Policy', + version: '1.0.0', + }), +); + +capturePolicyServer.tool('capture-policy', { message: z.string() }, async ({ message }) => { + return { + content: [{ type: 'text', text: `Capture policy result: ${message}` }], + }; +}); 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 504bfaffcd27..d732b4c77dc1 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 @@ -180,6 +180,49 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { }); }); +test('resolves capture policy when the MCP server is wrapped before Sentry.init', async ({ baseURL }) => { + const transport = new SSEClientTransport(new URL(`${baseURL}/capture-policy/sse`)); + const client = new Client({ + name: 'capture-policy-client', + version: '1.0.0', + }); + await client.connect(transport); + + const toolTransactionPromise = waitForTransaction('node-express', transactionEvent => { + return transactionEvent.transaction === 'tools/call capture-policy'; + }); + const privateMessage = 'node-v1-private-capture-policy-message'; + + const toolResult = await client.callTool({ + name: 'capture-policy', + arguments: { + message: privateMessage, + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: `Capture policy result: ${privateMessage}`, + type: 'text', + }, + ], + }); + + const toolTransaction = await toolTransactionPromise; + const traceData = toolTransaction.contexts?.trace?.data; + + expect(traceData?.['mcp.method.name']).toBe('tools/call'); + expect(traceData?.['mcp.tool.name']).toBe('capture-policy'); + expect(traceData?.['mcp.tool.result.content_count']).toBe(1); + expect(traceData?.['mcp.tool.result.content_type']).toBe('text'); + expect(traceData?.['mcp.request.argument.message']).toBeUndefined(); + expect(traceData?.['mcp.tool.result.content']).toBeUndefined(); + expect(JSON.stringify(traceData)).not.toContain(privateMessage); + + await client.close(); +}); + /** * Tests for StreamableHTTPServerTransport (wrapper transport pattern) * diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 6e4d73ee24f2..c9a7ba4b5c94 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -69,12 +69,20 @@ function getOrCreateSpanMap(transport: MCPTransport): Map(mcpServerInstance: S, } const serverInstance = mcpServerInstance as MCPServerInstance; - const client = getClient(); - const genAI = client?.getDataCollectionOptions().genAI; - - const resolvedOptions: ResolvedMcpOptions = { - recordInputs: options?.recordInputs ?? genAI?.inputs ?? true, - recordOutputs: options?.recordOutputs ?? genAI?.outputs ?? true, - }; + const captureOptions: McpServerWrapperOptions = { ...options }; fill(serverInstance, 'connect', originalConnect => { return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { @@ -76,8 +69,8 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, ...restArgs, ); - wrapTransportOnMessage(transport, resolvedOptions); - wrapTransportSend(transport, resolvedOptions); + wrapTransportOnMessage(transport, captureOptions); + wrapTransportSend(transport, captureOptions); wrapTransportOnClose(transport); wrapTransportError(transport); diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index be292c6fb849..3e1757b56e00 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -5,7 +5,7 @@ * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports */ -import { getIsolationScope, withIsolationScope } from '../../currentScopes'; +import { getClient, getIsolationScope, withIsolationScope } from '../../currentScopes'; import { startInactiveSpan, withActiveSpan } from '../../tracing'; import { isObjectLike } from '../../utils/is'; import { fill } from '../../utils/object'; @@ -19,17 +19,33 @@ import { } from './sessionExtraction'; import { cleanupSessionDataForTransport, updateSessionDataForTransport } from './sessionManagement'; import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; -import type { ExtraHandlerData, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; +import type { ExtraHandlerData, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse } from './validation'; +function resolveMcpOptions(options: McpServerWrapperOptions): ResolvedMcpOptions { + if (options.recordInputs !== undefined && options.recordOutputs !== undefined) { + return { + recordInputs: options.recordInputs, + recordOutputs: options.recordOutputs, + }; + } + + const genAI = getClient()?.getDataCollectionOptions().genAI; + + return { + recordInputs: options.recordInputs ?? genAI?.inputs ?? true, + recordOutputs: options.recordOutputs ?? genAI?.outputs ?? true, + }; +} + /** * Wraps transport.onmessage to create spans for incoming messages. * Extracts and stores client info and protocol version from legacy initialize * requests and modern message envelopes. * @param transport - MCP transport instance to wrap - * @param options - Resolved MCP options + * @param options - MCP capture overrides */ -export function wrapTransportOnMessage(transport: MCPTransport, options: ResolvedMcpOptions): void { +export function wrapTransportOnMessage(transport: MCPTransport, options: McpServerWrapperOptions): void { if (transport.onmessage) { fill(transport, 'onmessage', originalOnMessage => { return function (this: MCPTransport, message: unknown, extra?: unknown) { @@ -53,10 +69,11 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve } if (request) { + const resolvedOptions = resolveMcpOptions(options); const isolationScope = getIsolationScope().clone(); return withIsolationScope(isolationScope, () => { - const spanConfig = buildMcpServerSpanConfig(request, transport, extra as ExtraHandlerData, options); + const spanConfig = buildMcpServerSpanConfig(request, transport, extra as ExtraHandlerData, resolvedOptions); const span = startInactiveSpan(spanConfig); if (request.method === 'initialize' && messageSessionData) { @@ -68,7 +85,7 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve }); } - storeSpanForRequest(transport, request.id, span, request.method); + storeSpanForRequest(transport, request.id, span, request.method, resolvedOptions); return withActiveSpan(span, () => { return (originalOnMessage as (...args: unknown[]) => unknown).call(this, request, extra); @@ -77,7 +94,8 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve } if (notification) { - return createMcpNotificationSpan(notification, transport, extra as ExtraHandlerData, options, () => { + const resolvedOptions = resolveMcpOptions(options); + return createMcpNotificationSpan(notification, transport, extra as ExtraHandlerData, resolvedOptions, () => { return (originalOnMessage as (...args: unknown[]) => unknown).call(this, notification, extra); }); } @@ -93,16 +111,17 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve * Extracts and stores protocol version and server info from legacy initialize * responses and modern result metadata. * @param transport - MCP transport instance to wrap - * @param options - Resolved MCP options + * @param options - MCP capture overrides */ -export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpOptions): void { +export function wrapTransportSend(transport: MCPTransport, options: McpServerWrapperOptions): void { if (transport.send) { fill(transport, 'send', originalSend => { return async function (this: MCPTransport, ...args: unknown[]) { const [message] = args; if (isJsonRpcNotification(message)) { - return createMcpOutgoingNotificationSpan(message, transport, options, () => { + const resolvedOptions = resolveMcpOptions(options); + return createMcpOutgoingNotificationSpan(message, transport, resolvedOptions, () => { return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); }); } @@ -113,7 +132,7 @@ export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpO captureJsonRpcErrorResponse(message.error); } - completeSpanWithResults(transport, message.id, message.result, options, !!message.error); + completeSpanWithResults(transport, message.id, message.result, !!message.error); } } diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 613d5dd5ad77..6232a368210f 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -176,6 +176,7 @@ export type RequestId = string | number; export type RequestSpanMapValue = { span: Span; method: string; + capturePolicy: ResolvedMcpOptions; startTime: number; }; diff --git a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts new file mode 100644 index 000000000000..bef42c7f5af1 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts @@ -0,0 +1,393 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentScope, withScope } from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { Scope } from '../../../../src/scope'; +import * as tracingModule from '../../../../src/tracing'; +import { createMockClient, createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP Server Capture Policy', () => { + type MockTransport = ReturnType; + type MockServer = ReturnType; + type MockSpan = ReturnType; + type WrapperOptions = Parameters[1]; + + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const connectedTransports: MockTransport[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + afterEach(() => { + for (const transport of connectedTransports) { + transport.onclose?.(); + } + connectedTransports.length = 0; + getCurrentScope().setClient(undefined); + }); + + function createClientScope(inputs: boolean, outputs: boolean): Scope { + const scope = new Scope(); + scope.setClient(createMockClient(true, { inputs, outputs })); + return scope; + } + + function createMockSpan() { + return { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + }; + } + + function queueInactiveSpan(): MockSpan { + const span = createMockSpan(); + startInactiveSpanSpy.mockReturnValueOnce(span as unknown as ReturnType); + return span; + } + + async function connectServer(server: MockServer, sessionId: string): Promise { + const transport = createMockTransport(); + transport.sessionId = sessionId; + connectedTransports.push(transport); + await server.connect(transport); + return transport; + } + + function connectWrappedServer(sessionId: string, options?: WrapperOptions): Promise { + return connectServer(wrapMcpServerWithSentry(createMockMcpServer(), options), sessionId); + } + + function receiveToolCall( + transport: MockTransport, + scope: Scope, + request: { id: string; name?: string; location?: string }, + ): void { + const { id, name = 'weather', location } = request; + const params = { + name, + ...(location !== undefined && { arguments: { location } }), + }; + + withScope(scope, () => { + transport.onmessage?.({ jsonrpc: '2.0', method: 'tools/call', id, params }, {}); + }); + } + + async function sendToolResult( + transport: MockTransport, + scope: Scope, + response: { id: string; text: string }, + ): Promise { + await withScope(scope, () => + transport.send?.({ + jsonrpc: '2.0', + id: response.id, + result: { + content: [{ type: 'text', text: response.text }], + isError: false, + }, + }), + ); + } + + function buildToolSpanConfig(request: { id: string; sessionId: string; name?: string; location?: string }) { + const { id, sessionId, name = 'weather', location } = request; + return { + name: `tools/call ${name}`, + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': name, + 'mcp.request.id': id, + 'mcp.session.id': sessionId, + 'mcp.transport': 'StreamableHTTPServerTransport', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + ...(location !== undefined && { 'mcp.request.argument.location': JSON.stringify(location) }), + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }; + } + + function expectToolResult(span: MockSpan, content?: string): void { + expect(span.setAttributes).toHaveBeenCalledOnce(); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + ...(content !== undefined && { 'mcp.tool.result.content': content }), + 'mcp.tool.result.is_error': false, + }); + } + + function buildLoggingSpanConfig(options: { + direction: 'client_to_server' | 'server_to_client'; + level: string; + sessionId: string; + }) { + return { + name: 'notifications/message', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'notifications/message', + 'mcp.session.id': options.sessionId, + 'mcp.transport': 'StreamableHTTPServerTransport', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.logging.level': options.level, + 'mcp.logging.logger': 'weather-service', + 'mcp.logging.data_type': 'string', + 'sentry.op': `mcp.notification.${options.direction}`, + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }; + } + + it('defaults to capturing inputs and outputs when an operation has no client', async () => { + const transport = await connectWrappedServer('capture-policy-defaults'); + const scope = new Scope(); + const span = queueInactiveSpan(); + + receiveToolCall(transport, scope, { + id: 'default-policy-request', + location: 'Paris, France', + }); + await sendToolResult(transport, scope, { + id: 'default-policy-request', + text: 'Forecast for Paris', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ + id: 'default-policy-request', + sessionId: 'capture-policy-defaults', + location: 'Paris, France', + }), + ); + expectToolResult(span, 'Forecast for Paris'); + }); + + it('resolves input capture when an operation starts after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-input'); + queueInactiveSpan(); + + receiveToolCall(transport, createClientScope(false, false), { + id: 'private-input-request', + location: 'Madrid, Spain', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ id: 'private-input-request', sessionId: 'capture-policy-input' }), + ); + }); + + it('resolves output capture when an operation starts after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-output'); + const privacyScope = createClientScope(false, false); + const span = queueInactiveSpan(); + + receiveToolCall(transport, privacyScope, { id: 'private-output-request' }); + await sendToolResult(transport, privacyScope, { + id: 'private-output-request', + text: 'Private forecast for Madrid', + }); + + expectToolResult(span); + }); + + it('isolates capture policy between operations running in different scopes', async () => { + const transport = await connectWrappedServer('capture-policy-scopes'); + queueInactiveSpan(); + queueInactiveSpan(); + + receiveToolCall(transport, createClientScope(false, false), { + id: 'private-scope-request', + location: 'Madrid, Spain', + }); + receiveToolCall(transport, createClientScope(true, true), { + id: 'recording-scope-request', + location: 'Berlin, Germany', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledTimes(2); + expect(startInactiveSpanSpy).toHaveBeenNthCalledWith( + 1, + buildToolSpanConfig({ id: 'private-scope-request', sessionId: 'capture-policy-scopes' }), + ); + expect(startInactiveSpanSpy).toHaveBeenNthCalledWith( + 2, + buildToolSpanConfig({ + id: 'recording-scope-request', + sessionId: 'capture-policy-scopes', + location: 'Berlin, Germany', + }), + ); + }); + + it('uses the request policy snapshot when the response runs with a different client', async () => { + const transport = await connectWrappedServer('capture-policy-snapshot'); + const span = queueInactiveSpan(); + + receiveToolCall(transport, createClientScope(true, false), { id: 'snapshot-request' }); + await sendToolResult(transport, createClientScope(false, true), { + id: 'snapshot-request', + text: 'Private forecast for Valencia', + }); + + expectToolResult(span); + }); + + it('keeps explicit overrides while resolving unspecified policy per operation', async () => { + const transport = await connectWrappedServer('capture-policy-explicit-options', { recordInputs: true }); + const operationScope = createClientScope(false, false); + const span = queueInactiveSpan(); + + receiveToolCall(transport, operationScope, { + id: 'explicit-options-request', + location: 'Lisbon, Portugal', + }); + await sendToolResult(transport, operationScope, { + id: 'explicit-options-request', + text: 'Private forecast for Lisbon', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ + id: 'explicit-options-request', + sessionId: 'capture-policy-explicit-options', + location: 'Lisbon, Portugal', + }), + ); + expectToolResult(span); + }); + + it('resolves input capture for incoming notifications after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-incoming-notification'); + const privacyScope = createClientScope(false, false); + + withScope(privacyScope, () => { + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', logger: 'weather-service', data: 'Private incoming notification' }, + }, + {}, + ); + }); + + expect(startSpanSpy).toHaveBeenCalledOnce(); + expect(startSpanSpy).toHaveBeenCalledWith( + buildLoggingSpanConfig({ + direction: 'client_to_server', + level: 'info', + sessionId: 'capture-policy-incoming-notification', + }), + expect.any(Function), + ); + }); + + it('resolves input capture for outgoing notifications after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-outgoing-notification'); + const privacyScope = createClientScope(false, false); + + await withScope(privacyScope, () => + transport.send?.({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'warning', logger: 'weather-service', data: 'Private outgoing notification' }, + }), + ); + + expect(startSpanSpy).toHaveBeenCalledOnce(); + expect(startSpanSpy).toHaveBeenCalledWith( + buildLoggingSpanConfig({ + direction: 'server_to_client', + level: 'warning', + sessionId: 'capture-policy-outgoing-notification', + }), + expect.any(Function), + ); + }); + + it('keeps output policies isolated for concurrent requests completed in reverse order', async () => { + const transport = await connectWrappedServer('capture-policy-concurrent-requests'); + const privacyScope = createClientScope(false, false); + const recordingScope = createClientScope(false, true); + const privateSpan = queueInactiveSpan(); + const recordingSpan = queueInactiveSpan(); + + receiveToolCall(transport, privacyScope, { id: 'private-concurrent-request', name: 'private-weather' }); + receiveToolCall(transport, recordingScope, { id: 'recording-concurrent-request', name: 'recording-weather' }); + await sendToolResult(transport, privacyScope, { + id: 'recording-concurrent-request', + text: 'Recorded forecast for Oslo', + }); + await sendToolResult(transport, recordingScope, { + id: 'private-concurrent-request', + text: 'Private forecast for Stockholm', + }); + + expectToolResult(recordingSpan, 'Recorded forecast for Oslo'); + expectToolResult(privateSpan); + }); + + it('combines an explicit output override with the operation input policy', async () => { + const transport = await connectWrappedServer('capture-policy-partial-output-override', { recordOutputs: true }); + const privacyScope = createClientScope(false, false); + const span = queueInactiveSpan(); + + receiveToolCall(transport, privacyScope, { + id: 'partial-output-override-request', + location: 'Tallinn, Estonia', + }); + await sendToolResult(transport, privacyScope, { + id: 'partial-output-override-request', + text: 'Recorded forecast for Tallinn', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ + id: 'partial-output-override-request', + sessionId: 'capture-policy-partial-output-override', + }), + ); + expectToolResult(span, 'Recorded forecast for Tallinn'); + }); + + it('snapshots explicit overrides from the first wrap', async () => { + const options = { recordInputs: false, recordOutputs: false }; + const server = wrapMcpServerWithSentry(createMockMcpServer(), options); + options.recordInputs = true; + options.recordOutputs = true; + wrapMcpServerWithSentry(server, { recordInputs: true, recordOutputs: true }); + const transport = await connectServer(server, 'capture-policy-first-wrap'); + const recordingScope = createClientScope(true, true); + const span = queueInactiveSpan(); + + receiveToolCall(transport, recordingScope, { + id: 'first-wrap-request', + location: 'Reykjavik, Iceland', + }); + await sendToolResult(transport, recordingScope, { + id: 'first-wrap-request', + text: 'Private forecast for Reykjavik', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ id: 'first-wrap-request', sessionId: 'capture-policy-first-wrap' }), + ); + expectToolResult(span); + }); +});