Skip to content

Commit 670e6b1

Browse files
committed
feat(mcp-server): tool result handling and error capturing
- Introduced new attributes for tool result content count and error status. - Updated attribute extraction functions to utilize new constants for better maintainability. - Added error capturing utilities to handle tool execution errors and transport errors gracefully.
1 parent 24bff02 commit 670e6b1

File tree

9 files changed

+215
-20
lines changed

9 files changed

+215
-20
lines changed

packages/core/src/integrations/mcp-server/attributeExtraction.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
MCP_RESOURCE_URI_ATTRIBUTE,
1212
MCP_SESSION_ID_ATTRIBUTE,
1313
MCP_TOOL_NAME_ATTRIBUTE,
14+
MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE,
15+
MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE,
1416
MCP_TRANSPORT_ATTRIBUTE,
1517
NETWORK_PROTOCOL_VERSION_ATTRIBUTE,
1618
NETWORK_TRANSPORT_ATTRIBUTE,
@@ -263,7 +265,7 @@ export function buildTypeSpecificAttributes(
263265
/** Get metadata about tool result content array */
264266
function getContentMetadata(content: unknown[]): Record<string, string | number> {
265267
return {
266-
'mcp.tool.result.content_count': content.length,
268+
[MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length,
267269
};
268270
}
269271

@@ -350,7 +352,7 @@ export function extractToolResultAttributes(result: unknown): Record<string, str
350352
const resultObj = result as Record<string, unknown>;
351353

352354
if (typeof resultObj.isError === 'boolean') {
353-
attributes['mcp.tool.result.is_error'] = resultObj.isError;
355+
attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = resultObj.isError;
354356
}
355357

356358
if (Array.isArray(resultObj.content)) {

packages/core/src/integrations/mcp-server/attributes.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ export const MCP_RESOURCE_URI_ATTRIBUTE = 'mcp.resource.uri';
5555
*/
5656
export const MCP_PROMPT_NAME_ATTRIBUTE = 'mcp.prompt.name';
5757

58+
// =============================================================================
59+
// TOOL RESULT ATTRIBUTES
60+
// =============================================================================
61+
62+
/**
63+
* Whether a tool execution resulted in an error
64+
*/
65+
export const MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE = 'mcp.tool.result.is_error';
66+
67+
/**
68+
* Number of content items in the tool result
69+
*/
70+
export const MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE = 'mcp.tool.result.content_count';
71+
72+
/**
73+
* Serialized content of the tool result
74+
*/
75+
export const MCP_TOOL_RESULT_CONTENT_ATTRIBUTE = 'mcp.tool.result.content';
76+
5877
// =============================================================================
5978
// NETWORK ATTRIBUTES (OpenTelemetry Standard)
6079
// =============================================================================

packages/core/src/integrations/mcp-server/correlation.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import { getClient } from '../../currentScopes';
77
import { withActiveSpan } from '../../tracing';
88
import type { Span } from '../../types-hoist/span';
9+
import { MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE, MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE } from './attributes';
10+
import { captureError } from './errorCapture';
911
import { filterMcpPiiFromSpanData } from './piiFiltering';
1012
import type { RequestId, SessionId } from './types';
1113

@@ -78,21 +80,22 @@ export function completeSpanWithResults(requestId: RequestId, result: unknown):
7880

7981
spanWithMethods.setAttributes(toolAttributes);
8082

81-
// Set span status based on tool result
82-
if (toolAttributes['mcp.tool.result.is_error']) {
83+
const isToolError = rawToolAttributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] === true;
84+
85+
if (isToolError) {
8386
spanWithMethods.setStatus({
8487
code: 2, // ERROR
8588
message: 'Tool execution failed',
8689
});
90+
91+
captureError(new Error('Tool returned error result'), 'tool_execution');
8792
}
8893
}
8994

90-
// Complete the span
9195
if (spanWithMethods.end) {
9296
spanWithMethods.end();
9397
}
9498

95-
// Clean up correlation
9699
requestIdToSpanMap.delete(requestId);
97100
}
98101
}
@@ -130,17 +133,15 @@ function extractToolResultAttributes(result: unknown): Record<string, string | n
130133
if (typeof result === 'object' && result !== null) {
131134
const resultObj = result as Record<string, unknown>;
132135

133-
// Check if this is an error result
134136
if (typeof resultObj.isError === 'boolean') {
135-
attributes['mcp.tool.result.is_error'] = resultObj.isError;
137+
attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = resultObj.isError;
136138
}
137139

138-
// Store content as-is (serialized)
139140
if (Array.isArray(resultObj.content)) {
140-
attributes['mcp.tool.result.content_count'] = resultObj.content.length;
141+
attributes[MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE] = resultObj.content.length;
141142

142143
const serializedContent = JSON.stringify(resultObj.content);
143-
attributes['mcp.tool.result.content'] =
144+
attributes[MCP_TOOL_RESULT_CONTENT_ATTRIBUTE] =
144145
serializedContent.length > 5000 ? `${serializedContent.substring(0, 4997)}...` : serializedContent;
145146
}
146147
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Safe error capture utilities for MCP server instrumentation
3+
* Ensures Sentry error reporting never interferes with MCP service operation
4+
*/
5+
6+
import { getClient } from '../../currentScopes';
7+
import { captureException } from '../../exports';
8+
9+
/**
10+
* Safely captures an error to Sentry without affecting MCP service operation
11+
* The active span already contains all MCP context (method, tool, arguments, etc.)
12+
* Sentry automatically associates the error with the active span
13+
*/
14+
export function captureError(error: Error, errorType?: string): void {
15+
try {
16+
const client = getClient();
17+
if (!client) {
18+
return
19+
}
20+
21+
captureException(error, {
22+
tags: {
23+
mcp_error_type: errorType || 'handler_execution',
24+
},
25+
});
26+
} catch {
27+
// Silently ignore capture errors - never affect MCP operation
28+
}
29+
}

packages/core/src/integrations/mcp-server/handlers.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DEBUG_BUILD } from '../../debug-build';
77
import { logger } from '../../utils/logger';
88
import { fill } from '../../utils/object';
99
import { associateContextWithRequestSpan } from './correlation';
10+
import { captureError } from './errorCapture';
1011
import type { HandlerExtraData, MCPHandler, MCPServerInstance } from './types';
1112

1213
/**
@@ -21,22 +22,22 @@ function wrapMethodHandler(serverInstance: MCPServerInstance, methodName: keyof
2122
return (originalMethod as (...args: unknown[]) => unknown).call(this, name, ...args);
2223
}
2324

24-
const wrappedHandler = createWrappedHandler(handler as MCPHandler);
25+
const wrappedHandler = createWrappedHandler(handler as MCPHandler, methodName, name);
2526
return (originalMethod as (...args: unknown[]) => unknown).call(this, name, ...args.slice(0, -1), wrappedHandler);
2627
};
2728
});
2829
}
2930

3031
/**
31-
* Creates a wrapped handler with span correlation
32+
* Creates a wrapped handler with span correlation and error capture
3233
*/
33-
function createWrappedHandler(originalHandler: MCPHandler) {
34+
function createWrappedHandler(originalHandler: MCPHandler, methodName: keyof MCPServerInstance, handlerName: string) {
3435
return function (this: unknown, ...handlerArgs: unknown[]): unknown {
3536
try {
3637
const extraHandlerData = findExtraHandlerData(handlerArgs);
3738

3839
return associateContextWithRequestSpan(extraHandlerData, () => {
39-
return originalHandler.apply(this, handlerArgs);
40+
return createErrorCapturingHandler(originalHandler, methodName, handlerName, handlerArgs, extraHandlerData);
4041
});
4142
} catch (error) {
4243
DEBUG_BUILD && logger.warn('MCP handler wrapping failed:', error);
@@ -45,6 +46,75 @@ function createWrappedHandler(originalHandler: MCPHandler) {
4546
};
4647
}
4748

49+
/**
50+
* Creates a handler that captures execution errors for Sentry
51+
*/
52+
function createErrorCapturingHandler(
53+
originalHandler: MCPHandler,
54+
methodName: keyof MCPServerInstance,
55+
handlerName: string,
56+
handlerArgs: unknown[],
57+
extraHandlerData?: HandlerExtraData,
58+
): unknown {
59+
try {
60+
const result = originalHandler.apply(originalHandler, handlerArgs);
61+
62+
// Handle both sync and async handlers
63+
if (result && typeof result === 'object' && 'then' in result) {
64+
// Async handler - wrap with error capture
65+
return (result as Promise<unknown>).catch((error: Error) => {
66+
captureHandlerError(error, methodName, handlerName, handlerArgs, extraHandlerData);
67+
throw error; // Re-throw to maintain MCP error handling behavior
68+
});
69+
}
70+
71+
// Sync handler - return result as-is
72+
return result;
73+
} catch (error) {
74+
// Sync handler threw an error - capture it
75+
captureHandlerError(error as Error, methodName, handlerName, handlerArgs, extraHandlerData);
76+
throw error; // Re-throw to maintain MCP error handling behavior
77+
}
78+
}
79+
80+
/**
81+
* Captures handler execution errors based on handler type
82+
*/
83+
function captureHandlerError(
84+
error: Error,
85+
methodName: keyof MCPServerInstance,
86+
_handlerName: string,
87+
_handlerArgs: unknown[],
88+
_extraHandlerData?: HandlerExtraData,
89+
): void {
90+
try {
91+
if (methodName === 'tool') {
92+
// Check if this is a validation/protocol error
93+
if (
94+
error.name === 'ProtocolValidationError' ||
95+
error.message.includes('validation') ||
96+
error.message.includes('protocol')
97+
) {
98+
captureError(error, 'validation');
99+
} else if (
100+
error.name === 'ServerTimeoutError' ||
101+
error.message.includes('timed out') ||
102+
error.message.includes('timeout')
103+
) {
104+
captureError(error, 'timeout');
105+
} else {
106+
captureError(error, 'tool_execution');
107+
}
108+
} else if (methodName === 'resource') {
109+
captureError(error, 'resource_operation');
110+
} else if (methodName === 'prompt') {
111+
captureError(error, 'prompt_execution');
112+
}
113+
} catch (captureErr) {
114+
// silently ignore capture errors to not affect MCP operation
115+
}
116+
}
117+
48118
/**
49119
* Extracts request/session data from handler arguments
50120
*/

packages/core/src/integrations/mcp-server/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fill } from '../../utils/object';
22
import { wrapAllMCPHandlers } from './handlers';
3-
import { wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport';
3+
import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport';
44
import type { MCPServerInstance, MCPTransport } from './types';
55
import { validateMcpServerInstance } from './validation';
66

@@ -34,6 +34,7 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S):
3434
wrapTransportOnMessage(transport);
3535
wrapTransportSend(transport);
3636
wrapTransportOnClose(transport);
37+
wrapTransportError(transport);
3738

3839
return result;
3940
};

packages/core/src/integrations/mcp-server/piiFiltering.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
* Removes sensitive data when sendDefaultPii is false
44
*/
55

6+
import { MCP_TOOL_RESULT_CONTENT_ATTRIBUTE } from './attributes';
7+
68
/** PII attributes that should be removed when sendDefaultPii is false */
79
const PII_ATTRIBUTES = new Set([
810
'client.address',
911
'client.port',
1012
'mcp.logging.message',
1113
'mcp.resource.uri',
12-
'mcp.tool.result.content',
14+
MCP_TOOL_RESULT_CONTENT_ATTRIBUTE,
1315
]);
1416

1517
/**

packages/core/src/integrations/mcp-server/transport.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getIsolationScope, withIsolationScope } from '../../currentScopes';
77
import { startInactiveSpan, withActiveSpan } from '../../tracing';
88
import { fill } from '../../utils/object';
99
import { cleanupAllPendingSpans, completeSpanWithResults, storeSpanForRequest } from './correlation';
10+
import { captureError } from './errorCapture';
1011
import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans';
1112
import type { ExtraHandlerData, MCPTransport } from './types';
1213
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse } from './validation';
@@ -65,12 +66,14 @@ export function wrapTransportSend(transport: MCPTransport): void {
6566
});
6667
}
6768

68-
// Handle responses - enrich spans with tool results
6969
if (isJsonRpcResponse(message)) {
7070
const messageTyped = message as { id: string | number; result?: unknown; error?: unknown };
7171

7272
if (messageTyped.id !== null && messageTyped.id !== undefined) {
73-
// Complete span with tool results
73+
if (messageTyped.error) {
74+
captureJsonRpcErrorResponse(messageTyped.error, messageTyped.id, this);
75+
}
76+
7477
completeSpanWithResults(messageTyped.id, messageTyped.result);
7578
}
7679
}
@@ -88,11 +91,58 @@ export function wrapTransportOnClose(transport: MCPTransport): void {
8891
if (transport.onclose) {
8992
fill(transport, 'onclose', originalOnClose => {
9093
return function (this: MCPTransport, ...args: unknown[]) {
91-
// Clean up any pending spans on transport close
9294
cleanupAllPendingSpans();
9395

9496
return (originalOnClose as (...args: unknown[]) => unknown).call(this, ...args);
9597
};
9698
});
9799
}
98100
}
101+
102+
/**
103+
* Wraps transport error handlers to capture connection errors
104+
*/
105+
export function wrapTransportError(transport: MCPTransport): void {
106+
// All MCP transports have an onerror method as part of the standard interface
107+
if (transport.onerror) {
108+
fill(transport, 'onerror', (originalOnError: (error: Error) => void) => {
109+
return function (this: MCPTransport, error: Error) {
110+
captureTransportError(error, this);
111+
return originalOnError.call(this, error);
112+
};
113+
});
114+
}
115+
}
116+
117+
/**
118+
* Captures JSON-RPC error responses
119+
*/
120+
function captureJsonRpcErrorResponse(
121+
errorResponse: unknown,
122+
_requestId: string | number,
123+
_transport: MCPTransport,
124+
): void {
125+
try {
126+
if (errorResponse && typeof errorResponse === 'object' && 'code' in errorResponse && 'message' in errorResponse) {
127+
const jsonRpcError = errorResponse as { code: number; message: string; data?: unknown };
128+
129+
const error = new Error(jsonRpcError.message);
130+
error.name = `JsonRpcError_${jsonRpcError.code}`;
131+
132+
captureError(error, 'protocol');
133+
}
134+
} catch {
135+
// Silently ignore capture errors
136+
}
137+
}
138+
139+
/**
140+
* Captures transport connection errors
141+
*/
142+
function captureTransportError(error: Error, _transport: MCPTransport): void {
143+
try {
144+
captureError(error, 'transport');
145+
} catch {
146+
// Silently ignore capture errors
147+
}
148+
}

0 commit comments

Comments
 (0)