6565# multiple agents (each attach pushes a token; each detach pops just
6666# its own entry) and supports detaches in any order without clearing
6767# routing for the still-attached agents.
68- _provider_stack : list [tuple [object , Any ]] = []
68+ _provider_stack : list [tuple [object , Any , bool ]] = []
6969
7070
71- def register_provider (provider : Any ) -> object :
72- """Push ``provider`` onto the routing stack as the preferred source
73- for MCP spans. Returns an opaque token that
74- :func:`unregister_provider` uses to remove this exact entry.
71+ def register_provider (provider : Any , * , record_content : bool = False ) -> object :
72+ """Push ``provider`` and its content policy onto the routing stack.
7573
76- Called by :meth:`cubepi.tracing.Tracer.attach`.
74+ Returns an opaque token that :func:`unregister_provider` uses to remove
75+ this exact entry. Called by :meth:`cubepi.tracing.Tracer.attach`.
7776 """
7877 token = object ()
79- _provider_stack .append ((token , provider ))
78+ _provider_stack .append ((token , provider , record_content ))
8079 return token
8180
8281
@@ -93,26 +92,28 @@ def unregister_provider(token: object | None = None) -> None:
9392 if token is None :
9493 _provider_stack .pop ()
9594 return
96- for i , (t , _p ) in enumerate (_provider_stack ):
95+ for i , (t , _p , _record_content ) in enumerate (_provider_stack ):
9796 if t is token :
9897 _provider_stack .pop (i )
9998 return
10099
101100
102- def _get_tracer (scope_name : str ) -> Any :
103- """Resolve the tracer to use for emitting an MCP span.
104-
105- Prefers the most recently-registered provider over OTel's global
106- default (which is a no-op unless the user separately called
107- ``set_tracer_provider``).
108- """
101+ def _get_tracer_route (scope_name : str ) -> tuple [Any , bool ]:
102+ """Resolve the tracer and content policy for an MCP span."""
109103 if _provider_stack :
110- return _provider_stack [- 1 ][1 ].get_tracer (scope_name )
111- return _otel_trace .get_tracer (scope_name )
104+ _token , provider , record_content = _provider_stack [- 1 ]
105+ return provider .get_tracer (scope_name ), record_content
106+ return _otel_trace .get_tracer (scope_name ), False
107+
108+
109+ def _get_tracer (scope_name : str ) -> Any :
110+ """Resolve the tracer to use for emitting an MCP span."""
111+ tracer , _record_content = _get_tracer_route (scope_name )
112+ return tracer
112113
113114
114115# When the cubepi Recorder opens an ``execute_tool`` span, it publishes
115- # ``(span, owning_provider)`` here so an MCP tool call running inside
116+ # ``(span, owning_provider, record_content )`` here so an MCP tool call running inside
116117# the AgentTool body can make its CLIENT span a child of this span
117118# (rather than starting an orphan root trace — recorder doesn't bother
118119# installing ``execute_tool`` as the OTel current span; see
@@ -122,7 +123,7 @@ def _get_tracer(scope_name: str) -> Any:
122123#
123124# Lookup uses a per-task ``ContextVar`` holding a STACK of opaque
124125# handles (outermost first, innermost last), with the actual
125- # ``(span, provider)`` payload stored in a module-level
126+ # ``(span, provider, record_content )`` payload stored in a module-level
126127# ``_active_entries`` dict. The dict is the source of truth for which
127128# handles are still live; the contextvar stack records nesting order
128129# per task.
@@ -148,16 +149,21 @@ def _get_tracer(scope_name: str) -> Any:
148149# is found. Dead handles linger only in a task's local stack tuple and
149150# are GC'd when that task ends; ``_active_entries`` stays bounded by
150151# live registrations.
151- _active_entries : dict [object , tuple [Any , Any ]] = {}
152+ _active_entries : dict [object , tuple [Any , Any , bool ]] = {}
152153_handle_stack : contextvars .ContextVar [tuple [object , ...]] = contextvars .ContextVar (
153154 "_cubepi_mcp_tool_handle_stack" , default = ()
154155)
156+ _record_content_context : contextvars .ContextVar [bool ] = contextvars .ContextVar (
157+ "_cubepi_mcp_record_content" , default = False
158+ )
155159
156160
157161def register_tool_span (
158162 tool_call_id : str ,
159163 span : Any ,
160164 provider : Any = None ,
165+ * ,
166+ record_content : bool = False ,
161167) -> tuple [object , contextvars .Token [tuple [object , ...]]]:
162168 """Publish ``span`` (and its owning ``provider``) as the current
163169 ``execute_tool`` parent for the calling task.
@@ -173,7 +179,7 @@ def register_tool_span(
173179 """
174180 del tool_call_id
175181 handle = object ()
176- _active_entries [handle ] = (span , provider )
182+ _active_entries [handle ] = (span , provider , record_content )
177183 cv_token = _handle_stack .set (_handle_stack .get () + (handle ,))
178184 return (handle , cv_token )
179185
@@ -205,9 +211,10 @@ def unregister_tool_span(
205211 pass
206212
207213
208- def _get_tool_span_entry () -> tuple [Any , Any ] | None :
209- """Return the (span, provider) entry for the current task, or
210- ``None`` when no live ``execute_tool`` is in scope.
214+ def _get_tool_span_entry () -> tuple [Any , Any , bool ] | None :
215+ """Return the active span, provider, and content policy for this task.
216+
217+ Returns ``None`` when no live ``execute_tool`` is in scope.
211218
212219 Walks the per-task handle stack inner→outer and returns the first
213220 handle whose payload is still live in ``_active_entries``. A nested
@@ -287,16 +294,19 @@ async def mcp_client_span(
287294 del parent_tool_call_id
288295 entry = _get_tool_span_entry ()
289296 if entry is not None :
290- parent_span , parent_provider = entry
297+ parent_span , parent_provider , record_content = entry
291298 parent_context = _otel_trace .set_span_in_context (parent_span )
292- tracer = (
293- parent_provider .get_tracer (_SCOPE_NAME )
294- if parent_provider is not None
295- else _get_tracer (_SCOPE_NAME )
296- )
299+ if parent_provider is not None :
300+ tracer = parent_provider .get_tracer (_SCOPE_NAME )
301+ else :
302+ tracer , _fallback_record_content = _get_tracer_route (_SCOPE_NAME )
297303 else :
298304 parent_context = None
299305 tracer = _get_tracer (_SCOPE_NAME )
306+ # Without an execute_tool parent there is no task-scoped owner.
307+ # The provider stack is process-global, so borrowing its content flag
308+ # could leak details from a different concurrent Tracer. Fail closed.
309+ record_content = False
300310 attrs : dict [str , Any ] = {
301311 _MCP_METHOD_NAME : method ,
302312 _GEN_AI_OPERATION_NAME : "execute_tool" ,
@@ -318,35 +328,48 @@ async def mcp_client_span(
318328 attributes = attrs ,
319329 context = parent_context ,
320330 )
331+ content_token = _record_content_context .set (record_content )
321332 try :
322- # Disable use_span's default record_exception / set_status_on_exception
323- # so we are the single source of the exception event and ERROR
324- # status — otherwise OTel would auto-record on context exit AND
325- # this ``except`` block would record again, double-counting.
326- with _otel_trace .use_span (
327- span ,
328- record_exception = False ,
329- set_status_on_exception = False ,
330- ):
331- yield span
332- except BaseException as exc :
333333 try :
334- error_type = _error_type_for (exc )
335- span .set_attribute (_ERROR_TYPE , error_type )
336- # Cancellation is a control signal, not a failure — match the
337- # convention from the chat / turn / invoke_agent spans: leave
338- # Status UNSET and mark cubepi.aborted=true, do NOT record an
339- # exception event.
340- if error_type == "cubepi.aborted" :
341- span .set_attribute ("cubepi.aborted" , True )
342- else :
343- span .set_status (Status (StatusCode .ERROR , str (exc )[:256 ]))
344- span .record_exception (exc )
345- finally :
334+ # Disable use_span's default record_exception / set_status_on_exception
335+ # so we are the single source of the exception event and ERROR
336+ # status — otherwise OTel would auto-record on context exit AND
337+ # this ``except`` block would record again, double-counting.
338+ with _otel_trace .use_span (
339+ span ,
340+ record_exception = False ,
341+ set_status_on_exception = False ,
342+ ):
343+ yield span
344+ except BaseException as exc :
345+ try :
346+ error_type = _error_type_for (exc )
347+ span .set_attribute (_ERROR_TYPE , error_type )
348+ # Cancellation is a control signal, not a failure — match the
349+ # convention from the chat / turn / invoke_agent spans: leave
350+ # Status UNSET and mark cubepi.aborted=true, do NOT record an
351+ # exception event.
352+ if error_type == "cubepi.aborted" :
353+ span .set_attribute ("cubepi.aborted" , True )
354+ else :
355+ description = (
356+ str (exc )[:256 ] if record_content else "mcp client error"
357+ )
358+ span .set_status (Status (StatusCode .ERROR , description ))
359+ if record_content :
360+ span .record_exception (exc )
361+ else :
362+ span .add_event (
363+ "exception" ,
364+ attributes = {"exception.type" : type (exc ).__name__ },
365+ )
366+ finally :
367+ span .end ()
368+ raise
369+ else :
346370 span .end ()
347- raise
348- else :
349- span .end ()
371+ finally :
372+ _record_content_context .reset (content_token )
350373
351374
352375def mark_span_mcp_error (span : Any , message : str ) -> None :
@@ -362,7 +385,10 @@ def mark_span_mcp_error(span: Any, message: str) -> None:
362385 """
363386 if span is None or not _OTEL_AVAILABLE :
364387 return
365- span .set_status (Status (StatusCode .ERROR , message [:256 ]))
388+ description = (
389+ message [:256 ] if _record_content_context .get () else "mcp protocol error"
390+ )
391+ span .set_status (Status (StatusCode .ERROR , description ))
366392 span .set_attribute (_ERROR_TYPE , "mcp.is_error" )
367393
368394
0 commit comments