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,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 },
});
Original file line number Diff line number Diff line change
@@ -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();
};
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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' } } : {}),
},
);
}
});
54 changes: 54 additions & 0 deletions docs/mcp-semantic-attributes.md
Original file line number Diff line number Diff line change
@@ -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.<key>` | Prompt arguments, preserving key case and string values | `mcp.request.argument.<key>` with its existing lowercase keys and JSON encoding |
| `gen_ai.tool.call.arguments` | JSON-serialized tool arguments | Existing individual `mcp.request.argument.<key>` 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.
62 changes: 38 additions & 24 deletions packages/core/src/integrations/mcp-server/attributeExtraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,26 @@
* Core attribute extraction and building functions for MCP server instrumentation
*/

import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url';
import {
MCP_LOGGING_DATA_TYPE_ATTRIBUTE,
MCP_LOGGING_LEVEL_ATTRIBUTE,
MCP_LOGGING_LOGGER_ATTRIBUTE,
MCP_LOGGING_MESSAGE_ATTRIBUTE,
MCP_REQUEST_ID_ATTRIBUTE,
MCP_RESOURCE_URI_ATTRIBUTE,
} from './attributes';
JSONRPC_REQUEST_ID,
MCP_CANCELLED_REASON,
MCP_CANCELLED_REQUEST_ID,
MCP_LIFECYCLE_PHASE,
MCP_LOGGING_DATA_TYPE,
MCP_LOGGING_LEVEL,
MCP_LOGGING_LOGGER,
MCP_LOGGING_MESSAGE,
MCP_PROGRESS_CURRENT,
MCP_PROGRESS_MESSAGE,
MCP_PROGRESS_PERCENTAGE,
MCP_PROGRESS_TOKEN,
MCP_PROGRESS_TOTAL,
MCP_PROTOCOL_READY,
MCP_REQUEST_ID,
MCP_RESOURCE_PROTOCOL,
MCP_RESOURCE_URI,
} from '@sentry/conventions/attributes';
import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url';
import { extractTargetInfo, getRequestArguments } from './methodConfig';
import type { JsonRpcNotification, JsonRpcRequest, McpSpanType } from './types';

Expand Down Expand Up @@ -39,59 +50,60 @@ export function getNotificationAttributes(
switch (method) {
case 'notifications/cancelled':
if (params?.requestId) {
attributes['mcp.cancelled.request_id'] = String(params.requestId);
attributes[MCP_CANCELLED_REQUEST_ID] = String(params.requestId);
}
if (params?.reason) {
attributes['mcp.cancelled.reason'] = String(params.reason);
attributes[MCP_CANCELLED_REASON] = String(params.reason);
}
break;

case 'notifications/message':
if (params?.level) {
attributes[MCP_LOGGING_LEVEL_ATTRIBUTE] = String(params.level);
attributes[MCP_LOGGING_LEVEL] = String(params.level);
}
if (params?.logger) {
attributes[MCP_LOGGING_LOGGER_ATTRIBUTE] = String(params.logger);
attributes[MCP_LOGGING_LOGGER] = String(params.logger);
}
if (params?.data !== undefined) {
attributes[MCP_LOGGING_DATA_TYPE_ATTRIBUTE] = typeof params.data;
attributes[MCP_LOGGING_DATA_TYPE] = typeof params.data;
if (recordInputs) {
attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = formatLoggingData(params.data);
attributes[MCP_LOGGING_MESSAGE] = formatLoggingData(params.data);
}
}
break;

case 'notifications/progress':
if (params?.progressToken) {
attributes['mcp.progress.token'] = String(params.progressToken);
attributes[MCP_PROGRESS_TOKEN] = String(params.progressToken);
}
if (typeof params?.progress === 'number') {
attributes['mcp.progress.current'] = params.progress;
attributes[MCP_PROGRESS_CURRENT] = params.progress;
}
if (typeof params?.total === 'number') {
attributes['mcp.progress.total'] = params.total;
attributes[MCP_PROGRESS_TOTAL] = params.total;
if (typeof params?.progress === 'number') {
attributes['mcp.progress.percentage'] = (params.progress / params.total) * 100;
attributes[MCP_PROGRESS_PERCENTAGE] = (params.progress / params.total) * 100;
}
}
if (params?.message) {
attributes['mcp.progress.message'] = String(params.message);
attributes[MCP_PROGRESS_MESSAGE] = String(params.message);
}
break;

case 'notifications/resources/updated':
if (params?.uri) {
attributes[MCP_RESOURCE_URI_ATTRIBUTE] = String(params.uri);
attributes[MCP_RESOURCE_URI] = String(params.uri);
const urlObject = parseStringToURLObject(String(params.uri));
if (urlObject && !isURLObjectRelative(urlObject)) {
attributes['mcp.resource.protocol'] = urlObject.protocol.replace(':', '');
// oxlint-disable-next-line typescript/no-deprecated -- Keep the resource URI scheme distinct from the network protocol.
attributes[MCP_RESOURCE_PROTOCOL] = urlObject.protocol.replace(':', '');
}
}
break;

case 'notifications/initialized':
attributes['mcp.lifecycle.phase'] = 'initialization_complete';
attributes['mcp.protocol.ready'] = 1;
attributes[MCP_LIFECYCLE_PHASE] = 'initialization_complete';
attributes[MCP_PROTOCOL_READY] = 1;
break;
}

Expand All @@ -117,7 +129,9 @@ export function buildTypeSpecificAttributes(
const targetInfo = extractTargetInfo(request.method, params || {});

return {
...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }),
// 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 || {}) : {}),
};
Expand Down
Loading
Loading