7
7
from wrapt import ObjectProxy , register_post_import_hook , wrap_function_wrapper
8
8
9
9
from opentelemetry import context , trace
10
+ from opentelemetry .trace .status import Status , StatusCode
10
11
from opentelemetry .instrumentation .instrumentor import BaseInstrumentor
11
12
from opentelemetry .instrumentation .utils import unwrap
12
13
from opentelemetry .semconv .trace import SpanAttributes
15
16
from .version import __version__
16
17
17
18
from .semconv import (
18
- CLIENT_INITIALIZED ,
19
- MCP_METHOD_NAME ,
20
- MCP_REQUEST_ARGUMENT ,
21
- TOOLS_CALL ,
22
- TOOLS_LIST ,
23
- MCPAttributes ,
24
- MCPOperations ,
25
- MCPSpanNames ,
19
+ MCPSpanAttributes ,
20
+ MCPMethodNameValue ,
26
21
)
27
22
28
23
29
24
class McpInstrumentor (BaseInstrumentor ):
30
25
"""
31
- An instrumentor class for MCP.
26
+ An instrumentation class for MCP: https://modelcontextprotocol.io/overview .
32
27
"""
33
28
34
29
def __init__ (self , ** kwargs ):
@@ -40,20 +35,22 @@ def instrumentation_dependencies(self) -> Collection[str]:
40
35
return ("mcp >= 1.8.1" ,)
41
36
42
37
def _instrument (self , ** kwargs : Any ) -> None :
43
-
38
+ # TODO: add instrumentation for Streamable Http transport
39
+ # See: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
40
+
44
41
register_post_import_hook (
45
42
lambda _ : wrap_function_wrapper (
46
43
"mcp.shared.session" ,
47
44
"BaseSession.send_request" ,
48
- self ._wrap_send_request ,
45
+ self ._wrap_session_send_request ,
49
46
),
50
47
"mcp.shared.session" ,
51
48
)
52
49
register_post_import_hook (
53
50
lambda _ : wrap_function_wrapper (
54
51
"mcp.server.lowlevel.server" ,
55
52
"Server._handle_request" ,
56
- self ._wrap_handle_request ,
53
+ self ._wrap_stdio_handle_request ,
57
54
),
58
55
"mcp.server.lowlevel.server" ,
59
56
)
@@ -62,17 +59,26 @@ def _uninstrument(self, **kwargs: Any) -> None:
62
59
unwrap ("mcp.shared.session" , "BaseSession.send_request" )
63
60
unwrap ("mcp.server.lowlevel.server" , "Server._handle_request" )
64
61
65
- def _wrap_send_request (
62
+ def _wrap_session_send_request (
66
63
self , wrapped : Callable , instance : Any , args : Tuple [Any , ...], kwargs : Dict [str , Any ]
67
64
) -> Callable :
68
65
import mcp .types as types
69
-
70
66
71
67
"""
72
- Patches BaseSession.send_request which is responsible for sending requests from the client to the MCP server.
73
- This patched MCP client intercepts the request to obtain attributes for creating client-side span, extracts
74
- the current trace context, and embeds it into the request's params._meta.traceparent field
68
+ Instruments MCP client-side stdio request sending, see: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
69
+
70
+ This is the master function responsible for sending requests from the client to the MCP server. See:
71
+ https://github.com/modelcontextprotocol/python-sdk/blob/e68e513b428243057f9c4693e10162eb3bb52897/src/mcp/shared/session.py#L220
72
+
73
+ The instrumented MCP client intercepts the request to obtain attributes for creating client-side span, extracts
74
+ the current trace context, and embeds it into the request's params._meta field
75
75
before forwarding the request to the MCP server.
76
+
77
+ Args:
78
+ wrapped: The original BaseSession.send_request method being instrumented
79
+ instance: The BaseSession instance handling the stdio communication
80
+ args: Positional arguments passed to the original send_request method, containing the ClientRequest
81
+ kwargs: Keyword arguments passed to the original send_request method
76
82
"""
77
83
78
84
async def async_wrapper ():
@@ -81,88 +87,131 @@ async def async_wrapper():
81
87
if not request :
82
88
return await wrapped (* args , ** kwargs )
83
89
90
+ request_id = None
91
+
92
+ if hasattr (instance , "_request_id" ):
93
+ request_id = instance ._request_id
94
+
84
95
request_as_json = request .model_dump (by_alias = True , mode = "json" , exclude_none = True )
85
96
86
97
if "params" not in request_as_json :
87
98
request_as_json ["params" ] = {}
88
-
89
99
if "_meta" not in request_as_json ["params" ]:
90
100
request_as_json ["params" ]["_meta" ] = {}
91
101
92
- with self .tracer .start_as_current_span (
93
- MCPSpanNames .SPAN_MCP_CLIENT , kind = trace .SpanKind .CLIENT
94
- ) as client_span :
102
+ with self .tracer .start_as_current_span ("span.mcp.client" , kind = trace .SpanKind .CLIENT ) as client_span :
95
103
96
- if request :
97
- span_ctx = trace .set_span_in_context (client_span )
98
- parent_span = {}
99
- self .propagators .inject (carrier = parent_span , context = span_ctx )
104
+ span_ctx = trace .set_span_in_context (client_span )
105
+ parent_span = {}
106
+ self .propagators .inject (carrier = parent_span , context = span_ctx )
100
107
101
- McpInstrumentor ._set_mcp_client_attributes (client_span , request )
108
+ McpInstrumentor ._configure_mcp_span (client_span , request , request_id )
109
+ request_as_json ["params" ]["_meta" ].update (parent_span )
102
110
103
- request_as_json ["params" ]["_meta" ].update (parent_span )
111
+ # Reconstruct request object with injected trace context
112
+ modified_request = request .model_validate (request_as_json )
113
+ new_args = (modified_request ,) + args [1 :]
104
114
105
- # Reconstruct request object with injected trace context
106
- modified_request = request .model_validate (request_as_json )
107
- new_args = (modified_request ,) + args [1 :]
115
+ try :
116
+ result = await wrapped (* new_args , ** kwargs )
117
+ client_span .set_status (Status (StatusCode .OK ))
118
+ return result
119
+ except Exception as e :
120
+ client_span .set_status (Status (StatusCode .ERROR , str (e )))
121
+ client_span .record_exception (e )
122
+ raise
108
123
109
- return await wrapped (* new_args , ** kwargs )
110
124
return async_wrapper ()
111
125
112
- async def _wrap_handle_request (
126
+ async def _wrap_stdio_handle_request (
113
127
self , wrapped : Callable , instance : Any , args : Tuple [Any , ...], kwargs : Dict [str , Any ]
114
128
) -> Any :
115
- req = args [1 ] if len (args ) > 1 else None
129
+ """
130
+ Instruments MCP server-side stdio request handling, see: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
131
+
132
+ This is the core function responsible for processing incoming requests on the MCP server. See:
133
+ https://github.com/modelcontextprotocol/python-sdk/blob/e68e513b428243057f9c4693e10162eb3bb52897/src/mcp/server/lowlevel/server.py#L616
134
+
135
+ The instrumented MCP server intercepts incoming requests to extract tracing context from
136
+ the request's params._meta field, creates server-side spans linked to the originating client spans,
137
+ and processes the request while maintaining trace continuity.
138
+
139
+ Args:
140
+ wrapped: The original Server._handle_request method being instrumented
141
+ instance: The MCP Server instance processing the stdio communication
142
+ args: Positional arguments passed to the original _handle_request method, containing the incoming request
143
+ kwargs: Keyword arguments passed to the original _handle_request method
144
+ """
145
+ incoming_req = args [1 ] if len (args ) > 1 else None
146
+ request_id = None
116
147
carrier = {}
117
148
118
- if req and hasattr (req , "params" ) and req .params and hasattr (req .params , "meta" ) and req .params .meta :
119
- carrier = req .params .meta .model_dump ()
149
+ if incoming_req and hasattr (incoming_req , "id" ):
150
+ request_id = incoming_req .id
151
+ if incoming_req and hasattr (incoming_req , "params" ) and hasattr (incoming_req .params , "meta" ):
152
+ carrier = incoming_req .params .meta .model_dump ()
120
153
121
154
parent_ctx = self .propagators .extract (carrier = carrier )
122
155
123
156
if parent_ctx :
124
157
with self .tracer .start_as_current_span (
125
- MCPSpanNames .SPAN_MCP_SERVER , kind = trace .SpanKind .SERVER , context = parent_ctx
126
- ) as mcp_server_span :
127
- self ._set_mcp_server_attributes (mcp_server_span , req )
128
- return await wrapped (* args , ** kwargs )
158
+ "span.mcp.server" , kind = trace .SpanKind .SERVER , context = parent_ctx
159
+ ) as server_span :
129
160
130
- @staticmethod
131
- def _set_mcp_client_attributes (span : trace .Span , request : Any ) -> None :
132
- import mcp .types as types # pylint: disable=import-outside-toplevel,consider-using-from-import
161
+ self ._configure_mcp_span (server_span , incoming_req , request_id )
133
162
134
- if isinstance (request , types .ListToolsRequest ):
135
- span .set_attribute (MCP_METHOD_NAME , TOOLS_LIST )
136
- if isinstance (request , types .CallToolRequest ):
137
- tool_name = request .params .name
138
- tool_arguments = request .params .arguments
139
- if tool_arguments :
140
- for arg_name , arg_val in tool_arguments .items ():
141
- span .set_attribute (f"{ MCP_REQUEST_ARGUMENT } .{ arg_name } " , McpInstrumentor .serialize (arg_val ))
142
- span .update_name (f"{ TOOLS_CALL } { tool_name } " )
143
- span .set_attribute (MCP_METHOD_NAME , TOOLS_CALL )
144
- span .set_attribute (MCPAttributes .MCP_TOOL_NAME , tool_name )
145
- if isinstance (request , types .InitializeRequest ):
146
- span .set_attribute (MCP_METHOD_NAME , CLIENT_INITIALIZED )
163
+ try :
164
+ result = await wrapped (* args , ** kwargs )
165
+ server_span .set_status (Status (StatusCode .OK ))
166
+ return result
167
+ except Exception as e :
168
+ server_span .set_status (Status (StatusCode .ERROR , str (e )))
169
+ server_span .record_exception (e )
170
+ raise
147
171
148
172
@staticmethod
149
- def _set_mcp_server_attributes (span : trace .Span , request : Any ) -> None :
173
+ def _configure_mcp_span (span : trace .Span , request , request_id : Optional [ str ] ) -> None :
150
174
import mcp .types as types # pylint: disable=import-outside-toplevel,consider-using-from-import
151
175
176
+ if hasattr (request , "root" ):
177
+ request = request .root
178
+
179
+ if request_id :
180
+ span .set_attribute (MCPSpanAttributes .MCP_REQUEST_ID , request_id )
181
+
152
182
if isinstance (request , types .ListToolsRequest ):
153
- span .set_attribute (MCP_METHOD_NAME , TOOLS_LIST )
183
+ span .update_name (MCPMethodNameValue .TOOLS_LIST )
184
+ span .set_attribute (MCPSpanAttributes .MCP_METHOD_NAME , MCPMethodNameValue .TOOLS_LIST )
185
+ return
186
+
154
187
if isinstance (request , types .CallToolRequest ):
155
188
tool_name = request .params .name
156
- span .update_name (f"{ TOOLS_CALL } { tool_name } " )
157
- span .set_attribute (MCP_METHOD_NAME , TOOLS_CALL )
158
- span .set_attribute (MCPAttributes .MCP_TOOL_NAME , tool_name )
159
189
190
+ span .update_name (f"{ MCPMethodNameValue .TOOLS_CALL } { request .params .name } " )
191
+ span .set_attribute (MCPSpanAttributes .MCP_METHOD_NAME , MCPMethodNameValue .TOOLS_CALL )
192
+ span .set_attribute (MCPSpanAttributes .MCP_TOOL_NAME , tool_name )
160
193
161
- @staticmethod
194
+ if hasattr (request .params , "arguments" ):
195
+ for arg_name , arg_val in request .params .arguments .items ():
196
+ span .set_attribute (
197
+ f"{ MCPSpanAttributes .MCP_REQUEST_ARGUMENT } .{ arg_name } " , McpInstrumentor .serialize (arg_val )
198
+ )
199
+
200
+ if isinstance (request , types .InitializeRequest ):
201
+ span .update_name (MCPMethodNameValue .INITIALIZED )
202
+ span .set_attribute (MCPSpanAttributes .MCP_METHOD_NAME , MCPMethodNameValue .INITIALIZED )
203
+
204
+ if isinstance (request , types .CancelledNotification ):
205
+ span .update_name (MCPMethodNameValue .NOTIFICATIONS_CANCELLED )
206
+ span .set_attribute (MCPSpanAttributes .MCP_METHOD_NAME , MCPMethodNameValue .NOTIFICATIONS_CANCELLED )
207
+
208
+ if isinstance (request , types .ToolListChangedNotification ):
209
+ span .update_name (MCPMethodNameValue .NOTIFICATIONS_CANCELLED )
210
+ span .set_attribute (MCPSpanAttributes .MCP_METHOD_NAME , MCPMethodNameValue .NOTIFICATIONS_CANCELLED )
211
+
212
+ @staticmethod
162
213
def serialize (args ):
163
214
try :
164
215
return json .dumps (args )
165
216
except Exception :
166
217
return str (args )
167
-
168
-
0 commit comments