Skip to content

Commit aa0a9fd

Browse files
committed
Add tool call results and MCP spans duration to cover their children duration. adds configuration, extraction, and transport utilities. Introduce span creation functions and improves method wrapping for improved telemetry.
1 parent 1efda74 commit aa0a9fd

File tree

9 files changed

+910
-680
lines changed

9 files changed

+910
-680
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
MCP_TOOL_NAME_ATTRIBUTE,
3+
MCP_RESOURCE_URI_ATTRIBUTE,
4+
MCP_PROMPT_NAME_ATTRIBUTE,
5+
} from './attributes';
6+
import type { MethodConfig } from './types';
7+
8+
/**
9+
* Configuration for MCP methods to extract targets and arguments
10+
*/
11+
export const METHOD_CONFIGS: Record<string, MethodConfig> = {
12+
'tools/call': {
13+
targetField: 'name',
14+
targetAttribute: MCP_TOOL_NAME_ATTRIBUTE,
15+
captureArguments: true,
16+
argumentsField: 'arguments',
17+
},
18+
'resources/read': {
19+
targetField: 'uri',
20+
targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE,
21+
captureUri: true,
22+
},
23+
'resources/subscribe': {
24+
targetField: 'uri',
25+
targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE,
26+
},
27+
'resources/unsubscribe': {
28+
targetField: 'uri',
29+
targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE,
30+
},
31+
'prompts/get': {
32+
targetField: 'name',
33+
targetAttribute: MCP_PROMPT_NAME_ATTRIBUTE,
34+
captureName: true,
35+
captureArguments: true,
36+
argumentsField: 'arguments',
37+
},
38+
};
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url';
2+
import { METHOD_CONFIGS } from './config';
3+
import type { ExtraHandlerData } from './types';
4+
5+
/**
6+
* Extracts target info from method and params based on method type
7+
*/
8+
export function extractTargetInfo(
9+
method: string,
10+
params: Record<string, unknown>,
11+
): {
12+
target?: string;
13+
attributes: Record<string, string>;
14+
} {
15+
const config = METHOD_CONFIGS[method as keyof typeof METHOD_CONFIGS];
16+
if (!config) {
17+
return { attributes: {} };
18+
}
19+
20+
const target =
21+
config.targetField && typeof params?.[config.targetField] === 'string'
22+
? (params[config.targetField] as string)
23+
: undefined;
24+
25+
return {
26+
target,
27+
attributes: target && config.targetAttribute ? { [config.targetAttribute]: target } : {},
28+
};
29+
}
30+
31+
/**
32+
* Extracts request arguments based on method type
33+
*/
34+
export function getRequestArguments(method: string, params: Record<string, unknown>): Record<string, string> {
35+
const args: Record<string, string> = {};
36+
const config = METHOD_CONFIGS[method as keyof typeof METHOD_CONFIGS];
37+
38+
if (!config) {
39+
return args;
40+
}
41+
42+
// Capture arguments from the configured field
43+
if (config.captureArguments && config.argumentsField && params?.[config.argumentsField]) {
44+
const argumentsObj = params[config.argumentsField];
45+
if (typeof argumentsObj === 'object' && argumentsObj !== null) {
46+
for (const [key, value] of Object.entries(argumentsObj as Record<string, unknown>)) {
47+
args[`mcp.request.argument.${key.toLowerCase()}`] = JSON.stringify(value);
48+
}
49+
}
50+
}
51+
52+
// Capture specific fields as arguments
53+
if (config.captureUri && params?.uri) {
54+
args['mcp.request.argument.uri'] = JSON.stringify(params.uri);
55+
}
56+
57+
if (config.captureName && params?.name) {
58+
args['mcp.request.argument.name'] = JSON.stringify(params.name);
59+
}
60+
61+
return args;
62+
}
63+
64+
/**
65+
* Extracts additional attributes for specific notification types
66+
*/
67+
export function getNotificationAttributes(method: string, params: Record<string, unknown>): Record<string, string | number> {
68+
const attributes: Record<string, string | number> = {};
69+
70+
switch (method) {
71+
case 'notifications/cancelled':
72+
if (params?.requestId) {
73+
attributes['mcp.cancelled.request_id'] = String(params.requestId);
74+
}
75+
if (params?.reason) {
76+
attributes['mcp.cancelled.reason'] = String(params.reason);
77+
}
78+
break;
79+
80+
case 'notifications/message':
81+
if (params?.level) {
82+
attributes['mcp.logging.level'] = String(params.level);
83+
}
84+
if (params?.logger) {
85+
attributes['mcp.logging.logger'] = String(params.logger);
86+
}
87+
if (params?.data !== undefined) {
88+
attributes['mcp.logging.data_type'] = typeof params.data;
89+
// Store the actual message content
90+
if (typeof params.data === 'string') {
91+
attributes['mcp.logging.message'] = params.data;
92+
} else {
93+
attributes['mcp.logging.message'] = JSON.stringify(params.data);
94+
}
95+
}
96+
break;
97+
98+
case 'notifications/progress':
99+
if (params?.progressToken) {
100+
attributes['mcp.progress.token'] = String(params.progressToken);
101+
}
102+
if (typeof params?.progress === 'number') {
103+
attributes['mcp.progress.current'] = params.progress;
104+
}
105+
if (typeof params?.total === 'number') {
106+
attributes['mcp.progress.total'] = params.total;
107+
if (typeof params?.progress === 'number') {
108+
attributes['mcp.progress.percentage'] = (params.progress / params.total) * 100;
109+
}
110+
}
111+
if (params?.message) {
112+
attributes['mcp.progress.message'] = String(params.message);
113+
}
114+
break;
115+
116+
case 'notifications/resources/updated':
117+
if (params?.uri) {
118+
attributes['mcp.resource.uri'] = String(params.uri);
119+
// Extract protocol from URI
120+
const urlObject = parseStringToURLObject(String(params.uri));
121+
if (urlObject && !isURLObjectRelative(urlObject)) {
122+
attributes['mcp.resource.protocol'] = urlObject.protocol.replace(':', '');
123+
}
124+
}
125+
break;
126+
127+
case 'notifications/initialized':
128+
attributes['mcp.lifecycle.phase'] = 'initialization_complete';
129+
attributes['mcp.protocol.ready'] = 1;
130+
break;
131+
}
132+
133+
return attributes;
134+
}
135+
136+
/**
137+
* Extracts attributes from tool call results for tracking
138+
* Captures actual content for debugging and monitoring
139+
*
140+
* @param method The MCP method name (should be 'tools/call')
141+
* @param result The raw CallToolResult object returned by the tool handler
142+
*/
143+
export function extractToolResultAttributes(
144+
method: string,
145+
result: unknown,
146+
): Record<string, string | number | boolean> {
147+
const attributes: Record<string, string | number | boolean> = {};
148+
149+
// Only process tool call results
150+
if (method !== 'tools/call' || !result || typeof result !== 'object') {
151+
return attributes;
152+
}
153+
154+
// The result is the raw CallToolResult object from the tool handler
155+
const toolResult = result as {
156+
content?: Array<{ type?: string; text?: string; [key: string]: unknown }>;
157+
structuredContent?: Record<string, unknown>;
158+
isError?: boolean;
159+
};
160+
161+
// Track if result is an error
162+
if (toolResult.isError !== undefined) {
163+
attributes['mcp.tool.result.is_error'] = toolResult.isError;
164+
}
165+
166+
// Track content metadata and actual content
167+
if (toolResult.content && Array.isArray(toolResult.content)) {
168+
attributes['mcp.tool.result.content_count'] = toolResult.content.length;
169+
170+
// Track content types
171+
const types = toolResult.content.map(c => c.type).filter((type): type is string => typeof type === 'string');
172+
173+
if (types.length > 0) {
174+
attributes['mcp.tool.result.content_types'] = types.join(',');
175+
}
176+
177+
// Track actual content - serialize the full content array
178+
try {
179+
attributes['mcp.tool.result.content'] = JSON.stringify(toolResult.content);
180+
} catch (error) {
181+
// If serialization fails, store a fallback message
182+
attributes['mcp.tool.result.content'] = '[Content serialization failed]';
183+
}
184+
}
185+
186+
// Track structured content if exists
187+
if (toolResult.structuredContent !== undefined) {
188+
attributes['mcp.tool.result.has_structured_content'] = true;
189+
190+
// Track actual structured content
191+
try {
192+
attributes['mcp.tool.result.structured_content'] = JSON.stringify(toolResult.structuredContent);
193+
} catch (error) {
194+
// If serialization fails, store a fallback message
195+
attributes['mcp.tool.result.structured_content'] = '[Structured content serialization failed]';
196+
}
197+
}
198+
199+
return attributes;
200+
}
201+
202+
/**
203+
* Extracts arguments from handler parameters for handler-level instrumentation
204+
*/
205+
export function extractHandlerArguments(handlerType: string, args: unknown[]): Record<string, string> {
206+
const arguments_: Record<string, string> = {};
207+
208+
// Find the first argument that is not the extra object
209+
const firstArg = args.find(arg =>
210+
arg &&
211+
typeof arg === 'object' &&
212+
!('requestId' in arg)
213+
);
214+
215+
if (!firstArg) {
216+
return arguments_;
217+
}
218+
219+
if (handlerType === 'tool' || handlerType === 'prompt') {
220+
// For tools and prompts, first arg contains the arguments
221+
if (typeof firstArg === 'object' && firstArg !== null) {
222+
for (const [key, value] of Object.entries(firstArg as Record<string, unknown>)) {
223+
arguments_[`mcp.request.argument.${key.toLowerCase()}`] = typeof value === 'string' ? value : JSON.stringify(value);
224+
}
225+
}
226+
} else if (handlerType === 'resource') {
227+
// For resources, we might have URI and variables
228+
// First argument is usually the URI (resource name)
229+
// Second argument might be variables for template expansion
230+
const uriArg = args[0];
231+
if (typeof uriArg === 'string' || uriArg instanceof URL) {
232+
arguments_['mcp.request.argument.uri'] = JSON.stringify(uriArg.toString());
233+
}
234+
235+
// Check if second argument is variables (not the extra object)
236+
const secondArg = args[1];
237+
if (secondArg && typeof secondArg === 'object' && !('requestId' in secondArg)) {
238+
for (const [key, value] of Object.entries(secondArg as Record<string, unknown>)) {
239+
arguments_[`mcp.request.argument.${key.toLowerCase()}`] = typeof value === 'string' ? value : JSON.stringify(value);
240+
}
241+
}
242+
}
243+
244+
return arguments_;
245+
}
246+
247+
/**
248+
* Extracts client connection information
249+
*/
250+
export function extractClientInfo(extra: ExtraHandlerData): {
251+
address?: string;
252+
port?: number;
253+
} {
254+
return {
255+
address:
256+
extra?.requestInfo?.remoteAddress ||
257+
extra?.clientAddress ||
258+
extra?.request?.ip ||
259+
extra?.request?.connection?.remoteAddress,
260+
port: extra?.requestInfo?.remotePort || extra?.clientPort || extra?.request?.connection?.remotePort,
261+
};
262+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { JsonRpcNotification, JsonRpcRequest } from './types';
2+
import { DEBUG_BUILD } from '../../debug-build';
3+
import { logger } from '../../utils/logger';
4+
5+
/** Validates if a message is a JSON-RPC request */
6+
export function isJsonRpcRequest(message: unknown): message is JsonRpcRequest {
7+
return (
8+
typeof message === 'object' &&
9+
message !== null &&
10+
'jsonrpc' in message &&
11+
(message as JsonRpcRequest).jsonrpc === '2.0' &&
12+
'method' in message &&
13+
'id' in message
14+
);
15+
}
16+
17+
/** Validates if a message is a JSON-RPC notification */
18+
export function isJsonRpcNotification(message: unknown): message is JsonRpcNotification {
19+
return (
20+
typeof message === 'object' &&
21+
message !== null &&
22+
'jsonrpc' in message &&
23+
(message as JsonRpcNotification).jsonrpc === '2.0' &&
24+
'method' in message &&
25+
!('id' in message)
26+
);
27+
}
28+
29+
/** Validates MCP server instance with comprehensive type checking */
30+
export function validateMcpServerInstance(instance: unknown): boolean {
31+
if (
32+
typeof instance === 'object' &&
33+
instance !== null &&
34+
'resource' in instance &&
35+
'tool' in instance &&
36+
'prompt' in instance &&
37+
'connect' in instance
38+
) {
39+
return true;
40+
}
41+
DEBUG_BUILD && logger.warn('Did not patch MCP server. Interface is incompatible.');
42+
return false;
43+
}

0 commit comments

Comments
 (0)