diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/instrument.mjs new file mode 100644 index 000000000000..14efe4f2641f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + transport: loggingTransport, + dataCollection: { userInfo: false }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-common.cjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-common.cjs new file mode 100644 index 000000000000..8c415d27986f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-common.cjs @@ -0,0 +1,33 @@ +const assert = require('node:assert/strict'); + +module.exports = async function run(server, client, InMemoryTransport, z) { + server.registerTool('echo', { inputSchema: { message: z.string() } }, async ({ message }) => ({ + content: [{ type: 'text', text: message }], + structuredContent: { echoed: message }, + _meta: { privateContext: 'private-result-metadata' }, + requestState: 'private-request-state', + })); + server.registerTool('failure', {}, async () => ({ + content: [{ type: 'text', text: 'Tool failed' }], + isError: true, + })); + server.registerPrompt('greeting', { argsSchema: { Language: z.string() } }, async ({ Language }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello in ${Language}` } }], + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + const echo = await client.callTool({ name: 'echo', arguments: { message: 'Hello' } }); + assert.deepEqual(echo.structuredContent, { echoed: 'Hello' }); + assert.equal(echo._meta.privateContext, 'private-result-metadata'); + const failure = await client.callTool({ name: 'failure', arguments: {} }); + assert.equal(failure.isError, true); + const prompt = await client.getPrompt({ name: 'greeting', arguments: { Language: 'English' } }); + assert.equal(prompt.messages[0].content.text, 'Hello in English'); + const tools = await client.listTools(); + assert.equal(tools.tools.length, 2); + + await client.close(); + await server.close(); +}; diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-v1.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-v1.mjs new file mode 100644 index 000000000000..160d99fe4980 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-v1.mjs @@ -0,0 +1,14 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { wrapMcpServerWithSentry } from '@sentry/node'; +import { z } from 'zod'; +import run from './scenario-common.cjs'; + +const server = wrapMcpServerWithSentry(new McpServer({ name: 'test-server', version: '1.0.0' }), { + recordInputs: process.env.RECORD_CONTENT === 'true', + recordOutputs: process.env.RECORD_CONTENT === 'true', +}); +const client = new Client({ name: 'test-client', version: '1.0.0' }); + +run(server, client, InMemoryTransport, z); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-v2.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-v2.mjs new file mode 100644 index 000000000000..032bcc8a093d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/scenario-v2.mjs @@ -0,0 +1,13 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; +import { wrapMcpServerWithSentry } from '@sentry/node'; +import { z } from 'zod/v4'; +import run from './scenario-common.cjs'; + +const server = wrapMcpServerWithSentry(new McpServer({ name: 'test-server', version: '1.0.0' }), { + recordInputs: process.env.RECORD_CONTENT === 'true', + recordOutputs: process.env.RECORD_CONTENT === 'true', +}); +const client = new Client({ name: 'test-client', version: '1.0.0' }); + +run(server, client, InMemoryTransport, z); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/test.ts new file mode 100644 index 000000000000..aac10a52166b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/semantic-attributes/test.ts @@ -0,0 +1,100 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +function assertAttributes(container: SerializedStreamedSpanContainer, recordContent: boolean): void { + const requests = container.items.filter( + span => + span.attributes['sentry.op']?.value === 'mcp.server' && + ['tools/call', 'prompts/get', 'tools/list'].includes(String(span.attributes['mcp.method.name']?.value)), + ); + expect(requests).toHaveLength(4); + for (const request of requests) { + expect(request.attributes['jsonrpc.request.id']).toEqual({ type: 'string', value: expect.any(String) }); + expect(request.attributes['jsonrpc.request.id']).toEqual(request.attributes['mcp.request.id']); + } + + const tool = requests.find(span => span.attributes['gen_ai.tool.name']?.value === 'echo'); + expect(tool).toBeDefined(); + expect(tool?.attributes['mcp.tool.name']?.value).toBe('echo'); + expect(tool?.attributes['gen_ai.operation.name']?.value).toBe('execute_tool'); + expect(tool?.attributes['mcp.tool.result.content_count']?.value).toBe(1); + expect(tool?.attributes['gen_ai.prompt.name']).toBeUndefined(); + + const failure = requests.find(span => span.attributes['gen_ai.tool.name']?.value === 'failure'); + expect(failure).toBeDefined(); + expect(failure?.attributes['mcp.tool.name']?.value).toBe('failure'); + expect(failure?.attributes['gen_ai.operation.name']?.value).toBe('execute_tool'); + expect(failure?.attributes['mcp.tool.result.is_error']?.value).toBe(true); + expect(failure?.attributes['gen_ai.tool.call.result']).toBeUndefined(); + expect(failure?.status).toBe('error'); + + const prompt = requests.find(span => span.attributes['mcp.method.name']?.value === 'prompts/get'); + expect(prompt?.attributes['gen_ai.prompt.name']?.value).toBe('greeting'); + expect(prompt?.attributes['mcp.prompt.name']?.value).toBe('greeting'); + expect(prompt?.attributes['gen_ai.operation.name']).toBeUndefined(); + expect(prompt?.attributes['gen_ai.tool.name']).toBeUndefined(); + expect(prompt?.attributes['gen_ai.tool.call.arguments']).toBeUndefined(); + expect(prompt?.attributes['gen_ai.tool.call.result']).toBeUndefined(); + expect(prompt?.attributes['gen_ai.prompt.variable.language']).toBeUndefined(); + + const list = requests.find(span => span.attributes['mcp.method.name']?.value === 'tools/list'); + expect(list?.attributes['gen_ai.operation.name']).toBeUndefined(); + expect(list?.attributes['gen_ai.tool.name']).toBeUndefined(); + expect(list?.attributes['gen_ai.tool.call.arguments']).toBeUndefined(); + expect(list?.attributes['gen_ai.tool.call.result']).toBeUndefined(); + + if (recordContent) { + expect(JSON.parse(String(tool?.attributes['gen_ai.tool.call.arguments']?.value))).toEqual({ message: 'Hello' }); + expect(JSON.parse(String(tool?.attributes['gen_ai.tool.call.result']?.value))).toEqual({ + content: [{ type: 'text', text: 'Hello' }], + structuredContent: { echoed: 'Hello' }, + }); + expect(tool?.attributes['mcp.request.argument.message']?.value).toBe('"Hello"'); + expect(tool?.attributes['mcp.tool.result.content']?.value).toBe('Hello'); + expect(prompt?.attributes['gen_ai.prompt.variable.Language']).toEqual({ type: 'string', value: 'English' }); + expect(prompt?.attributes['mcp.request.argument.language']?.value).toBe('"English"'); + } else { + for (const request of requests) { + expect(request.attributes['gen_ai.tool.call.arguments']).toBeUndefined(); + expect(request.attributes['gen_ai.tool.call.result']).toBeUndefined(); + expect(Object.keys(request.attributes).filter(key => key.startsWith('gen_ai.prompt.variable.'))).toEqual([]); + expect(Object.keys(request.attributes).filter(key => key.startsWith('mcp.request.argument.'))).toEqual([]); + } + expect(tool?.attributes['mcp.tool.result.content']).toBeUndefined(); + expect(prompt?.attributes['mcp.prompt.result.message_content']).toBeUndefined(); + } + + expect(JSON.stringify(container)).not.toContain('private-result-metadata'); + expect(JSON.stringify(container)).not.toContain('private-request-state'); +} + +describe('MCP semantic attributes', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + for (const version of ['v1', 'v2']) { + createEsmAndCjsTests( + __dirname, + `scenario-${version}.mjs`, + 'instrument.mjs', + (createTestRunner, test) => { + for (const recordContent of [true, false]) { + test(`${version} emits canonical and legacy attributes with content capture ${recordContent ? 'enabled' : 'disabled'}`, async () => { + await createTestRunner() + .withEnv({ RECORD_CONTENT: String(recordContent) }) + .unordered() + .expect({ span: container => assertAttributes(container, recordContent) }) + .start() + .completed(); + }); + } + }, + { + copyPaths: ['scenario-common.cjs'], + ...(version === 'v1' ? { additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } } : {}), + }, + ); + } +}); diff --git a/docs/mcp-semantic-attributes.md b/docs/mcp-semantic-attributes.md new file mode 100644 index 000000000000..527b7d4005f9 --- /dev/null +++ b/docs/mcp-semantic-attributes.md @@ -0,0 +1,54 @@ +# MCP semantic attributes + +MCP server instrumentation uses the shared constants from `@sentry/conventions/attributes`. +The attribute mapping follows the [OpenTelemetry MCP conventions at e07f4eb](https://github.com/open-telemetry/semantic-conventions-genai/blob/e07f4ebacb08f56db8c4c882d117720333fbca04/docs/gen-ai/mcp.md), +which are in Development. This migration covers attributes, not every recommendation in that document. + +## Canonical and legacy attributes + +| Canonical attribute | Source | Legacy attribute retained | +| ------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `jsonrpc.request.id` | Request ID, converted to a string; absent/null IDs are omitted | `mcp.request.id` | +| `gen_ai.tool.name` | Tool name for `tools/call` | `mcp.tool.name` | +| `gen_ai.prompt.name` | Prompt name for `prompts/get` | `mcp.prompt.name` | +| `gen_ai.operation.name` | `execute_tool`, only for `tools/call` | No equivalent | +| `gen_ai.prompt.variable.` | Prompt arguments, preserving key case and string values | `mcp.request.argument.` with its existing lowercase keys and JSON encoding | +| `gen_ai.tool.call.arguments` | JSON-serialized tool arguments | Existing individual `mcp.request.argument.` attributes | +| `gen_ai.tool.call.result` | JSON-serialized successful tool output | Existing `mcp.tool.result.*` attributes | + +Both forms are emitted during the transition so existing queries continue to work. +Their values are not always interchangeable: the canonical content attributes represent structured +payloads, while the legacy attributes preserve their existing flattened representation. +Removing legacy attributes requires a separate migration. + +## Content capture + +Canonical inputs and outputs use the same resolved `recordInputs` and `recordOutputs` options as +legacy content, including the existing `dataCollection.genAI` setting. This migration does not +change option defaults or precedence. Protocol IDs and tool/prompt names do not require content capture. + +Tool results include only `content` and `structuredContent`, excluding response-level `_meta`, +continuation state, and other protocol fields. Protocol `_meta` is also omitted from content blocks +and embedded resources; similarly named keys in user-provided arguments or `structuredContent` are preserved. +Following the [MCP tool result specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools), +`structuredContent` can be any JSON value. Results with `isError: true` or a `resultType` other than +`complete` are omitted from the canonical result attribute. An absent `resultType` is treated as a +legacy complete result. Legacy error and content metadata remain unchanged. + +The new tool payload attributes are omitted if serialization fails or the serialized value exceeds +20,000 characters. Prompt variables share a 20,000-character budget for argument keys and values; +variables exceeding that budget are omitted. These are Sentry capture limits, not OpenTelemetry +requirements. JSON payloads are omitted as a whole instead of being truncated into invalid JSON. +The limits do not change legacy attribute behavior. + +## Sentry-specific metadata + +`mcp.transport` records the transport implementation name, including a custom constructor name. +It is distinct from OpenTelemetry's `network.transport`. Likewise, `mcp.resource.protocol` records +the resource URI scheme, which need not match the network protocol used to communicate with the server. +These Sentry attributes remain separate from their network counterparts. + +MCP client/server implementation identity, registered OAuth client identity, progress metadata, +and Sentry span operations are Sentry extensions rather than attributes defined by the OpenTelemetry +MCP registry. Shared conventions record that provenance; using an OpenTelemetry-compatible span name +does not make the Sentry span operation an OpenTelemetry convention. diff --git a/packages/core/src/integrations/mcp-server/attributeExtraction.ts b/packages/core/src/integrations/mcp-server/attributeExtraction.ts index fff8d76dc46f..b1308bd676a4 100644 --- a/packages/core/src/integrations/mcp-server/attributeExtraction.ts +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -3,6 +3,7 @@ */ import { + JSONRPC_REQUEST_ID, MCP_CANCELLED_REASON, MCP_CANCELLED_REQUEST_ID, MCP_LIFECYCLE_PHASE, @@ -129,6 +130,7 @@ export function buildTypeSpecificAttributes( return { // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy request ID attribute for existing consumers. ...(request.id !== undefined && { [MCP_REQUEST_ID]: String(request.id) }), + ...(request.id != null && { [JSONRPC_REQUEST_ID]: String(request.id) }), ...targetInfo.attributes, ...(recordInputs ? getRequestArguments(request.method, params || {}) : {}), }; diff --git a/packages/core/src/integrations/mcp-server/methodConfig.ts b/packages/core/src/integrations/mcp-server/methodConfig.ts index 1e885187b83e..710436487f77 100644 --- a/packages/core/src/integrations/mcp-server/methodConfig.ts +++ b/packages/core/src/integrations/mcp-server/methodConfig.ts @@ -3,6 +3,11 @@ */ import { + GEN_AI_OPERATION_NAME, + GEN_AI_PROMPT_NAME, + GEN_AI_PROMPT_VARIABLE_KEY_BASE, + GEN_AI_TOOL_CALL_ARGUMENTS, + GEN_AI_TOOL_NAME, MCP_PROMPT_NAME, MCP_REQUEST_ARGUMENT_KEY_BASE, MCP_REQUEST_ARGUMENT_NAME, @@ -11,6 +16,7 @@ import { MCP_TOOL_NAME, } from '@sentry/conventions/attributes'; import { isObjectLike } from '../../utils/is'; +import { MAX_MCP_CONTENT_LENGTH, serializeMcpContent } from './serialization'; import type { MethodConfig } from './types'; /** @@ -77,7 +83,14 @@ export function extractTargetInfo( return { target, targetIsLowCardinality: !!config.targetIsLowCardinality, - attributes: target && config.targetAttribute ? { [config.targetAttribute]: target } : {}, + attributes: { + ...(target && config.targetAttribute ? { [config.targetAttribute]: target } : {}), + ...(method === 'tools/call' && { + [GEN_AI_OPERATION_NAME]: 'execute_tool', + ...(target && { [GEN_AI_TOOL_NAME]: target }), + }), + ...(method === 'prompts/get' && target && { [GEN_AI_PROMPT_NAME]: target }), + }, }; } @@ -85,7 +98,7 @@ export function extractTargetInfo( * Extracts request arguments based on method type * @param method - MCP method name * @param params - Method parameters - * @returns Arguments as span attributes with mcp.request.argument prefix + * @returns Canonical input attributes alongside legacy request arguments */ export function getRequestArguments(method: string, params: Record): Record { const args: Record = {}; @@ -98,8 +111,21 @@ export function getRequestArguments(method: string, params: Record)) { args[`${MCP_REQUEST_ARGUMENT_KEY_BASE}.${key.toLowerCase()}`] = JSON.stringify(value); + if (method === 'prompts/get' && typeof value === 'string') { + promptContentLength += key.length + value.length; + if (promptContentLength <= MAX_MCP_CONTENT_LENGTH) { + args[`${GEN_AI_PROMPT_VARIABLE_KEY_BASE}.${key}`] = value; + } + } } } } diff --git a/packages/core/src/integrations/mcp-server/resultExtraction.ts b/packages/core/src/integrations/mcp-server/resultExtraction.ts index 42fd9d8740c1..65e94fa59aa9 100644 --- a/packages/core/src/integrations/mcp-server/resultExtraction.ts +++ b/packages/core/src/integrations/mcp-server/resultExtraction.ts @@ -5,6 +5,7 @@ */ import { + GEN_AI_TOOL_CALL_RESULT, MCP_PROMPT_RESULT_DESCRIPTION, MCP_PROMPT_RESULT_KEY_CONTENT, MCP_PROMPT_RESULT_KEY_ROLE, @@ -30,6 +31,7 @@ import { MCP_TOOL_RESULT_RESOURCE_URI, MCP_TOOL_RESULT_URI, } from '@sentry/conventions/attributes'; +import { serializeMcpContent } from './serialization'; import { isValidContentItem } from './validation'; /* oxlint-disable typescript/no-deprecated -- Preserve the legacy result attributes for existing consumers. */ @@ -102,6 +104,25 @@ function buildAllContentItemAttributes( } /* oxlint-enable typescript/no-deprecated */ +/** + * Omit protocol metadata without stripping similarly named fields from user data. + * @param item - A tool result content block + * @returns Content with protocol metadata removed from the block and embedded resource + */ +function removeContentMetadata(item: unknown): unknown { + if (!isValidContentItem(item)) { + return item; + } + const content = { ...item }; + delete content._meta; + if (content.type === 'resource' && isValidContentItem(content.resource)) { + const resource = { ...content.resource }; + delete resource._meta; + content.resource = resource; + } + return content; +} + /** * Extract tool result attributes for span instrumentation * @param result - Tool execution result @@ -123,6 +144,25 @@ export function extractToolResultAttributes( attributes[MCP_TOOL_RESULT_IS_ERROR] = result.isError; } + if (recordOutputs && result.isError !== true) { + try { + if (result.resultType !== undefined && result.resultType !== 'complete') { + return attributes; + } + // Select tool output only: response _meta and opaque continuation state are not content. + const output = { + ...(Array.isArray(result.content) && { content: result.content.map(removeContentMetadata) }), + ...(result.structuredContent !== undefined && { structuredContent: result.structuredContent }), + }; + const serialized = Object.keys(output).length > 0 ? serializeMcpContent(output) : undefined; + if (serialized !== undefined) { + attributes[GEN_AI_TOOL_CALL_RESULT] = serialized; + } + } catch { + // Optional content extraction must not interfere with the tool response. + } + } + return attributes; } diff --git a/packages/core/src/integrations/mcp-server/serialization.ts b/packages/core/src/integrations/mcp-server/serialization.ts new file mode 100644 index 000000000000..0e9597cdff6c --- /dev/null +++ b/packages/core/src/integrations/mcp-server/serialization.ts @@ -0,0 +1,14 @@ +import { stringify } from '../../utils/string'; + +// Bound the additional content emitted alongside legacy attributes during migration. +export const MAX_MCP_CONTENT_LENGTH = 20_000; + +/** + * Serialize opt-in MCP content without emitting partial JSON or interrupting dispatch. + * @param value - Content selected from the MCP request or result + * @returns JSON within the capture limit, or undefined when unavailable + */ +export function serializeMcpContent(value: unknown): string | undefined { + const serialized = stringify(value, ''); + return serialized && serialized.length <= MAX_MCP_CONTENT_LENGTH ? serialized : undefined; +} diff --git a/packages/core/test/lib/integrations/mcp-server/canonicalContent.test.ts b/packages/core/test/lib/integrations/mcp-server/canonicalContent.test.ts new file mode 100644 index 000000000000..0949b2feeffb --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/canonicalContent.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { getRequestArguments } from '../../../../src/integrations/mcp-server/methodConfig'; +import { extractToolResultAttributes } from '../../../../src/integrations/mcp-server/resultExtraction'; +import { MAX_MCP_CONTENT_LENGTH, serializeMcpContent } from '../../../../src/integrations/mcp-server/serialization'; + +describe('MCP canonical content attributes', () => { + it('bounds canonical prompt variables together without changing legacy arguments', () => { + const Language = 'x'.repeat(MAX_MCP_CONTENT_LENGTH - 'Language'.length); + + expect(getRequestArguments('prompts/get', { arguments: { Language, Topic: 'weather' } })).toEqual({ + 'gen_ai.prompt.variable.Language': Language, + 'mcp.request.argument.language': JSON.stringify(Language), + 'mcp.request.argument.topic': JSON.stringify('weather'), + }); + }); + + it('serializes tool arguments as one JSON object while preserving legacy argument values', () => { + const args = { query: 'foo "bar"', options: { limit: 2 }, tags: ['first', 'second'] }; + + expect(getRequestArguments('tools/call', { name: 'search', arguments: args })).toEqual({ + 'gen_ai.tool.call.arguments': JSON.stringify(args), + 'mcp.request.argument.query': JSON.stringify(args.query), + 'mcp.request.argument.options': JSON.stringify(args.options), + 'mcp.request.argument.tags': JSON.stringify(args.tags), + }); + }); + + it.each([undefined, 'complete'])('selects successful tool output when resultType is %s', resultType => { + const content = [{ type: 'text', text: 'Hello' }]; + const structuredContent = { greeting: 'Hello', count: 1 }; + + expect( + extractToolResultAttributes( + { + content, + structuredContent, + resultType, + isError: false, + _meta: { private: 'metadata' }, + requestState: 'opaque', + }, + true, + ), + ).toEqual({ + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + 'mcp.tool.result.content': 'Hello', + 'mcp.tool.result.is_error': false, + 'gen_ai.tool.call.result': JSON.stringify({ content, structuredContent }), + }); + }); + + it.each([{ isError: true }, { resultType: 'input_required', requestState: 'opaque' }, { resultType: 'unknown' }])( + 'omits the canonical output for unsuccessful or incomplete results: %j', + result => { + const attributes = extractToolResultAttributes( + { ...result, content: [{ type: 'text', text: 'Need more input' }] }, + true, + ); + + expect(attributes).not.toHaveProperty('gen_ai.tool.call.result'); + expect(attributes['mcp.tool.result.content']).toBe('Need more input'); + }, + ); + + it.each([42, 'Hello', [1, 2], null])('preserves structured tool output containing %j', structuredContent => { + expect(extractToolResultAttributes({ structuredContent }, true)).toEqual({ + 'gen_ai.tool.call.result': JSON.stringify({ structuredContent }), + }); + }); + + it('removes metadata at protocol boundaries while preserving structured user data and the original result', () => { + const result = { + content: [ + { type: 'text', text: 'Hello', _meta: { private: 'block' } }, + { + type: 'resource', + resource: { uri: 'file:///example.txt', text: 'Resource', _meta: { private: 'resource' } }, + _meta: { private: 'embedded block' }, + }, + ], + structuredContent: { _meta: { userField: 'keep' }, nested: { _meta: 'also keep' } }, + }; + const original = JSON.stringify(result); + const attributes = extractToolResultAttributes(result, true); + + expect(attributes['gen_ai.tool.call.result']).toBe( + JSON.stringify({ + content: [ + { type: 'text', text: 'Hello' }, + { type: 'resource', resource: { uri: 'file:///example.txt', text: 'Resource' } }, + ], + structuredContent: result.structuredContent, + }), + ); + expect(JSON.stringify(result)).toBe(original); + }); + + it.each(['resultType', 'structuredContent'])('ignores an unreadable %s without losing legacy metadata', key => { + const result = { content: [{ type: 'text', text: 'Hello' }] }; + Object.defineProperty(result, key, { + get() { + throw new Error('Cannot read result'); + }, + }); + + expect(extractToolResultAttributes(result, true)).toEqual({ + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + 'mcp.tool.result.content': 'Hello', + }); + }); + + it('keeps metadata but omits both output formats when output capture is disabled', () => { + expect( + extractToolResultAttributes( + { content: [{ type: 'text', text: 'Private output' }], structuredContent: { secret: true }, isError: false }, + false, + ), + ).toEqual({ + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + 'mcp.tool.result.is_error': false, + }); + }); + + it('omits oversized JSON instead of emitting a truncated document', () => { + const text = 'x'.repeat(MAX_MCP_CONTENT_LENGTH - JSON.stringify({ text: '' }).length); + const serialized = serializeMcpContent({ text }); + + expect(serialized).toHaveLength(MAX_MCP_CONTENT_LENGTH); + expect(JSON.parse(serialized!)).toEqual({ text }); + expect(serializeMcpContent({ text: `${text}x` })).toBeUndefined(); + }); + + it('omits JSON that cannot be serialized', () => { + const circular: { value?: unknown } = {}; + circular.value = circular; + const throwsOnSerialization = { + toJSON() { + throw new Error('Cannot serialize'); + }, + }; + + expect(serializeMcpContent(circular)).toBeUndefined(); + expect(serializeMcpContent(throwsOnSerialization)).toBeUndefined(); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts index 3371ebed35d7..6d8df455cf28 100644 --- a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts @@ -101,12 +101,16 @@ describe('MCP Server Capture Policy', () => { attributes: { 'mcp.method.name': 'tools/call', 'mcp.tool.name': name, + 'gen_ai.tool.name': name, + 'gen_ai.operation.name': 'execute_tool', 'mcp.request.id': id, + 'jsonrpc.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) }), + ...(location !== undefined && { 'gen_ai.tool.call.arguments': JSON.stringify({ location }) }), 'sentry.op': 'mcp.server', 'sentry.origin': 'auto.function.mcp_server', 'sentry.segment.name.source': 'route', @@ -120,6 +124,9 @@ describe('MCP Server Capture Policy', () => { 'mcp.tool.result.content_count': 1, 'mcp.tool.result.content_type': 'text', ...(content !== undefined && { 'mcp.tool.result.content': content }), + ...(content !== undefined && { + 'gen_ai.tool.call.result': JSON.stringify({ content: [{ type: 'text', text: content }] }), + }), 'mcp.tool.result.is_error': false, }); } diff --git a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts index 54c3d630ed6c..403b2dc1148c 100644 --- a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -51,7 +51,10 @@ describe('MCP Server Semantic Conventions', () => { attributes: { 'mcp.method.name': 'tools/call', 'mcp.tool.name': 'get-weather', + 'gen_ai.tool.name': 'get-weather', + 'gen_ai.operation.name': 'execute_tool', 'mcp.request.id': 'req-1', + 'jsonrpc.request.id': 'req-1', 'mcp.session.id': 'test-session-123', 'client.address': '192.168.1.100', 'client.port': 54321, @@ -59,6 +62,7 @@ describe('MCP Server Semantic Conventions', () => { 'network.transport': 'tcp', 'network.protocol.version': '2.0', 'mcp.request.argument.location': '"Seattle, WA"', + 'gen_ai.tool.call.arguments': '{"location":"Seattle, WA"}', 'sentry.op': 'mcp.server', 'sentry.origin': 'auto.function.mcp_server', 'sentry.segment.name.source': 'route', @@ -85,6 +89,7 @@ describe('MCP Server Semantic Conventions', () => { 'mcp.method.name': 'resources/read', 'mcp.resource.uri': 'file:///docs/api.md', 'mcp.request.id': 'req-2', + 'jsonrpc.request.id': 'req-2', 'mcp.session.id': 'test-session-123', 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', @@ -115,7 +120,9 @@ describe('MCP Server Semantic Conventions', () => { attributes: { 'mcp.method.name': 'prompts/get', 'mcp.prompt.name': 'analyze-code', + 'gen_ai.prompt.name': 'analyze-code', 'mcp.request.id': 'req-3', + 'jsonrpc.request.id': 'req-3', 'mcp.session.id': 'test-session-123', 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', @@ -164,6 +171,57 @@ describe('MCP Server Semantic Conventions', () => { expect(attributes).not.toHaveProperty('mcp.request.id'); }); + it('preserves a numeric zero request ID in canonical and legacy attributes', async () => { + await wrappedMcpServer.connect(mockTransport); + + mockTransport.onmessage?.({ jsonrpc: '2.0', method: 'tools/list', id: 0 }, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'jsonrpc.request.id': '0', 'mcp.request.id': '0' }), + }), + ); + }); + + it.each(['tools/list', 'prompts/get', 'resources/read'])('does not label %s as tool execution', async method => { + await wrappedMcpServer.connect(mockTransport); + + mockTransport.onmessage?.({ jsonrpc: '2.0', method, id: 'non-tool', params: { name: 'example' } }, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ 'gen_ai.operation.name': expect.anything() }), + }), + ); + }); + + it('keeps prompt variable names and strings intact alongside legacy arguments', async () => { + await wrappedMcpServer.connect(mockTransport); + + mockTransport.onmessage?.( + { + jsonrpc: '2.0', + method: 'prompts/get', + id: 'prompt-variables', + params: { name: 'greeting', arguments: { Language: 'English', language: 'Spanish' } }, + }, + {}, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'gen_ai.prompt.name': 'greeting', + 'mcp.prompt.name': 'greeting', + 'gen_ai.prompt.variable.Language': 'English', + 'gen_ai.prompt.variable.language': 'Spanish', + 'mcp.request.argument.language': '"Spanish"', + }), + }), + ); + }); + it('should create spans for list operations without target in name', async () => { await wrappedMcpServer.connect(mockTransport); 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 6428b777bef3..4b22e8826ef1 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -148,7 +148,10 @@ describe('MCP Server Transport Instrumentation', () => { attributes: { 'mcp.method.name': 'tools/call', 'mcp.tool.name': 'get-weather', + 'gen_ai.tool.name': 'get-weather', + 'gen_ai.operation.name': 'execute_tool', 'mcp.request.id': 'queued-request', + 'jsonrpc.request.id': 'queued-request', 'mcp.transport': 'InMemoryTransport', 'network.transport': 'unknown', 'network.protocol.version': '2.0', @@ -434,12 +437,16 @@ describe('MCP Server Transport Instrumentation', () => { attributes: { 'mcp.method.name': 'tools/call', 'mcp.tool.name': 'process-file', + 'gen_ai.tool.name': 'process-file', + 'gen_ai.operation.name': 'execute_tool', 'mcp.request.id': 'req-stdio-1', + 'jsonrpc.request.id': 'req-stdio-1', 'mcp.session.id': 'stdio-session-456', 'mcp.transport': 'StdioServerTransport', 'network.transport': 'pipe', // Should be pipe, not tcp 'network.protocol.version': '2.0', 'mcp.request.argument.path': '"/tmp/data.txt"', + 'gen_ai.tool.call.arguments': '{"path":"/tmp/data.txt"}', 'sentry.op': 'mcp.server', 'sentry.origin': 'auto.function.mcp_server', 'sentry.segment.name.source': 'route',