diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json index df6fa844b888..4dcddbb427e1 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -14,10 +14,10 @@ "test:dev": "TEST_ENV=development playwright test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/server": "2.0.0", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "agents": "0.3.10", - "zod": "^3.25.76" + "agents": "0.20.1", + "zod": "^4.2.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240725.0", @@ -30,11 +30,5 @@ "volta": { "node": "24.15.0", "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "strip-literal": "~2.0.0", - "@modelcontextprotocol/sdk": "1.25.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts index c715346306eb..f6c9d4ded9fa 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts @@ -11,9 +11,55 @@ * Learn more at https://developers.cloudflare.com/workers/ */ import * as Sentry from '@sentry/cloudflare'; -import { createMcpHandler } from 'agents/mcp'; -import * as z from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler } from 'agents/mcp/server'; +import { z } from 'zod'; + +function createServer() { + const server = Sentry.wrapMcpServerWithSentry( + new McpServer({ + name: 'cloudflare-mcp', + version: '2.0.0', + }), + ); + + server.registerTool( + 'my-tool', + { + title: 'My Tool', + description: 'My Tool Description', + inputSchema: z.object({ + message: z.string(), + }), + }, + async ({ message }) => { + const span = Sentry.getActiveSpan(); + + await new Promise(resolve => setTimeout(resolve, 500)); + + if (span) { + span.setAttribute('mcp.tool.name', 'my-tool'); + span.setAttribute('mcp.tool.extra', 'ƸӜƷ'); + span.setAttribute('mcp.tool.input', JSON.stringify({ message })); + } + + return { + content: [ + { + type: 'text' as const, + text: `Tool my-tool: ${message}`, + }, + ], + }; + }, + ); + + return server; +} + +const mcpHandler = createMcpHandler(createServer, { + route: '/mcp', +}); export default Sentry.withSentry( (env: Env) => ({ @@ -31,54 +77,13 @@ export default Sentry.withSentry( }), { async fetch(request, env, ctx) { - const server = new McpServer({ - name: 'cloudflare-mcp', - version: '1.0.0', - }); - const span = Sentry.getActiveSpan(); if (span) { span.setAttribute('mcp.server.extra', ' /|\ ^._.^ /|\ '); } - server.registerTool( - 'my-tool', - { - title: 'My Tool', - description: 'My Tool Description', - inputSchema: { - message: z.string(), - }, - }, - async ({ message }) => { - const span = Sentry.getActiveSpan(); - - // simulate a long running tool - await new Promise(resolve => setTimeout(resolve, 500)); - - if (span) { - span.setAttribute('mcp.tool.name', 'my-tool'); - span.setAttribute('mcp.tool.extra', 'ƸӜƷ'); - span.setAttribute('mcp.tool.input', JSON.stringify({ message })); - } - - return { - content: [ - { - type: 'text' as const, - text: `Tool my-tool: ${message}`, - }, - ], - }; - }, - ); - - const handler = createMcpHandler(Sentry.wrapMcpServerWithSentry(server), { - route: '/mcp', - }); - - return handler(request, env, ctx); + return mcpHandler(request, env, ctx); }, } satisfies ExportedHandler, ); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts index a7bc516e8dae..22098b605de8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts @@ -1,32 +1,59 @@ import { expect, test } from '@playwright/test'; import { waitForRequest } from '@sentry-internal/test-utils'; -test('sends spans for MCP tool calls', async ({ baseURL }) => { - const spanRequestWaiter = waitForRequest('cloudflare-mcp', event => { - const transaction = event.envelope[1][0][1]; - return typeof transaction !== 'string' && 'transaction' in transaction && transaction.transaction === 'POST /mcp'; - }); +const APP_NAME = 'cloudflare-mcp'; + +function getTransaction(eventData: Awaited>) { + const event = eventData.envelope[1][0][1]; + return typeof event !== 'string' && 'transaction' in event ? event : undefined; +} + +function requireTransaction(eventData: Awaited>) { + const event = getTransaction(eventData); + if (!event) { + throw new Error('Expected a transaction event'); + } + return event; +} + +test.describe.configure({ mode: 'serial' }); - const spanMcpWaiter = waitForRequest('cloudflare-mcp', event => { - const transaction = event.envelope[1][0][1]; +test('sends spans for MCP 2026-07-28 tool calls', async ({ baseURL }) => { + const url = `${baseURL}/mcp?protocol=modern`; + const requestWaiter = waitForRequest(APP_NAME, eventData => { + const event = getTransaction(eventData); + return event?.transaction === 'POST /mcp' && event.contexts?.trace?.data?.['url.full'] === url; + }); + const mcpWaiter = waitForRequest(APP_NAME, eventData => { + const event = getTransaction(eventData); return ( - typeof transaction !== 'string' && - 'transaction' in transaction && - transaction.transaction === 'tools/call my-tool' + event?.transaction === 'tools/call my-tool' && + event.contexts?.trace?.data?.['mcp.protocol.version'] === '2026-07-28' ); }); - const response = await fetch(`${baseURL}/mcp`, { + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'my-tool', }, body: JSON.stringify({ jsonrpc: '2.0', - id: 1, + id: 'modern-tool-call', method: 'tools/call', params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { + name: 'cloudflare-modern-client', + version: '2.0.0', + }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }, name: 'my-tool', arguments: { message: 'ʕっ•ᴥ•ʔっ', @@ -36,76 +63,102 @@ test('sends spans for MCP tool calls', async ({ baseURL }) => { }); expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 'modern-tool-call', + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'Tool my-tool: ʕっ•ᴥ•ʔっ' }], + }, + }); - const requestData = await spanRequestWaiter; - const mcpData = await spanMcpWaiter; - - const requestEvent = requestData.envelope[1][0][1]; - const mcpEvent = mcpData.envelope[1][0][1]; - - // Check that the events have contexts - // this is for TypeScript type safety - if ( - typeof mcpEvent === 'string' || - !('contexts' in mcpEvent) || - typeof requestEvent === 'string' || - !('contexts' in requestEvent) - ) { - throw new Error("Events don't have contexts"); - } + const requestData = await requestWaiter; + const mcpData = await mcpWaiter; + const requestEvent = requireTransaction(requestData); + const mcpEvent = requireTransaction(mcpData); + const requestTrace = requestEvent.contexts?.trace; + const mcpTrace = mcpEvent.contexts?.trace; - expect(mcpEvent.contexts?.trace?.trace_id).toBe((mcpData.envelope[0].trace as any).trace_id); + expect(requestTrace?.op).toBe('http.server'); + expect(requestTrace?.origin).toBe('auto.http.cloudflare'); + expect(requestTrace?.status).toBe('ok'); + expect(requestTrace?.data?.['sentry.origin']).toBe('auto.http.cloudflare'); + expect(requestTrace?.data?.['sentry.op']).toBe('http.server'); + expect(requestTrace?.data?.['sentry.source']).toBe('url'); + expect(requestTrace?.data?.['http.request.method']).toBe('POST'); + expect(requestTrace?.data?.['url.path']).toBe('/mcp'); + expect(requestTrace?.data?.['url.full']).toBe(url); + expect(requestTrace?.data?.['url.port']).toBe('38787'); + expect(requestTrace?.data?.['url.scheme']).toBe('http:'); + expect(requestTrace?.data?.['server.address']).toBe('localhost'); + expect(requestTrace?.data?.['http.request.body.size']).toBe(341); + expect(requestTrace?.data?.['user_agent.original']).toBe('node'); + expect(requestTrace?.data?.['http.request.header.content_type']).toBe('application/json'); + expect(requestTrace?.data?.['network.protocol.name']).toBe('HTTP/1.1'); + expect(requestTrace?.data?.['http.response.status_code']).toBe(200); + expect(requestTrace?.data?.['mcp.server.extra']).toBe(' /|\ ^._.^ /|\ '); + expect(mcpTrace?.trace_id).toBe(requestTrace?.trace_id); + expect(mcpTrace?.trace_id).toBe((mcpData.envelope[0].trace as { trace_id: string }).trace_id); + expect(mcpTrace?.parent_span_id).toBe(requestTrace?.span_id); expect(requestData.envelope[0].event_id).not.toBe(mcpData.envelope[0].event_id); + expect(mcpTrace?.op).toBe('mcp.server'); + expect(mcpTrace?.origin).toBe('auto.function.mcp_server'); + expect(mcpTrace?.status).toBe('ok'); + expect(mcpTrace?.data?.['mcp.transport']).toBe('PerRequestHTTPServerTransport'); + expect(mcpTrace?.data?.['network.transport']).toBe('tcp'); + expect(mcpTrace?.data?.['mcp.protocol.version']).toBe('2026-07-28'); + expect(mcpTrace?.data?.['mcp.client.name']).toBe('cloudflare-modern-client'); + expect(mcpTrace?.data?.['mcp.client.version']).toBe('2.0.0'); + expect(mcpTrace?.data?.['mcp.server.name']).toBe('cloudflare-mcp'); + expect(mcpTrace?.data?.['mcp.server.version']).toBe('2.0.0'); + expect(mcpTrace?.data?.['mcp.method.name']).toBe('tools/call'); + expect(mcpTrace?.data?.['mcp.request.id']).toBe('modern-tool-call'); + expect(mcpTrace?.data?.['mcp.tool.name']).toBe('my-tool'); + expect(mcpTrace?.data?.['mcp.request.argument.message']).toBe('"ʕっ•ᴥ•ʔっ"'); + expect(mcpTrace?.data?.['mcp.tool.result.content_count']).toBe(1); + expect(mcpTrace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: ʕっ•ᴥ•ʔっ'); +}); - expect(requestEvent.contexts?.trace).toEqual({ - span_id: expect.any(String), - trace_id: expect.any(String), - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - 'sentry.op': 'http.server', - 'sentry.source': 'url', - 'sentry.sample_rate': 1, - 'http.request.method': 'POST', - 'url.path': '/mcp', - 'url.full': 'http://localhost:38787/mcp', - 'url.port': '38787', - 'url.scheme': 'http:', - 'server.address': 'localhost', - 'http.request.body.size': 120, - 'user_agent.original': 'node', - 'http.request.header.content_type': 'application/json', - 'network.protocol.name': 'HTTP/1.1', - 'mcp.server.extra': ' /|\ ^._.^ /|\ ', - 'http.response.status_code': 200, - }), - op: 'http.server', - status: 'ok', - origin: 'auto.http.cloudflare', +test('keeps sending spans for legacy-compatible MCP tool calls', async ({ baseURL }) => { + const url = `${baseURL}/mcp?protocol=legacy`; + const mcpWaiter = waitForRequest(APP_NAME, eventData => { + const event = getTransaction(eventData); + return ( + event?.transaction === 'tools/call my-tool' && + event.contexts?.trace?.data?.['mcp.request.argument.message'] === '"legacy protocol request"' + ); }); - expect(mcpEvent.contexts?.trace).toEqual({ - trace_id: expect.any(String), - parent_span_id: requestEvent.contexts?.trace?.span_id, - span_id: expect.any(String), - op: 'mcp.server', - origin: 'auto.function.mcp_server', - status: 'ok', - data: { - 'sentry.origin': 'auto.function.mcp_server', - 'sentry.op': 'mcp.server', - 'sentry.source': 'route', - 'mcp.transport': 'WorkerTransport', - 'network.transport': 'unknown', - 'network.protocol.version': '2.0', - 'mcp.method.name': 'tools/call', - 'mcp.request.id': '1', - 'mcp.tool.name': 'my-tool', - 'mcp.request.argument.message': '"ʕっ•ᴥ•ʔっ"', - 'mcp.tool.extra': 'ƸӜƷ', - 'mcp.tool.input': '{"message":"ʕっ•ᴥ•ʔっ"}', - 'mcp.tool.result.content_count': 1, - 'mcp.tool.result.content_type': 'text', - 'mcp.tool.result.content': 'Tool my-tool: ʕっ•ᴥ•ʔっ', + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'legacy-tool-call', + method: 'tools/call', + params: { + name: 'my-tool', + arguments: { + message: 'legacy protocol request', + }, + }, + }), }); + + expect(response.status).toBe(200); + + const mcpEvent = requireTransaction(await mcpWaiter); + const trace = mcpEvent.contexts?.trace; + + expect(trace?.op).toBe('mcp.server'); + expect(trace?.status).toBe('ok'); + expect(trace?.data?.['mcp.transport']).toBe('WebStandardStreamableHTTPServerTransport'); + expect(trace?.data?.['mcp.method.name']).toBe('tools/call'); + expect(trace?.data?.['mcp.request.id']).toBe('legacy-tool-call'); + expect(trace?.data?.['mcp.tool.name']).toBe('my-tool'); + expect(trace?.data?.['mcp.protocol.version']).toBeUndefined(); + expect(trace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: legacy protocol request'); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json index 4460adbd034c..1f745b394192 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -12,17 +12,17 @@ }, "dependencies": { "@cfworker/json-schema": "^4.0.0", - "@modelcontextprotocol/server": "2.0.0-alpha.2", - "@modelcontextprotocol/node": "2.0.0-alpha.2", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^4.21.2", "typescript": "~5.0.0", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { - "@modelcontextprotocol/client": "2.0.0-alpha.2", + "@modelcontextprotocol/client": "2.0.0", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts index 776725c11cf2..a5413565ee03 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts @@ -3,9 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; import { Client } from '@modelcontextprotocol/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -test('Should record transactions for MCP handlers using @modelcontextprotocol/sdk v2 (register* API)', async ({ - baseURL, -}) => { +test('records transactions for stable MCP SDK v2 handlers using the register API', async ({ baseURL }) => { const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); const client = new Client({ diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index c527a34cd5a2..6e4d73ee24f2 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -14,7 +14,12 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types/span'; import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; -import { buildServerAttributesFromInfo, extractSessionDataFromInitializeResponse } from './sessionExtraction'; +import { + buildServerAttributesFromInfo, + extractSessionDataFromInitializeResponse, + extractSessionDataFromResponse, +} from './sessionExtraction'; +import { updateSessionDataForTransport } from './sessionManagement'; import type { MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; /** @@ -94,21 +99,25 @@ export function completeSpanWithResults( const spanData = spanMap.get(requestId); if (spanData) { const { span, method } = spanData; + const responseSessionData = + method === 'initialize' + ? extractSessionDataFromInitializeResponse(result) + : extractSessionDataFromResponse(result); + if (responseSessionData.protocolVersion || responseSessionData.serverInfo) { + updateSessionDataForTransport(transport, responseSessionData); + } + const responseAttributes: Record = { + ...buildServerAttributesFromInfo(responseSessionData.serverInfo), + }; + if (responseSessionData.protocolVersion) { + responseAttributes[MCP_PROTOCOL_VERSION_ATTRIBUTE] = responseSessionData.protocolVersion; + } + if (Object.keys(responseAttributes).length > 0) { + span.setAttributes(responseAttributes); + } if (hasError) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - } else if (method === 'initialize') { - const sessionData = extractSessionDataFromInitializeResponse(result); - const serverAttributes = buildServerAttributesFromInfo(sessionData.serverInfo); - - const initAttributes: Record = { - ...serverAttributes, - }; - if (sessionData.protocolVersion) { - initAttributes[MCP_PROTOCOL_VERSION_ATTRIBUTE] = sessionData.protocolVersion; - } - - span.setAttributes(initAttributes); } else if (method === 'tools/call') { const toolAttributes = extractToolResultAttributes(result, options.recordOutputs); span.setAttributes(toolAttributes); diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index fd4c5168551a..cfefe1c1d162 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -12,10 +12,10 @@ import { validateMcpServerInstance } from './validation'; const wrappedMcpServerInstances = new WeakSet(); /** - * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. + * Wraps an MCP Server instance with Sentry instrumentation. * * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package (legacy `tool`/`resource`/`prompt` API) - * and versions that expose the newer `registerTool`/`registerResource`/`registerPrompt` API (introduced in 1.x, sole API in 2.x). + * and `@modelcontextprotocol/server` version 2.x (`registerTool`/`registerResource`/`registerPrompt` API). * Automatically instruments transport methods and handler functions for comprehensive monitoring. * * Both call orderings are supported: wrapping before or after registering tools, resources, @@ -26,8 +26,8 @@ const wrappedMcpServerInstances = new WeakSet(); * @example * ```typescript * import * as Sentry from '@sentry/core'; - * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; - * import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + * import { McpServer } from '@modelcontextprotocol/server'; + * import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; * * // Wrap first, then register tools — this is the correct order * const server = Sentry.wrapMcpServerWithSentry( @@ -42,7 +42,7 @@ const wrappedMcpServerInstances = new WeakSet(); * { recordInputs: true, recordOutputs: false } * ); * - * const transport = new StreamableHTTPServerTransport(); + * const transport = new NodeStreamableHTTPServerTransport(); * await server.connect(transport); * ``` * diff --git a/packages/core/src/integrations/mcp-server/sessionExtraction.ts b/packages/core/src/integrations/mcp-server/sessionExtraction.ts index 7b7878a05644..e487dcad1873 100644 --- a/packages/core/src/integrations/mcp-server/sessionExtraction.ts +++ b/packages/core/src/integrations/mcp-server/sessionExtraction.ts @@ -21,9 +21,20 @@ import { getProtocolVersionForTransport, getSessionDataForTransport, } from './sessionManagement'; -import type { ExtraHandlerData, JsonRpcRequest, MCPTransport, PartyInfo, SessionData } from './types'; +import type { + ExtraHandlerData, + JsonRpcNotification, + JsonRpcRequest, + MCPTransport, + PartyInfo, + SessionData, +} from './types'; import { isValidContentItem } from './validation'; +const MCP_PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; +const MCP_CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo'; +const MCP_SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo'; + /** * Extracts and validates PartyInfo from an unknown object * @param obj - Unknown object that might contain party info @@ -62,6 +73,29 @@ export function extractSessionDataFromInitializeRequest(request: JsonRpcRequest) sessionData.clientInfo = extractPartyInfo(request.params.clientInfo); } } + + return sessionData; +} + +/** + * Extracts session data from an MCP 2026-07-28 request or notification envelope. + * @param message - JSON-RPC message containing modern request metadata + * @returns Session data extracted from the message + */ +export function extractSessionDataFromMessage(message: JsonRpcRequest | JsonRpcNotification): SessionData { + const sessionData: SessionData = {}; + if (isValidContentItem(message.params)) { + if (isValidContentItem(message.params._meta)) { + const meta = message.params._meta; + if (typeof meta[MCP_PROTOCOL_VERSION_META_KEY] === 'string') { + sessionData.protocolVersion = meta[MCP_PROTOCOL_VERSION_META_KEY]; + } + if (meta[MCP_CLIENT_INFO_META_KEY]) { + sessionData.clientInfo = extractPartyInfo(meta[MCP_CLIENT_INFO_META_KEY]); + } + } + } + return sessionData; } @@ -80,6 +114,22 @@ export function extractSessionDataFromInitializeResponse(result: unknown): Parti sessionData.serverInfo = extractPartyInfo(result.serverInfo); } } + + return sessionData; +} + +/** + * Extracts session data from MCP 2026-07-28 result metadata. + * @param result - JSON-RPC result containing modern response metadata + * @returns Session data extracted from the result + */ +export function extractSessionDataFromResponse(result: unknown): Partial { + const sessionData: Partial = {}; + if (isValidContentItem(result)) { + if (isValidContentItem(result._meta) && result._meta[MCP_SERVER_INFO_META_KEY]) { + sessionData.serverInfo = extractPartyInfo(result._meta[MCP_SERVER_INFO_META_KEY]); + } + } return sessionData; } diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index 8ae9902c2405..be292c6fb849 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -15,21 +15,17 @@ import { captureError } from './errorCapture'; import { buildClientAttributesFromInfo, extractSessionDataFromInitializeRequest, - extractSessionDataFromInitializeResponse, + extractSessionDataFromMessage, } from './sessionExtraction'; -import { - cleanupSessionDataForTransport, - storeSessionDataForTransport, - updateSessionDataForTransport, -} from './sessionManagement'; +import { cleanupSessionDataForTransport, updateSessionDataForTransport } from './sessionManagement'; import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; import type { ExtraHandlerData, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, isValidContentItem } from './validation'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse } from './validation'; /** * Wraps transport.onmessage to create spans for incoming messages. - * For "initialize" requests, extracts and stores client info and protocol version - * in the session data for the transport. + * 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 */ @@ -37,46 +33,52 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve if (transport.onmessage) { fill(transport, 'onmessage', originalOnMessage => { return function (this: MCPTransport, message: unknown, extra?: unknown) { - if (isJsonRpcRequest(message)) { - const isInitialize = message.method === 'initialize'; - let initSessionData: SessionData | undefined; - - if (isInitialize) { - try { - initSessionData = extractSessionDataFromInitializeRequest(message); - storeSessionDataForTransport(transport, initSessionData); - } catch { - // noop + const request = isJsonRpcRequest(message) ? message : undefined; + const notification = isJsonRpcNotification(message) ? message : undefined; + const jsonRpcMessage = request || notification; + let messageSessionData: SessionData | undefined; + + if (jsonRpcMessage) { + try { + messageSessionData = + request?.method === 'initialize' + ? extractSessionDataFromInitializeRequest(request) + : extractSessionDataFromMessage(jsonRpcMessage); + if (messageSessionData.protocolVersion || messageSessionData.clientInfo) { + updateSessionDataForTransport(transport, messageSessionData); } + } catch { + // noop } + } + if (request) { const isolationScope = getIsolationScope().clone(); return withIsolationScope(isolationScope, () => { - const spanConfig = buildMcpServerSpanConfig(message, transport, extra as ExtraHandlerData, options); + const spanConfig = buildMcpServerSpanConfig(request, transport, extra as ExtraHandlerData, options); const span = startInactiveSpan(spanConfig); - // For initialize requests, add client info directly to span (works even for stateless transports) - if (isInitialize && initSessionData) { + if (request.method === 'initialize' && messageSessionData) { span.setAttributes({ - ...buildClientAttributesFromInfo(initSessionData.clientInfo), - ...(initSessionData.protocolVersion && { - [MCP_PROTOCOL_VERSION_ATTRIBUTE]: initSessionData.protocolVersion, + ...buildClientAttributesFromInfo(messageSessionData.clientInfo), + ...(messageSessionData.protocolVersion && { + [MCP_PROTOCOL_VERSION_ATTRIBUTE]: messageSessionData.protocolVersion, }), }); } - storeSpanForRequest(transport, message.id, span, message.method); + storeSpanForRequest(transport, request.id, span, request.method); return withActiveSpan(span, () => { - return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, request, extra); }); }); } - if (isJsonRpcNotification(message)) { - return createMcpNotificationSpan(message, transport, extra as ExtraHandlerData, options, () => { - return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); + if (notification) { + return createMcpNotificationSpan(notification, transport, extra as ExtraHandlerData, options, () => { + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, notification, extra); }); } @@ -88,8 +90,8 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve /** * Wraps transport.send to handle outgoing messages and response correlation. - * For "initialize" responses, extracts and stores protocol version and server info - * in the session data for the transport. + * 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 */ @@ -111,17 +113,6 @@ export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpO captureJsonRpcErrorResponse(message.error); } - if (isValidContentItem(message.result)) { - if (message.result.protocolVersion || message.result.serverInfo) { - try { - const serverData = extractSessionDataFromInitializeResponse(message.result); - updateSessionDataForTransport(transport, serverData); - } catch { - // noop - } - } - } - completeSpanWithResults(transport, message.id, message.result, options, !!message.error); } } 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 a7b00bb3b1c6..4d1b6bc7fa52 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -5,6 +5,8 @@ import { buildTransportAttributes, extractSessionDataFromInitializeRequest, extractSessionDataFromInitializeResponse, + extractSessionDataFromMessage, + extractSessionDataFromResponse, getTransportTypes, } from '../../../../src/integrations/mcp-server/sessionExtraction'; import { @@ -498,6 +500,60 @@ describe('MCP Server Transport Instrumentation', () => { }); }); + it('extracts session data from a modern request envelope', () => { + const request = { + jsonrpc: '2.0' as const, + method: 'tools/call', + id: 'modern-tool-call', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { + name: 'modern-client', + title: 'Modern Client', + version: '2.0.0', + }, + }, + name: 'weather', + }, + }; + + const sessionData = extractSessionDataFromMessage(request); + + expect(sessionData).toEqual({ + protocolVersion: '2026-07-28', + clientInfo: { + name: 'modern-client', + title: 'Modern Client', + version: '2.0.0', + }, + }); + }); + + it('extracts server info from modern result metadata', () => { + const result = { + resultType: 'complete', + content: [], + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'modern-server', + title: 'Modern Server', + version: '2.0.0', + }, + }, + }; + + const sessionData = extractSessionDataFromResponse(result); + + expect(sessionData).toEqual({ + serverInfo: { + name: 'modern-server', + title: 'Modern Server', + version: '2.0.0', + }, + }); + }); + it('should store and retrieve session data', () => { const sessionData = { protocolVersion: '2025-06-18', @@ -652,7 +708,7 @@ describe('MCP Server Transport Instrumentation', () => { }); }); - describe('Initialize Span Attributes', () => { + describe('Protocol Metadata Span Attributes', () => { it('should add client info to initialize span on request', async () => { const mockMcpServer = createMockMcpServer(); const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); @@ -721,6 +777,164 @@ describe('MCP Server Transport Instrumentation', () => { ); expect(mockSpan.end).toHaveBeenCalled(); }); + + it('adds modern protocol and client info to request spans', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'modern-tool-call', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'modern-client', version: '2.0.0' }, + }, + name: 'weather', + }, + }, + { classification: { era: 'modern', revision: '2026-07-28' } }, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.client.name': 'modern-client', + 'mcp.client.version': '2.0.0', + 'mcp.protocol.version': '2026-07-28', + }), + }), + ); + }); + + it('ignores legacy session fields outside initialize messages', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValue(mockSpan as any); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'custom/process', + id: 'custom-request', + params: { + protocolVersion: 'application-version', + clientInfo: { name: 'application-client', version: '1.0.0' }, + }, + }, + {}, + ); + await transport.send?.({ + jsonrpc: '2.0', + id: 'custom-request', + result: { + protocolVersion: 'application-version', + serverInfo: { name: 'application-server', version: '1.0.0' }, + }, + }); + + expect(getSessionDataForTransport(transport)).toBeUndefined(); + expect(mockSpan.setAttributes).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalledOnce(); + }); + + it('adds modern protocol and client info to notification spans', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'modern-client', version: '2.0.0' }, + }, + }, + }, + { classification: { era: 'modern', revision: '2026-07-28' } }, + ); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'notifications/tools/list_changed', + forceTransaction: true, + attributes: { + 'mcp.transport': 'StreamableHTTPServerTransport', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.protocol.version': '2026-07-28', + 'mcp.client.name': 'modern-client', + 'mcp.client.version': '2.0.0', + 'mcp.method.name': 'notifications/tools/list_changed', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }, + expect.any(Function), + ); + }); + + it('adds modern server info to completed request spans', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValue(mockSpan as any); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'modern-tool-call', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'modern-client', version: '2.0.0' }, + }, + name: 'weather', + }, + }, + { classification: { era: 'modern', revision: '2026-07-28' } }, + ); + await transport.send?.({ + jsonrpc: '2.0', + id: 'modern-tool-call', + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'Sunny' }], + _meta: { + 'io.modelcontextprotocol/serverInfo': { name: 'modern-server', version: '2.0.0' }, + }, + }, + }); + + expect(mockSpan.setAttributes).toHaveBeenCalledWith({ + 'mcp.server.name': 'modern-server', + 'mcp.server.version': '2.0.0', + }); + expect(mockSpan.end).toHaveBeenCalledOnce(); + }); }); describe('Wrapper Options', () => {