Skip to content
Open
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 @@ -4,13 +4,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod';

class MyMCPAgentBase extends McpAgent<Env, unknown, Record<string, unknown>> {
#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<void> {
Expand All @@ -31,7 +33,6 @@ class MyMCPAgentBase extends McpAgent<Env, unknown, Record<string, unknown>> {
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 {
Expand All @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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({
Expand All @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -60,6 +61,26 @@ server.tool('always-error', {}, async () => {
});

const transports: Record<string, SSEServerTransport> = {};
const capturePolicyTransports: Record<string, SSEServerTransport> = {};

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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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}` }],
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/integrations/mcp-server/correlation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,20 @@ function getOrCreateSpanMap(transport: MCPTransport): Map<RequestId, RequestSpan
* @param requestId - Request identifier
* @param span - Active span to correlate
* @param method - MCP method name
* @param capturePolicy - Capture policy resolved when the request began
*/
export function storeSpanForRequest(transport: MCPTransport, requestId: RequestId, span: Span, method: string): void {
export function storeSpanForRequest(
transport: MCPTransport,
requestId: RequestId,
span: Span,
method: string,
capturePolicy: ResolvedMcpOptions,
): void {
const spanMap = getOrCreateSpanMap(transport);
spanMap.set(requestId, {
span,
method,
capturePolicy,
// oxlint-disable-next-line sdk/no-unsafe-random-apis
startTime: Date.now(),
});
Expand All @@ -85,14 +93,12 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI
* @param transport - MCP transport instance
* @param requestId - Request identifier
* @param result - Execution result for attribute extraction
* @param options - Resolved MCP options
* @param hasError - Whether the JSON-RPC response contained an error
*/
export function completeSpanWithResults(
transport: MCPTransport,
requestId: RequestId,
result: unknown,
options: ResolvedMcpOptions,
hasError = false,
): void {
const spanMap = getOrCreateSpanMap(transport);
Expand All @@ -119,10 +125,10 @@ export function completeSpanWithResults(
if (hasError) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
} else if (method === 'tools/call') {
const toolAttributes = extractToolResultAttributes(result, options.recordOutputs);
const toolAttributes = extractToolResultAttributes(result, spanData.capturePolicy.recordOutputs);
span.setAttributes(toolAttributes);
} else if (method === 'prompts/get') {
const promptAttributes = extractPromptResultAttributes(result, options.recordOutputs);
const promptAttributes = extractPromptResultAttributes(result, spanData.capturePolicy.recordOutputs);
span.setAttributes(promptAttributes);
}

Expand Down
15 changes: 4 additions & 11 deletions packages/core/src/integrations/mcp-server/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { getClient } from '../../currentScopes';
import { fill } from '../../utils/object';
import { wrapAllMCPHandlers, wrapExistingHandlers } from './handlers';
import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport';
import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions } from './types';
import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport } from './types';
import { validateMcpServerInstance } from './validation';

/**
Expand Down Expand Up @@ -60,13 +59,7 @@ export function wrapMcpServerWithSentry<S extends object>(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[]) {
Expand All @@ -76,8 +69,8 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S,
...restArgs,
);

wrapTransportOnMessage(transport, resolvedOptions);
wrapTransportSend(transport, resolvedOptions);
wrapTransportOnMessage(transport, captureOptions);
wrapTransportSend(transport, captureOptions);
wrapTransportOnClose(transport);
wrapTransportError(transport);

Expand Down
41 changes: 30 additions & 11 deletions packages/core/src/integrations/mcp-server/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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);
});
}
Expand All @@ -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);
});
}
Expand All @@ -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);
}
}

Expand Down
Loading
Loading