Skip to content
Draft
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
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { wrapMcpServerWithSentry } from '@sentry/node';

class CustomTransport extends WebStandardStreamableHTTPServerTransport {}

const server = wrapMcpServerWithSentry(new McpServer({ name: 'Echo', version: '1.0.0' }));
server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] }));
const transport = new CustomTransport({ sessionIdGenerator: () => 'test-session', enableJsonResponse: true });

async function request(id, method, userAgent, params) {
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Mcp-Protocol-Version': '2025-11-25',
};
if (userAgent !== undefined) {
headers['User-Agent'] = userAgent;
}
if (transport.sessionId !== undefined) {
headers['Mcp-Session-Id'] = transport.sessionId;
}

const response = await transport.handleRequest(
new Request('https://example.test/mcp', {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
}),
);
return response.status === 202 ? undefined : response.json();
}

async function run() {
await server.connect(transport);
const initialize = await request(1, 'initialize', 'initialize-client/1.0', {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
});
assert.ok(initialize.result);
await request(undefined, 'notifications/initialized');

const [tools, resources] = await Promise.all([
request(2, 'tools/list', 'tools-client/2.0'),
request(3, 'resources/list', 'resources-client/3.0'),
]);
assert.equal(tools.result.tools[0].name, 'echo');
assert.equal(resources.error.code, -32601);
assert.ok((await request(4, 'tools/list')).result);
await server.close();
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { wrapMcpServerWithSentry } from '@sentry/node';

const handler = createMcpHandler(() => {
const server = wrapMcpServerWithSentry(new McpServer({ name: 'Echo', version: '1.0.0' }));
server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] }));
return server;
});

async function request(id, method, userAgent) {
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Mcp-Protocol-Version': '2026-07-28',
'Mcp-Method': method,
};
if (userAgent !== undefined) {
headers['User-Agent'] = userAgent;
}

const response = await handler.fetch(
new Request('https://example.test/mcp', {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id,
method,
params: {
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': {},
},
},
}),
}),
);
return response.json();
}

async function run() {
const [discovery, tools, resources] = await Promise.all([
request(1, 'server/discover', 'discovery-client/1.0'),
request(2, 'tools/list', 'tools-client/2.0'),
request(3, 'resources/list', 'resources-client/3.0'),
]);
assert.ok(discovery.result);
assert.equal(tools.result.tools[0].name, 'echo');
assert.equal(resources.error.code, -32601);
assert.ok((await request(4, 'tools/list')).result);
}

run();
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@ function assertInitializeSpan(container: SerializedStreamedSpanContainer): void
expect(initializeSpan.attributes['test.mcp.initialize_spans_started']).toEqual({ type: 'integer', value: 1 });
}

function assertHttpSpans(
container: SerializedStreamedSpanContainer,
transport: string,
firstMethod: 'initialize' | 'server/discover',
): void {
const spans = mcpSpans(container);
expect(spans).toHaveLength(4);

for (const [id, method, userAgent] of [
['1', firstMethod, firstMethod === 'initialize' ? 'initialize-client/1.0' : 'discovery-client/1.0'],
['2', 'tools/list', 'tools-client/2.0'],
['3', 'resources/list', 'resources-client/3.0'],
['4', 'tools/list', undefined],
]) {
const span = spans.find(item => item.attributes['mcp.request.id']?.value === id);
expect(span).toBeDefined();
expect(span?.attributes['mcp.method.name']?.value).toBe(method);
expect(span?.attributes['mcp.transport']?.value).toBe(transport);
expect(span?.attributes['network.protocol.name']?.value).toBe('http');
expect(span?.attributes['network.protocol.version']).toBeUndefined();
expect(span?.attributes['network.transport']).toBeUndefined();
expect(span?.attributes['user_agent.original']?.value).toBe(userAgent);
expect(span?.status).toBe('ok');
}

const unsupportedSpan = spans.find(span => span.attributes['mcp.request.id']?.value === '3');
expect(unsupportedSpan?.attributes['rpc.response.status_code']?.value).toBe('-32601');
}

describe('MCP server spans (streamed, manual instrumentation)', () => {
afterAll(() => {
cleanupChildProcesses();
Expand Down Expand Up @@ -84,4 +113,30 @@ describe('MCP server spans (streamed, manual instrumentation)', () => {
},
{ additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } },
);

createEsmAndCjsTests(__dirname, 'scenario-http-v2.mjs', 'instrument.mjs', (createTestRunner, test) => {
test('captures request-local HTTP metadata for modern discovery and unsupported methods', async () => {
await createTestRunner()
.unordered()
.expect({ span: container => assertHttpSpans(container, 'PerRequestHTTPServerTransport', 'server/discover') })
.start()
.completed();
});
});

createEsmAndCjsTests(
__dirname,
'scenario-http-v1.mjs',
'instrument.mjs',
(createTestRunner, test) => {
test('preserves a custom transport class and request-local MCP v1 HTTP metadata', async () => {
await createTestRunner()
.unordered()
.expect({ span: container => assertHttpSpans(container, 'CustomTransport', 'initialize') })
.start()
.completed();
});
},
{ additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } },
);
});
2 changes: 2 additions & 0 deletions packages/core/src/integrations/mcp-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ function interceptTransportStart(transport: MCPTransport, beforeStart: () => voi
* Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package (legacy `tool`/`resource`/`prompt` API)
* and `@modelcontextprotocol/server` version 2.x (`registerTool`/`registerResource`/`registerPrompt` API).
* Automatically instruments transport methods and handler functions for comprehensive monitoring.
* Records the transport class as `mcp.transport` and HTTP User-Agent when available.
* Network protocol versions are recorded only when provided by the request runtime.
*
* Both call orderings are supported: wrapping before or after registering tools, resources,
* and prompts. Sentry patches the registration methods for future handlers and retroactively
Expand Down
67 changes: 49 additions & 18 deletions packages/core/src/integrations/mcp-server/sessionExtraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
MCP_SERVER_VERSION,
MCP_SESSION_ID,
MCP_TRANSPORT,
NETWORK_PROTOCOL_NAME,
NETWORK_PROTOCOL_VERSION,
NETWORK_TRANSPORT,
USER_AGENT_ORIGINAL,
} from '@sentry/conventions/attributes';
import {
getClientInfoForTransport,
Expand Down Expand Up @@ -242,27 +244,55 @@ export function extractClientInfo(extra: ExtraHandlerData): {
}

/**
* Extracts transport types based on transport constructor name
* Identifies known transport implementations without guessing from custom class names.
* @param transport - MCP transport instance
* @returns Transport type mapping for span attributes
*/
export function getTransportTypes(transport: MCPTransport): { mcpTransport: string; networkTransport: string } {
if (!transport?.constructor) {
return { mcpTransport: 'unknown', networkTransport: 'unknown' };
}
const transportName = typeof transport.constructor?.name === 'string' ? transport.constructor.name : 'unknown';
let networkTransport = 'unknown';

const lowerTransportName = transportName.toLowerCase();
if (lowerTransportName.includes('stdio')) {
networkTransport = 'pipe';
} else if (lowerTransportName.includes('http') || lowerTransportName.includes('sse')) {
networkTransport = 'tcp';
}
export function getTransportTypes(transport: MCPTransport): {
mcpTransport: string;
networkTransport?: string;
networkProtocolName?: string;
} {
const transportName = typeof transport?.constructor?.name === 'string' ? transport.constructor.name : 'unknown';
const isHttp = [
'StreamableHTTPServerTransport',
'NodeStreamableHTTPServerTransport',
'WebStandardStreamableHTTPServerTransport',
'PerRequestHTTPServerTransport',
'SSEServerTransport',
].includes(transportName);
Comment thread
cursor[bot] marked this conversation as resolved.

return {
mcpTransport: transportName,
networkTransport,
networkTransport: transportName === 'StdioServerTransport' ? 'pipe' : undefined,
networkProtocolName: isHttp ? 'http' : undefined,
};
}

/**
* Extracts HTTP metadata available on the current MCP request.
* @param extra - Request metadata provided by the MCP transport
* @see https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md#recording-mcp-transport
*/
function getHttpAttributes(extra?: ExtraHandlerData): Record<string, string> {
const headers = extra?.request?.headers ?? extra?.requestInfo?.headers;
const userAgent =
typeof headers?.get === 'function'
? headers.get('user-agent')
: headers && 'user-agent' in headers
? headers['user-agent']
: undefined;
const httpProtocol = extra?.request?.cf?.httpProtocol;
const httpVersion = typeof httpProtocol === 'string' ? /^HTTP\/([\d.]+)$/i.exec(httpProtocol)?.[1] : undefined;
const networkTransport =
httpVersion === '3' ? 'quic' : httpVersion && ['1.0', '1.1', '2'].includes(httpVersion) ? 'tcp' : undefined;

return {
...((headers || httpVersion) && { [NETWORK_PROTOCOL_NAME]: 'http' }),
...(httpVersion && { [NETWORK_PROTOCOL_VERSION]: httpVersion }),
...(networkTransport && { [NETWORK_TRANSPORT]: networkTransport }),
...(typeof userAgent === 'string' && userAgent && { [USER_AGENT_ORIGINAL]: userAgent }),
...(Array.isArray(userAgent) && { [USER_AGENT_ORIGINAL]: userAgent.join(', ') }),
};
}

Expand All @@ -283,7 +313,7 @@ export function buildTransportAttributes(
const hasRequestMetadata = messageData?.protocolVersion !== undefined || messageData?.clientInfo !== undefined;
const sessionId = !hasRequestMetadata && transport && 'sessionId' in transport ? transport.sessionId : undefined;
const clientInfo = extra ? extractClientInfo(extra) : {};
const { mcpTransport, networkTransport } = getTransportTypes(transport);
const { mcpTransport, networkTransport, networkProtocolName } = getTransportTypes(transport);
const clientAttributes = hasRequestMetadata
? buildClientAttributesFromInfo(messageData?.clientInfo)
: getClientAttributes(transport);
Expand All @@ -295,8 +325,9 @@ export function buildTransportAttributes(
...(clientInfo.address && { [CLIENT_ADDRESS]: clientInfo.address }),
...(clientInfo.port && { [CLIENT_PORT]: clientInfo.port }),
[MCP_TRANSPORT]: mcpTransport,
[NETWORK_TRANSPORT]: networkTransport,
[NETWORK_PROTOCOL_VERSION]: '2.0',
...(networkTransport && { [NETWORK_TRANSPORT]: networkTransport }),
...(networkProtocolName && { [NETWORK_PROTOCOL_NAME]: networkProtocolName }),
...getHttpAttributes(extra),
...(protocolVersion && { [MCP_PROTOCOL_VERSION]: protocolVersion }),
...clientAttributes,
...serverAttributes,
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/integrations/mcp-server/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Span } from '../../types/span';
import type { WebFetchHeaders } from '../../types/webfetchapi';

/** Types for MCP server instrumentation */

Expand Down Expand Up @@ -146,10 +147,16 @@ export interface MCPServerInstance {

/** Client connection information for handlers */
export interface ExtraHandlerData {
requestInfo?: { remoteAddress?: string; remotePort?: number };
requestInfo?: {
remoteAddress?: string;
remotePort?: number;
headers?: Record<string, string | string[] | undefined>;
};
clientAddress?: string;
clientPort?: number;
request?: {
headers?: Pick<WebFetchHeaders, 'get'> | Record<string, string | string[] | undefined>;
cf?: { httpProtocol?: string };
ip?: string;
connection?: { remoteAddress?: string; remotePort?: number };
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,7 @@ describe('MCP Server Capture Policy', () => {
'mcp.request.id': id,
'mcp.session.id': sessionId,
'mcp.transport': 'StreamableHTTPServerTransport',
'network.transport': 'tcp',
'network.protocol.version': '2.0',
'network.protocol.name': 'http',
...(location !== undefined && { 'mcp.request.argument.location': JSON.stringify(location) }),
'sentry.op': 'mcp.server',
'sentry.origin': 'auto.function.mcp_server',
Expand Down Expand Up @@ -136,8 +135,7 @@ describe('MCP Server Capture Policy', () => {
'mcp.method.name': 'notifications/message',
'mcp.session.id': options.sessionId,
'mcp.transport': 'StreamableHTTPServerTransport',
'network.transport': 'tcp',
'network.protocol.version': '2.0',
'network.protocol.name': 'http',
'mcp.logging.level': options.level,
'mcp.logging.logger': 'weather-service',
'mcp.logging.data_type': 'string',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { buildTransportAttributes } from '../../../../src/integrations/mcp-server/sessionExtraction';
import { createMockStdioTransport, createMockTransport } from './testUtils';

describe('MCP network attributes', () => {
it('identifies per-request HTTP without request metadata', () => {
const transport = createMockTransport();
Object.defineProperty(transport, 'constructor', { value: { name: 'PerRequestHTTPServerTransport' } });

expect(buildTransportAttributes(transport)).toEqual({
'mcp.transport': 'PerRequestHTTPServerTransport',
'mcp.session.id': 'test-session-123',
'network.protocol.name': 'http',
});
});

it('does not report JSON-RPC as a network protocol for stdio', () => {
const attributes = buildTransportAttributes(createMockStdioTransport());

expect(attributes).toMatchObject({
'mcp.transport': 'StdioServerTransport',
'network.transport': 'pipe',
});
expect(attributes).not.toHaveProperty('network.protocol.name');
expect(attributes).not.toHaveProperty('network.protocol.version');
expect(attributes).not.toHaveProperty('user_agent.original');
});

it('does not infer a network transport from a custom class name', () => {
const transport = createMockTransport();
Object.defineProperty(transport, 'constructor', { value: { name: 'CustomHttpStdioTransport' } });

expect(buildTransportAttributes(transport)).toEqual({
'mcp.transport': 'CustomHttpStdioTransport',
'mcp.session.id': 'test-session-123',
});
});

it.each([
['HTTP/1.1', '1.1', 'tcp'],
['HTTP/2', '2', 'tcp'],
['HTTP/3', '3', 'quic'],
])('records Cloudflare %s metadata from the current request', (httpProtocol, version, networkTransport) => {
const request = Object.assign(new Request('https://example.com/mcp'), { cf: { httpProtocol } });
const attributes = buildTransportAttributes(createMockTransport(), { request });

expect(attributes).toMatchObject({
'network.protocol.name': 'http',
'network.protocol.version': version,
'network.transport': networkTransport,
});
});

it('omits unknown HTTP version and transport instead of inferring them from the URL or headers', () => {
const request = new Request('https://example.com/mcp', {
headers: { 'MCP-Protocol-Version': '2026-07-28', 'X-Forwarded-Proto': 'https' },
});
const attributes = buildTransportAttributes(createMockTransport(), { request });

expect(attributes['network.protocol.name']).toBe('http');
expect(attributes).not.toHaveProperty('network.protocol.version');
expect(attributes).not.toHaveProperty('network.transport');
});
});
Loading
Loading