Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,14 @@ export function getNotificationAttributes(
* @param type - Span type (request or notification)
* @param message - JSON-RPC message
* @param params - Optional parameters for attribute extraction
* @param recordInputs - Whether to capture input arguments in spans
* @returns Type-specific attributes for span instrumentation
*/
export function buildTypeSpecificAttributes(
type: McpSpanType,
message: JsonRpcRequest | JsonRpcNotification,
params?: Record<string, unknown>,
recordInputs?: boolean,
): Record<string, string | number> {
if (type === 'request') {
const request = message as JsonRpcRequest;
Expand All @@ -109,7 +111,7 @@ export function buildTypeSpecificAttributes(
return {
...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }),
...targetInfo.attributes,
...getRequestArguments(request.method, params || {}),
...(recordInputs ? getRequestArguments(request.method, params || {}) : {}),
};
}

Expand Down
24 changes: 10 additions & 14 deletions packages/core/src/integrations/mcp-server/correlation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@
* request ID collisions between different MCP sessions.
*/

import { getClient } from '../../currentScopes';
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types-hoist/span';
import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes';
import { filterMcpPiiFromSpanData } from './piiFiltering';
import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction';
import { buildServerAttributesFromInfo, extractSessionDataFromInitializeResponse } from './sessionExtraction';
import type { MCPTransport, RequestId, RequestSpanMapValue } from './types';
import type { MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types';

/**
* Transport-scoped correlation system that prevents collisions between different MCP sessions
Expand Down Expand Up @@ -57,8 +55,14 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI
* @param transport - MCP transport instance
* @param requestId - Request identifier
* @param result - Execution result for attribute extraction
* @param options - Resolved MCP options
*/
export function completeSpanWithResults(transport: MCPTransport, requestId: RequestId, result: unknown): void {
export function completeSpanWithResults(
transport: MCPTransport,
requestId: RequestId,
result: unknown,
options: ResolvedMcpOptions,
): void {
const spanMap = getOrCreateSpanMap(transport);
const spanData = spanMap.get(requestId);
if (spanData) {
Expand All @@ -77,18 +81,10 @@ export function completeSpanWithResults(transport: MCPTransport, requestId: Requ

span.setAttributes(initAttributes);
} else if (method === 'tools/call') {
const rawToolAttributes = extractToolResultAttributes(result);
const client = getClient();
const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii);
const toolAttributes = filterMcpPiiFromSpanData(rawToolAttributes, sendDefaultPii);

const toolAttributes = extractToolResultAttributes(result, options.recordOutputs);
span.setAttributes(toolAttributes);
} else if (method === 'prompts/get') {
const rawPromptAttributes = extractPromptResultAttributes(result);
const client = getClient();
const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii);
const promptAttributes = filterMcpPiiFromSpanData(rawPromptAttributes, sendDefaultPii);

const promptAttributes = extractPromptResultAttributes(result, options.recordOutputs);
span.setAttributes(promptAttributes);
}

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

/**
Expand All @@ -22,18 +23,26 @@ const wrappedMcpServerInstances = new WeakSet();
* import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
* import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
*
* // Default: inputs/outputs captured based on sendDefaultPii option
* const server = Sentry.wrapMcpServerWithSentry(
* new McpServer({ name: "my-server", version: "1.0.0" })
* );
*
* // Explicitly control input/output capture
* const server = Sentry.wrapMcpServerWithSentry(
* new McpServer({ name: "my-server", version: "1.0.0" }),
* { recordInputs: true, recordOutputs: false }
* );
*
* const transport = new StreamableHTTPServerTransport();
* await server.connect(transport);
* ```
*
* @param mcpServerInstance - MCP server instance to instrument
* @param options - Optional configuration for recording inputs and outputs
* @returns Instrumented server instance (same reference)
*/
export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S): S {
export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S, options?: McpServerWrapperOptions): S {
if (wrappedMcpServerInstances.has(mcpServerInstance)) {
return mcpServerInstance;
}
Expand All @@ -43,6 +52,13 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S):
}

const serverInstance = mcpServerInstance as MCPServerInstance;
const client = getClient();
const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii);

const resolvedOptions: ResolvedMcpOptions = {
recordInputs: options?.recordInputs ?? sendDefaultPii,
recordOutputs: options?.recordOutputs ?? sendDefaultPii,
};

fill(serverInstance, 'connect', originalConnect => {
return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) {
Expand All @@ -52,8 +68,8 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S):
...restArgs,
);

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

Expand Down
64 changes: 15 additions & 49 deletions packages/core/src/integrations/mcp-server/piiFiltering.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,37 @@
/**
* PII filtering for MCP server spans
*
* Removes sensitive data when sendDefaultPii is false.
* Uses configurable attribute filtering to protect user privacy.
* Removes network-level sensitive data when sendDefaultPii is false.
* Input/output data (request arguments, tool/prompt results) is controlled
* separately via recordInputs/recordOutputs options.
*/
import type { SpanAttributeValue } from '../../types-hoist/span';
import {
CLIENT_ADDRESS_ATTRIBUTE,
CLIENT_PORT_ATTRIBUTE,
MCP_LOGGING_MESSAGE_ATTRIBUTE,
MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE,
MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE,
MCP_PROMPT_RESULT_PREFIX,
MCP_REQUEST_ARGUMENT,
MCP_RESOURCE_URI_ATTRIBUTE,
MCP_TOOL_RESULT_CONTENT_ATTRIBUTE,
MCP_TOOL_RESULT_PREFIX,
} from './attributes';
import { CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE } from './attributes';

/**
* PII attributes that should be removed when sendDefaultPii is false
* Network PII attributes that should be removed when sendDefaultPii is false
* @internal
*/
const PII_ATTRIBUTES = new Set([
CLIENT_ADDRESS_ATTRIBUTE,
CLIENT_PORT_ATTRIBUTE,
MCP_LOGGING_MESSAGE_ATTRIBUTE,
MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE,
MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE,
MCP_RESOURCE_URI_ATTRIBUTE,
MCP_TOOL_RESULT_CONTENT_ATTRIBUTE,
]);
const NETWORK_PII_ATTRIBUTES = new Set([CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE]);

/**
* Checks if an attribute key should be considered PII.
* Checks if an attribute key should be considered network PII.
*
* Returns true for:
* - Explicit PII attributes (client.address, client.port, mcp.logging.message, etc.)
* - All request arguments (mcp.request.argument.*)
* - Tool and prompt result content (mcp.tool.result.*, mcp.prompt.result.*) except metadata
*
* Preserves metadata attributes ending with _count, _error, or .is_error as they don't contain sensitive data.
* - client.address (IP address)
* - client.port (port number)
* - mcp.resource.uri (potentially sensitive URIs)
*
* @param key - Attribute key to evaluate
* @returns true if the attribute should be filtered out (is PII), false if it should be preserved
* @returns true if the attribute should be filtered out (is network PII), false if it should be preserved
* @internal
*/
function isPiiAttribute(key: string): boolean {
if (PII_ATTRIBUTES.has(key)) {
return true;
}

if (key.startsWith(`${MCP_REQUEST_ARGUMENT}.`)) {
return true;
}

if (key.startsWith(`${MCP_TOOL_RESULT_PREFIX}.`) || key.startsWith(`${MCP_PROMPT_RESULT_PREFIX}.`)) {
if (!key.endsWith('_count') && !key.endsWith('_error') && !key.endsWith('.is_error')) {
return true;
}
}

return false;
function isNetworkPiiAttribute(key: string): boolean {
return NETWORK_PII_ATTRIBUTES.has(key);
}

/**
* Removes PII attributes from span data when sendDefaultPii is false
* Removes network PII attributes from span data when sendDefaultPii is false
* @param spanData - Raw span attributes
* @param sendDefaultPii - Whether to include PII data
* @returns Filtered span attributes
Expand All @@ -80,7 +46,7 @@ export function filterMcpPiiFromSpanData(

return Object.entries(spanData).reduce(
(acc, [key, value]) => {
if (!isPiiAttribute(key)) {
if (!isNetworkPiiAttribute(key)) {
acc[key] = value as SpanAttributeValue;
}
return acc;
Expand Down
105 changes: 62 additions & 43 deletions packages/core/src/integrations/mcp-server/resultExtraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import { isValidContentItem } from './validation';
/**
* Build attributes for tool result content items
* @param content - Array of content items from tool result
* @returns Attributes extracted from each content item including type, text, mime type, URI, and resource info
* @param includeContent - Whether to include actual content (text, URIs) or just metadata
* @returns Attributes extracted from each content item
*/
function buildAllContentItemAttributes(content: unknown[]): Record<string, string | number | boolean> {
function buildAllContentItemAttributes(
content: unknown[],
includeContent: boolean,
): Record<string, string | number | boolean> {
const attributes: Record<string, string | number> = {
[MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length,
};
Expand All @@ -29,29 +33,34 @@ function buildAllContentItemAttributes(content: unknown[]): Record<string, strin

const prefix = content.length === 1 ? 'mcp.tool.result' : `mcp.tool.result.${i}`;

const safeSet = (key: string, value: unknown): void => {
if (typeof value === 'string') {
attributes[`${prefix}.${key}`] = value;
}
};
if (typeof item.type === 'string') {
attributes[`${prefix}.content_type`] = item.type;
}

safeSet('content_type', item.type);
safeSet('mime_type', item.mimeType);
safeSet('uri', item.uri);
safeSet('name', item.name);
if (includeContent) {
const safeSet = (key: string, value: unknown): void => {
if (typeof value === 'string') {
attributes[`${prefix}.${key}`] = value;
}
};

if (typeof item.text === 'string') {
attributes[`${prefix}.content`] = item.text;
}
safeSet('mime_type', item.mimeType);
safeSet('uri', item.uri);
safeSet('name', item.name);

if (typeof item.data === 'string') {
attributes[`${prefix}.data_size`] = item.data.length;
}
if (typeof item.text === 'string') {
attributes[`${prefix}.content`] = item.text;
}

const resource = item.resource;
if (isValidContentItem(resource)) {
safeSet('resource_uri', resource.uri);
safeSet('resource_mime_type', resource.mimeType);
if (typeof item.data === 'string') {
attributes[`${prefix}.data_size`] = item.data.length;
}

const resource = item.resource;
if (isValidContentItem(resource)) {
safeSet('resource_uri', resource.uri);
safeSet('resource_mime_type', resource.mimeType);
}
}
}

Expand All @@ -61,14 +70,18 @@ function buildAllContentItemAttributes(content: unknown[]): Record<string, strin
/**
* Extract tool result attributes for span instrumentation
* @param result - Tool execution result
* @param recordOutputs - Whether to include actual content or just metadata (counts, error status)
* @returns Attributes extracted from tool result content
*/
export function extractToolResultAttributes(result: unknown): Record<string, string | number | boolean> {
export function extractToolResultAttributes(
result: unknown,
recordOutputs: boolean,
): Record<string, string | number | boolean> {
if (!isValidContentItem(result)) {
return {};
}

const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content) : {};
const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content, recordOutputs) : {};

if (typeof result.isError === 'boolean') {
attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = result.isError;
Expand All @@ -80,43 +93,49 @@ export function extractToolResultAttributes(result: unknown): Record<string, str
/**
* Extract prompt result attributes for span instrumentation
* @param result - Prompt execution result
* @param recordOutputs - Whether to include actual content or just metadata (counts)
* @returns Attributes extracted from prompt result
*/
export function extractPromptResultAttributes(result: unknown): Record<string, string | number | boolean> {
export function extractPromptResultAttributes(
result: unknown,
recordOutputs: boolean,
): Record<string, string | number | boolean> {
const attributes: Record<string, string | number | boolean> = {};
if (!isValidContentItem(result)) {
return attributes;
}

if (typeof result.description === 'string') {
if (recordOutputs && typeof result.description === 'string') {
attributes[MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE] = result.description;
}

if (Array.isArray(result.messages)) {
attributes[MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE] = result.messages.length;

const messages = result.messages;
for (const [i, message] of messages.entries()) {
if (!isValidContentItem(message)) {
continue;
}
if (recordOutputs) {
const messages = result.messages;
for (const [i, message] of messages.entries()) {
if (!isValidContentItem(message)) {
continue;
}

const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`;
const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`;

const safeSet = (key: string, value: unknown): void => {
if (typeof value === 'string') {
const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`;
attributes[attrName] = value;
}
};
const safeSet = (key: string, value: unknown): void => {
if (typeof value === 'string') {
const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`;
attributes[attrName] = value;
}
};

safeSet('role', message.role);
safeSet('role', message.role);

if (isValidContentItem(message.content)) {
const content = message.content;
if (typeof content.text === 'string') {
const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`;
attributes[attrName] = content.text;
if (isValidContentItem(message.content)) {
const content = message.content;
if (typeof content.text === 'string') {
const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`;
attributes[attrName] = content.text;
}
}
}
}
Expand Down
Loading
Loading