From 268611f0630638308ee3eddd9d9531302779f9e3 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Tue, 10 Mar 2026 13:27:25 -0700 Subject: [PATCH 01/13] feat(core): add traces as per semantic convention with context propagation --- packages/toolbox-core/pyproject.toml | 4 +- .../toolbox_core/mcp_transport/telemetry.py | 162 ++++++++++++++++++ .../mcp_transport/v20241105/mcp.py | 56 +++++- .../mcp_transport/v20250326/mcp.py | 54 +++++- .../mcp_transport/v20250618/mcp.py | 53 +++++- .../mcp_transport/v20251125/mcp.py | 72 +++++++- .../mcp_transport/v20251125/types.py | 10 ++ 7 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py diff --git a/packages/toolbox-core/pyproject.toml b/packages/toolbox-core/pyproject.toml index 68c490483..da2c192dc 100644 --- a/packages/toolbox-core/pyproject.toml +++ b/packages/toolbox-core/pyproject.toml @@ -14,7 +14,9 @@ dependencies = [ "aiohttp>=3.8.6,<4.0.0", "deprecated>=1.2.15,<2.0.0", "google-auth>=2.0.0,<3.0.0", - "requests>=2.19.0,<3.0.0" + "requests>=2.19.0,<3.0.0", + "opentelemetry-api>=1.20.0,<2.0.0", + "opentelemetry-sdk>=1.20.0,<2.0.0" ] classifiers = [ diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py new file mode 100644 index 000000000..e525ae874 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -0,0 +1,162 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenTelemetry telemetry utilities for MCP protocol. + +This module implements telemetry following the MCP Semantic Conventions: +https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp +""" + +from typing import Optional +from urllib.parse import urlparse + +from opentelemetry import trace +from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + +# Attribute names following MCP semantic conventions +ATTR_MCP_METHOD_NAME = "mcp.method.name" +ATTR_MCP_PROTOCOL_VERSION = "mcp.protocol.version" +ATTR_MCP_SESSION_ID = "mcp.session.id" +ATTR_JSONRPC_REQUEST_ID = "jsonrpc.request.id" +ATTR_ERROR_TYPE = "error.type" +ATTR_GEN_AI_TOOL_NAME = "gen_ai.tool.name" +ATTR_GEN_AI_PROMPT_NAME = "gen_ai.prompt.name" +ATTR_SERVER_ADDRESS = "server.address" +ATTR_SERVER_PORT = "server.port" +ATTR_NETWORK_TRANSPORT = "network.transport" + + +def get_tracer(name: str = "toolbox-core-mcp", version: Optional[str] = None) -> Tracer: + """Get or create a tracer for MCP operations. + + Args: + name: The tracer name + version: The tracer version + + Returns: + An OpenTelemetry Tracer instance + """ + return trace.get_tracer(name, version) + + +def extract_server_info(url: str) -> tuple[str, Optional[int]]: + """Extract server address and port from URL. + + Args: + url: The server URL + + Returns: + Tuple of (server_address, server_port) + """ + parsed = urlparse(url) + return parsed.hostname or parsed.netloc, parsed.port + + +def create_traceparent_from_context() -> str: + """Create W3C traceparent header from current trace context. + + Returns: + W3C traceparent header string in format: + version-trace_id-parent_id-trace_flags + Example: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 + """ + propagator = TraceContextTextMapPropagator() + carrier: dict[str, str] = {} + propagator.inject(carrier) + return carrier.get("traceparent", "") + + +def create_tracestate_from_context() -> str: + """Create W3C tracestate header from current trace context. + + Returns: + W3C tracestate header string + """ + propagator = TraceContextTextMapPropagator() + carrier: dict[str, str] = {} + propagator.inject(carrier) + return carrier.get("tracestate", "") + + +def start_span( + tracer: Tracer, + method_name: str, + protocol_version: str, + server_url: str, + tool_name: Optional[str] = None, +) -> Optional[trace.Span]: + """Start a telemetry span for MCP operations. Returns None if telemetry fails. + + Args: + tracer: The OpenTelemetry tracer + method_name: The MCP method name (e.g., "tools/call", "tools/list") + protocol_version: The MCP protocol version + server_url: The MCP server URL + tool_name: Optional tool name for tools/call operations + + Returns: + The started span, or None if telemetry failed + """ + try: + span_name = f"{method_name} {tool_name}" if tool_name else method_name + span = tracer.start_span(span_name, kind=SpanKind.CLIENT) + + # Set attributes + span.set_attribute(ATTR_MCP_METHOD_NAME, method_name) + span.set_attribute(ATTR_MCP_PROTOCOL_VERSION, protocol_version) + + server_address, server_port = extract_server_info(server_url) + span.set_attribute(ATTR_SERVER_ADDRESS, server_address) + if server_port: + span.set_attribute(ATTR_SERVER_PORT, server_port) + + if tool_name: + span.set_attribute(ATTR_GEN_AI_TOOL_NAME, tool_name) + + return span + except Exception: + # Telemetry failed - continue without it + return None + + +def end_span(span: Optional[trace.Span], error: Optional[Exception] = None) -> None: + """End a telemetry span. Safe to call with None span. + + Args: + span: The span to end (can be None if telemetry failed) + error: Optional exception if operation failed + """ + if span is None: + return + try: + if error: + span.set_status(Status(StatusCode.ERROR, str(error))) + span.set_attribute(ATTR_ERROR_TYPE, type(error).__name__) + span.end() + except Exception: + # Ignore telemetry errors + pass + + +def record_error_from_jsonrpc(span: trace.Span, error_code: int, error_message: str) -> None: + """Record error information from JSON-RPC error response. + + Args: + span: The span to record the error on + error_code: The JSON-RPC error code + error_message: The JSON-RPC error message + """ + span.set_status(Status(StatusCode.ERROR, error_message)) + span.set_attribute(ATTR_ERROR_TYPE, f"jsonrpc.error.{error_code}") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index cffa8b244..6eef6b57c 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -18,6 +18,7 @@ from ... import version from ...protocol import ManifestSchema +from .. import telemetry from ..transport_base import _McpHttpTransportBase from . import types @@ -27,6 +28,10 @@ class McpHttpTransportV20241105(_McpHttpTransportBase): """Transport for the MCP v2024-11-05 protocol.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) + async def _send_request( self, url: str, @@ -94,6 +99,11 @@ async def _initialize_session( ), ) + # Start telemetry span + span = telemetry.start_span( + self._tracer, "initialize", self._protocol_version, self._mcp_base_url + ) + result = await self._send_request( url=self._mcp_base_url, request=types.InitializeRequest(params=params), @@ -101,17 +111,25 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + error = RuntimeError("Failed to initialize session: No response from server.") + telemetry.end_span(span, error=error) + raise error self._server_version = result.serverInfo.version + if result.protocolVersion != self._protocol_version: - raise RuntimeError( + error = RuntimeError( f"MCP version mismatch: client does not support server version {result.protocolVersion}" ) + telemetry.end_span(span, error=error) + raise error + if not result.capabilities.tools: if self._manage_session: await self.close() - raise RuntimeError("Server does not support the 'tools' capability.") + error = RuntimeError("Server does not support the 'tools' capability.") + telemetry.end_span(span, error=error) + raise error await self._send_request( url=self._mcp_base_url, @@ -119,6 +137,8 @@ async def _initialize_session( headers=headers, ) + telemetry.end_span(span) + async def tools_list( self, toolset_name: Optional[str] = None, @@ -128,19 +148,30 @@ async def tools_list( await self._ensure_initialized(headers=headers) url = self._mcp_base_url + (toolset_name if toolset_name else "") + + # Start telemetry span + span = telemetry.start_span( + self._tracer, "tools/list", self._protocol_version, url + ) + result = await self._send_request( url=url, request=types.ListToolsRequest(), headers=headers ) if result is None: - raise RuntimeError("Failed to list tools: No response from server.") + error = RuntimeError("Failed to list tools: No response from server.") + telemetry.end_span(span, error=error) + raise error tools_map = { t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) for t in result.tools } if self._server_version is None: - raise RuntimeError("Server version not available.") + error = RuntimeError("Server version not available.") + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return ManifestSchema(serverVersion=self._server_version, tools=tools_map) async def tool_get( @@ -163,6 +194,15 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) + # Start telemetry span following MCP semantic conventions + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + ) + result = await self._send_request( url=self._mcp_base_url, request=types.CallToolRequest( @@ -170,9 +210,13 @@ async def tool_invoke( ), headers=headers, ) + if result is None: - raise RuntimeError( + error = RuntimeError( f"Failed to invoke tool '{tool_name}': No response from server." ) + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return self._process_tool_result_content(result.content) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index 14023a2a9..0ae44eeb9 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -18,6 +18,7 @@ from ... import version from ...protocol import ManifestSchema +from .. import telemetry from ..transport_base import _McpHttpTransportBase from . import types @@ -30,6 +31,7 @@ class McpHttpTransportV20250326(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._session_id: Optional[str] = None + self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) async def _send_request( self, @@ -110,6 +112,11 @@ async def _initialize_session( ), ) + # Start telemetry span + span = telemetry.start_span( + self._tracer, "initialize", self._protocol_version, self._mcp_base_url + ) + result = await self._send_request( url=self._mcp_base_url, request=types.InitializeRequest(params=params), @@ -117,20 +124,26 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + error = RuntimeError("Failed to initialize session: No response from server.") + telemetry.end_span(span, error=error) + raise error self._server_version = result.serverInfo.version if result.protocolVersion != self._protocol_version: - raise RuntimeError( + error = RuntimeError( "MCP version mismatch: client does not support server version" f" {result.protocolVersion}" ) + telemetry.end_span(span, error=error) + raise error if not result.capabilities.tools: if self._manage_session: await self.close() - raise RuntimeError("Server does not support the 'tools' capability.") + error = RuntimeError("Server does not support the 'tools' capability.") + telemetry.end_span(span, error=error) + raise error # Extract session ID from extra fields (v2025-03-26 specific) # Session ID is captured from headers in _send_request @@ -138,9 +151,11 @@ async def _initialize_session( if not self._session_id: if self._manage_session: await self.close() - raise RuntimeError( + error = RuntimeError( "Server did not return a Mcp-Session-Id during initialization." ) + telemetry.end_span(span, error=error) + raise error await self._send_request( url=self._mcp_base_url, @@ -148,6 +163,8 @@ async def _initialize_session( headers=headers, ) + telemetry.end_span(span) + async def tools_list( self, toolset_name: Optional[str] = None, @@ -157,19 +174,30 @@ async def tools_list( await self._ensure_initialized(headers=headers) url = self._mcp_base_url + (toolset_name if toolset_name else "") + + # Start telemetry span + span = telemetry.start_span( + self._tracer, "tools/list", self._protocol_version, url + ) + result = await self._send_request( url=url, request=types.ListToolsRequest(), headers=headers ) if result is None: - raise RuntimeError("Failed to list tools: No response from server.") + error = RuntimeError("Failed to list tools: No response from server.") + telemetry.end_span(span, error=error) + raise error tools_map = { t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) for t in result.tools } if self._server_version is None: - raise RuntimeError("Server version not available.") + error = RuntimeError("Server version not available.") + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return ManifestSchema( serverVersion=self._server_version, tools=tools_map, @@ -195,6 +223,15 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) + # Start telemetry span following MCP semantic conventions + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + ) + result = await self._send_request( url=self._mcp_base_url, request=types.CallToolRequest( @@ -204,8 +241,11 @@ async def tool_invoke( ) if result is None: - raise RuntimeError( + error = RuntimeError( f"Failed to invoke tool '{tool_name}': No response from server." ) + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return self._process_tool_result_content(result.content) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index 81e0bc183..67af987a8 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -18,6 +18,7 @@ from ... import version from ...protocol import ManifestSchema +from .. import telemetry from ..transport_base import _McpHttpTransportBase from . import types @@ -27,6 +28,10 @@ class McpHttpTransportV20250618(_McpHttpTransportBase): """Transport for the MCP v2025-06-18 protocol.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) + async def _send_request( self, url: str, @@ -101,6 +106,11 @@ async def _initialize_session( ), ) + # Start telemetry span + span = telemetry.start_span( + self._tracer, "initialize", self._protocol_version, self._mcp_base_url + ) + result = await self._send_request( url=self._mcp_base_url, request=types.InitializeRequest(params=params), @@ -108,20 +118,26 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + error = RuntimeError("Failed to initialize session: No response from server.") + telemetry.end_span(span, error=error) + raise error self._server_version = result.serverInfo.version if result.protocolVersion != self._protocol_version: - raise RuntimeError( + error = RuntimeError( "MCP version mismatch: client does not support server version" f" {result.protocolVersion}" ) + telemetry.end_span(span, error=error) + raise error if not result.capabilities.tools: if self._manage_session: await self.close() - raise RuntimeError("Server does not support the 'tools' capability.") + error = RuntimeError("Server does not support the 'tools' capability.") + telemetry.end_span(span, error=error) + raise error await self._send_request( url=self._mcp_base_url, @@ -129,6 +145,8 @@ async def _initialize_session( headers=headers, ) + telemetry.end_span(span) + async def tools_list( self, toolset_name: Optional[str] = None, @@ -138,19 +156,30 @@ async def tools_list( await self._ensure_initialized(headers=headers) url = self._mcp_base_url + (toolset_name if toolset_name else "") + + # Start telemetry span + span = telemetry.start_span( + self._tracer, "tools/list", self._protocol_version, url + ) + result = await self._send_request( url=url, request=types.ListToolsRequest(), headers=headers ) if result is None: - raise RuntimeError("Failed to list tools: No response from server.") + error = RuntimeError("Failed to list tools: No response from server.") + telemetry.end_span(span, error=error) + raise error tools_map = { t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) for t in result.tools } if self._server_version is None: - raise RuntimeError("Server version not available.") + error = RuntimeError("Server version not available.") + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return ManifestSchema( serverVersion=self._server_version, tools=tools_map, @@ -176,6 +205,15 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) + # Start telemetry span following MCP semantic conventions + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + ) + result = await self._send_request( url=self._mcp_base_url, request=types.CallToolRequest( @@ -185,8 +223,11 @@ async def tool_invoke( ) if result is None: - raise RuntimeError( + error = RuntimeError( f"Failed to invoke tool '{tool_name}': No response from server." ) + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return self._process_tool_result_content(result.content) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index 81ab05cbb..83051fbed 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -18,6 +18,7 @@ from ... import version from ...protocol import ManifestSchema +from .. import telemetry from ..transport_base import _McpHttpTransportBase from . import types @@ -27,6 +28,10 @@ class McpHttpTransportV20251125(_McpHttpTransportBase): """Transport for the MCP v2025-11-25 protocol.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) + async def _send_request( self, url: str, @@ -38,7 +43,7 @@ async def _send_request( req_headers["MCP-Protocol-Version"] = self._protocol_version params = ( - request.params.model_dump(mode="json", exclude_none=True) + request.params.model_dump(mode="json", exclude_none=True, by_alias=True) if isinstance(request.params, BaseModel) else request.params ) @@ -101,6 +106,11 @@ async def _initialize_session( ), ) + # Start telemetry span + span = telemetry.start_span( + self._tracer, "initialize", self._protocol_version, self._mcp_base_url + ) + result = await self._send_request( url=self._mcp_base_url, request=types.InitializeRequest(params=params), @@ -108,20 +118,26 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + error = RuntimeError("Failed to initialize session: No response from server.") + telemetry.end_span(span, error=error) + raise error self._server_version = result.serverInfo.version if result.protocolVersion != self._protocol_version: - raise RuntimeError( + error = RuntimeError( "MCP version mismatch: client does not support server version" f" {result.protocolVersion}" ) + telemetry.end_span(span, error=error) + raise error if not result.capabilities.tools: if self._manage_session: await self.close() - raise RuntimeError("Server does not support the 'tools' capability.") + error = RuntimeError("Server does not support the 'tools' capability.") + telemetry.end_span(span, error=error) + raise error await self._send_request( url=self._mcp_base_url, @@ -129,6 +145,9 @@ async def _initialize_session( headers=headers, ) + # End span on success + telemetry.end_span(span) + async def tools_list( self, toolset_name: Optional[str] = None, @@ -138,19 +157,30 @@ async def tools_list( await self._ensure_initialized(headers=headers) url = self._mcp_base_url + (toolset_name if toolset_name else "") + + # Start telemetry span + span = telemetry.start_span( + self._tracer, "tools/list", self._protocol_version, url + ) + result = await self._send_request( url=url, request=types.ListToolsRequest(), headers=headers ) if result is None: - raise RuntimeError("Failed to list tools: No response from server.") + error = RuntimeError("Failed to list tools: No response from server.") + telemetry.end_span(span, error=error) + raise error tools_map = { t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) for t in result.tools } if self._server_version is None: - raise RuntimeError("Server version not available.") + error = RuntimeError("Server version not available.") + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return ManifestSchema( serverVersion=self._server_version, tools=tools_map, @@ -176,17 +206,43 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) + # Start telemetry span following MCP semantic conventions + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + ) + + # Generate trace context for OpenTelemetry context propagation + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + result = await self._send_request( url=self._mcp_base_url, request=types.CallToolRequest( - params=types.CallToolRequestParams(name=tool_name, arguments=arguments) + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) ), headers=headers, ) if result is None: - raise RuntimeError( + error = RuntimeError( f"Failed to invoke tool '{tool_name}': No response from server." ) + telemetry.end_span(span, error=error) + raise error + telemetry.end_span(span) return self._process_tool_result_content(result.content) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py index 4cbcfa992..4fc3b9dda 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py @@ -147,9 +147,19 @@ def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult +class MCPMeta(_BaseMCPModel): + """Metadata for MCP requests including OpenTelemetry trace context.""" + + traceparent: str | None = None + tracestate: str | None = None + + class CallToolRequestParams(_BaseMCPModel): name: str arguments: dict[str, Any] + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: MCPMeta | None = Field(default=None, alias="_meta") class CallToolRequest(MCPRequest[CallToolResult]): From 1545d8886464085b8eb328282881960a37de3194 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Tue, 10 Mar 2026 18:32:15 -0700 Subject: [PATCH 02/13] feat(core): collectry otel span attributes --- .../toolbox_core/mcp_transport/telemetry.py | 26 ++++++++++++++----- .../mcp_transport/v20241105/mcp.py | 19 +++++++++++--- .../mcp_transport/v20250326/mcp.py | 19 +++++++++++--- .../mcp_transport/v20250618/mcp.py | 20 +++++++++++--- .../mcp_transport/v20251125/mcp.py | 17 +++++++++--- 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py index e525ae874..7c5c909e1 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -29,13 +29,14 @@ ATTR_MCP_METHOD_NAME = "mcp.method.name" ATTR_MCP_PROTOCOL_VERSION = "mcp.protocol.version" ATTR_MCP_SESSION_ID = "mcp.session.id" -ATTR_JSONRPC_REQUEST_ID = "jsonrpc.request.id" ATTR_ERROR_TYPE = "error.type" ATTR_GEN_AI_TOOL_NAME = "gen_ai.tool.name" +ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name" ATTR_GEN_AI_PROMPT_NAME = "gen_ai.prompt.name" ATTR_SERVER_ADDRESS = "server.address" ATTR_SERVER_PORT = "server.port" ATTR_NETWORK_TRANSPORT = "network.transport" +ATTR_NETWORK_PROTOCOL_NAME = "network.protocol.name" def get_tracer(name: str = "toolbox-core-mcp", version: Optional[str] = None) -> Tracer: @@ -51,17 +52,18 @@ def get_tracer(name: str = "toolbox-core-mcp", version: Optional[str] = None) -> return trace.get_tracer(name, version) -def extract_server_info(url: str) -> tuple[str, Optional[int]]: - """Extract server address and port from URL. +def extract_server_info(url: str) -> tuple[str, Optional[int], str]: + """Extract server address, port, and protocol from URL. Args: url: The server URL Returns: - Tuple of (server_address, server_port) + Tuple of (server_address, server_port, protocol_name) """ parsed = urlparse(url) - return parsed.hostname or parsed.netloc, parsed.port + protocol_name = parsed.scheme if parsed.scheme else "http" + return parsed.hostname or parsed.netloc, parsed.port, protocol_name def create_traceparent_from_context() -> str: @@ -96,6 +98,7 @@ def start_span( protocol_version: str, server_url: str, tool_name: Optional[str] = None, + network_transport: Optional[str] = None, ) -> Optional[trace.Span]: """Start a telemetry span for MCP operations. Returns None if telemetry fails. @@ -105,6 +108,7 @@ def start_span( protocol_version: The MCP protocol version server_url: The MCP server URL tool_name: Optional tool name for tools/call operations + network_transport: Optional network transport type ("tcp" for HTTP/HTTPS, "pipe" for stdio) Returns: The started span, or None if telemetry failed @@ -113,17 +117,25 @@ def start_span( span_name = f"{method_name} {tool_name}" if tool_name else method_name span = tracer.start_span(span_name, kind=SpanKind.CLIENT) - # Set attributes + # Required: MCP method name span.set_attribute(ATTR_MCP_METHOD_NAME, method_name) span.set_attribute(ATTR_MCP_PROTOCOL_VERSION, protocol_version) - server_address, server_port = extract_server_info(server_url) + # Extract server info and network protocol from URL + server_address, server_port, protocol_name = extract_server_info(server_url) span.set_attribute(ATTR_SERVER_ADDRESS, server_address) + span.set_attribute(ATTR_NETWORK_PROTOCOL_NAME, protocol_name) if server_port: span.set_attribute(ATTR_SERVER_PORT, server_port) + # Network transport ("tcp" for HTTP/HTTPS, "pipe" for stdio) + if network_transport: + span.set_attribute(ATTR_NETWORK_TRANSPORT, network_transport) + if tool_name: span.set_attribute(ATTR_GEN_AI_TOOL_NAME, tool_name) + if method_name == "tools/call": + span.set_attribute(ATTR_GEN_AI_OPERATION_NAME, "execute_tool") return span except Exception: diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index 6eef6b57c..accc2dae8 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -99,9 +99,13 @@ async def _initialize_session( ), ) - # Start telemetry span + # Start telemetry span for initialize operation span = telemetry.start_span( - self._tracer, "initialize", self._protocol_version, self._mcp_base_url + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", ) result = await self._send_request( @@ -149,9 +153,13 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span + # Start telemetry span for tools/list operation span = telemetry.start_span( - self._tracer, "tools/list", self._protocol_version, url + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", ) result = await self._send_request( @@ -195,12 +203,15 @@ async def tool_invoke( await self._ensure_initialized(headers=headers) # Start telemetry span following MCP semantic conventions + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + # gen_ai.operation.name and gen_ai.tool.name are set automatically in start_span span = telemetry.start_span( self._tracer, "tools/call", self._protocol_version, self._mcp_base_url, tool_name=tool_name, + network_transport="tcp", ) result = await self._send_request( diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index 0ae44eeb9..51b949607 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -112,9 +112,13 @@ async def _initialize_session( ), ) - # Start telemetry span + # Start telemetry span for initialize operation span = telemetry.start_span( - self._tracer, "initialize", self._protocol_version, self._mcp_base_url + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", ) result = await self._send_request( @@ -175,9 +179,13 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span + # Start telemetry span for tools/list operation span = telemetry.start_span( - self._tracer, "tools/list", self._protocol_version, url + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", ) result = await self._send_request( @@ -224,12 +232,15 @@ async def tool_invoke( await self._ensure_initialized(headers=headers) # Start telemetry span following MCP semantic conventions + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + # gen_ai.operation.name and gen_ai.tool.name are set automatically in start_span span = telemetry.start_span( self._tracer, "tools/call", self._protocol_version, self._mcp_base_url, tool_name=tool_name, + network_transport="tcp", ) result = await self._send_request( diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index 67af987a8..a49e6a0b2 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -106,9 +106,14 @@ async def _initialize_session( ), ) - # Start telemetry span + # Start telemetry span for initialize operation + # HTTP transport uses TCP as the underlying network transport span = telemetry.start_span( - self._tracer, "initialize", self._protocol_version, self._mcp_base_url + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", ) result = await self._send_request( @@ -157,9 +162,13 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span + # Start telemetry span for tools/list operation span = telemetry.start_span( - self._tracer, "tools/list", self._protocol_version, url + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", ) result = await self._send_request( @@ -206,12 +215,15 @@ async def tool_invoke( await self._ensure_initialized(headers=headers) # Start telemetry span following MCP semantic conventions + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + # gen_ai.operation.name and gen_ai.tool.name are set automatically in start_span span = telemetry.start_span( self._tracer, "tools/call", self._protocol_version, self._mcp_base_url, tool_name=tool_name, + network_transport="tcp", ) result = await self._send_request( diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index 83051fbed..78b6c613e 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -106,9 +106,13 @@ async def _initialize_session( ), ) - # Start telemetry span + # Start telemetry span for initialize operation span = telemetry.start_span( - self._tracer, "initialize", self._protocol_version, self._mcp_base_url + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", ) result = await self._send_request( @@ -158,9 +162,13 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span + # Start telemetry span for tools/list operation span = telemetry.start_span( - self._tracer, "tools/list", self._protocol_version, url + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", ) result = await self._send_request( @@ -214,6 +222,7 @@ async def tool_invoke( self._protocol_version, self._mcp_base_url, tool_name=tool_name, + network_transport="tcp", ) # Generate trace context for OpenTelemetry context propagation From 2ca0d2b274f409a84de8988eeaa97b120c7b212e Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Tue, 10 Mar 2026 19:10:24 -0700 Subject: [PATCH 03/13] feat(core): set up otlp param for exposing telemetry --- packages/toolbox-core/pyproject.toml | 3 +- .../toolbox-core/src/toolbox_core/client.py | 13 +++++++ .../toolbox_core/mcp_transport/telemetry.py | 37 +++++++++++++++++++ .../src/toolbox_core/sync_client.py | 8 ++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/toolbox-core/pyproject.toml b/packages/toolbox-core/pyproject.toml index da2c192dc..fe698712c 100644 --- a/packages/toolbox-core/pyproject.toml +++ b/packages/toolbox-core/pyproject.toml @@ -16,7 +16,8 @@ dependencies = [ "google-auth>=2.0.0,<3.0.0", "requests>=2.19.0,<3.0.0", "opentelemetry-api>=1.20.0,<2.0.0", - "opentelemetry-sdk>=1.20.0,<2.0.0" + "opentelemetry-sdk>=1.20.0,<2.0.0", + "opentelemetry-exporter-otlp>=1.20.0,<2.0.0" ] classifiers = [ diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index 79e598e64..e2dad3e90 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -28,6 +28,7 @@ McpHttpTransportV20250618, McpHttpTransportV20251125, ) +from .mcp_transport.telemetry import setup_otlp_tracer_provider from .protocol import Protocol, ToolSchema from .tool import ToolboxTool from .utils import identify_auth_requirements, resolve_value, warn_if_http_and_headers @@ -54,6 +55,7 @@ def __init__( protocol: Protocol = Protocol.MCP, client_name: Optional[str] = None, client_version: Optional[str] = None, + telemetry_otlp: Optional[str] = None, ): """ Initializes the ToolboxClient. @@ -67,7 +69,18 @@ def __init__( client_headers: Headers to include in each request sent through this client. protocol: The communication protocol to use. + client_name: Optional client name for identification. + client_version: Optional client version for identification. + telemetry_otlp: Optional OTLP endpoint URL for sending telemetry + (e.g., "http://localhost:4318"). If provided, sets up an OTLP + tracer provider to export traces to this endpoint. """ + + print("[AGNOST AI] : Initializing ToolboxClient with protocol", telemetry_otlp, url) + # Setup OTLP tracer provider if endpoint is provided + if telemetry_otlp: + setup_otlp_tracer_provider(telemetry_otlp) + if protocol != Protocol.MCP_LATEST: logging.warning( f"A newer version of MCP ({Protocol.MCP_LATEST.value}) is available. " diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py index 7c5c909e1..cc9bc14ab 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -22,6 +22,10 @@ from urllib.parse import urlparse from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator @@ -172,3 +176,36 @@ def record_error_from_jsonrpc(span: trace.Span, error_code: int, error_message: """ span.set_status(Status(StatusCode.ERROR, error_message)) span.set_attribute(ATTR_ERROR_TYPE, f"jsonrpc.error.{error_code}") + + +def setup_otlp_tracer_provider( + otlp_endpoint: str, service_name: str = "toolbox-core-mcp" +) -> None: + """Setup OTLP tracer provider to send telemetry to an OTLP endpoint. + + Args: + otlp_endpoint: The OTLP endpoint URL (e.g., "http://localhost:4318") + service_name: The service name for the resource + """ + # Create resource with service name + resource = Resource.create({"service.name": service_name}) + + # Create OTLP exporter with the provided endpoint + # The endpoint should include the full path for HTTP: http://host:port/v1/traces + # If only host:port is provided, append the traces path + if not otlp_endpoint.endswith("/v1/traces"): + if otlp_endpoint.endswith("/"): + otlp_endpoint = otlp_endpoint + "v1/traces" + else: + otlp_endpoint = otlp_endpoint + "/v1/traces" + + otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint) + + # Create tracer provider with resource + tracer_provider = TracerProvider(resource=resource) + + # Add batch span processor with OTLP exporter + tracer_provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) + + # Set as global tracer provider + trace.set_tracer_provider(tracer_provider) diff --git a/packages/toolbox-core/src/toolbox_core/sync_client.py b/packages/toolbox-core/src/toolbox_core/sync_client.py index 606c27234..2b0efd281 100644 --- a/packages/toolbox-core/src/toolbox_core/sync_client.py +++ b/packages/toolbox-core/src/toolbox_core/sync_client.py @@ -45,6 +45,7 @@ def __init__( protocol: Protocol = Protocol.MCP, client_name: Optional[str] = None, client_version: Optional[str] = None, + telemetry_otlp: Optional[str] = None, ): """ Initializes the ToolboxSyncClient. @@ -52,6 +53,12 @@ def __init__( Args: url: The base URL for the Toolbox service API (e.g., "http://localhost:5000"). client_headers: Headers to include in each request sent through this client. + protocol: The communication protocol to use. + client_name: Optional client name for identification. + client_version: Optional client version for identification. + telemetry_otlp: Optional OTLP endpoint URL for sending telemetry + (e.g., "http://localhost:4318"). If provided, sets up an OTLP + tracer provider to export traces to this endpoint. """ # Running a loop in a background thread allows us to support async # methods from non-async environments. @@ -71,6 +78,7 @@ async def create_client(): protocol=protocol, client_name=client_name, client_version=client_version, + telemetry_otlp=telemetry_otlp, ) self.__async_client = run_coroutine_threadsafe( From cc20e98c44312add8fcbb3cbdb9f916d97ede8ed Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Wed, 11 Mar 2026 11:41:37 -0700 Subject: [PATCH 04/13] feat(core): telemetry context propagation --- .../toolbox_core/mcp_transport/telemetry.py | 2 +- .../mcp_transport/v20241105/mcp.py | 20 +++++++++++++-- .../mcp_transport/v20241105/types.py | 10 ++++++++ .../mcp_transport/v20250326/mcp.py | 20 +++++++++++++-- .../mcp_transport/v20250326/types.py | 10 ++++++++ .../mcp_transport/v20250618/mcp.py | 20 +++++++++++++-- .../mcp_transport/v20250618/types.py | 10 ++++++++ .../mcp_transport/v20251125/mcp.py | 25 +++++++++++-------- .../mcp_transport/v20251125/types.py | 2 +- 9 files changed, 100 insertions(+), 19 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py index 7c5c909e1..fad0419ec 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -39,7 +39,7 @@ ATTR_NETWORK_PROTOCOL_NAME = "network.protocol.name" -def get_tracer(name: str = "toolbox-core-mcp", version: Optional[str] = None) -> Tracer: +def get_tracer(name: str = "toolbox", version: Optional[str] = None) -> Tracer: """Get or create a tracer for MCP operations. Args: diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index accc2dae8..5a2b75d42 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -14,6 +14,7 @@ from typing import Mapping, Optional, TypeVar +from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -30,7 +31,7 @@ class McpHttpTransportV20241105(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) + self._tracer = telemetry.get_tracer("toolbox", version.__version__) async def _send_request( self, @@ -214,10 +215,25 @@ async def tool_invoke( network_transport="tcp", ) + meta: Optional[types.MCPMeta] = None + + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + result = await self._send_request( url=self._mcp_base_url, request=types.CallToolRequest( - params=types.CallToolRequestParams(name=tool_name, arguments=arguments) + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) ), headers=headers, ) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py index 5cfca277a..0523c01f3 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py @@ -147,9 +147,19 @@ def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult +class MCPMeta(_BaseMCPModel): + """Metadata for MCP requests including OpenTelemetry trace context.""" + + traceparent: str | None = None + tracestate: str | None = None + + class CallToolRequestParams(_BaseMCPModel): name: str arguments: dict[str, Any] + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: MCPMeta | None = Field(default=None, serialization_alias="_meta") class CallToolRequest(MCPRequest[CallToolResult]): diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index 51b949607..917356013 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -14,6 +14,7 @@ from typing import Mapping, Optional, TypeVar +from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -31,7 +32,7 @@ class McpHttpTransportV20250326(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._session_id: Optional[str] = None - self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) + self._tracer = telemetry.get_tracer("toolbox", version.__version__) async def _send_request( self, @@ -243,10 +244,25 @@ async def tool_invoke( network_transport="tcp", ) + meta: Optional[types.MCPMeta] = None + + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + result = await self._send_request( url=self._mcp_base_url, request=types.CallToolRequest( - params=types.CallToolRequestParams(name=tool_name, arguments=arguments) + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) ), headers=headers, ) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py index 5cfca277a..0523c01f3 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py @@ -147,9 +147,19 @@ def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult +class MCPMeta(_BaseMCPModel): + """Metadata for MCP requests including OpenTelemetry trace context.""" + + traceparent: str | None = None + tracestate: str | None = None + + class CallToolRequestParams(_BaseMCPModel): name: str arguments: dict[str, Any] + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: MCPMeta | None = Field(default=None, serialization_alias="_meta") class CallToolRequest(MCPRequest[CallToolResult]): diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index a49e6a0b2..e55a67ef6 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -14,6 +14,7 @@ from typing import Mapping, Optional, TypeVar +from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -30,7 +31,7 @@ class McpHttpTransportV20250618(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) + self._tracer = telemetry.get_tracer("toolbox", version.__version__) async def _send_request( self, @@ -226,10 +227,25 @@ async def tool_invoke( network_transport="tcp", ) + meta: Optional[types.MCPMeta] = None + + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + result = await self._send_request( url=self._mcp_base_url, request=types.CallToolRequest( - params=types.CallToolRequestParams(name=tool_name, arguments=arguments) + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) ), headers=headers, ) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py index 5cfca277a..0523c01f3 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py @@ -147,9 +147,19 @@ def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult +class MCPMeta(_BaseMCPModel): + """Metadata for MCP requests including OpenTelemetry trace context.""" + + traceparent: str | None = None + tracestate: str | None = None + + class CallToolRequestParams(_BaseMCPModel): name: str arguments: dict[str, Any] + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: MCPMeta | None = Field(default=None, serialization_alias="_meta") class CallToolRequest(MCPRequest[CallToolResult]): diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index 78b6c613e..9b75eda5d 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -14,6 +14,7 @@ from typing import Mapping, Optional, TypeVar +from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -30,7 +31,7 @@ class McpHttpTransportV20251125(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._tracer = telemetry.get_tracer("toolbox-core-mcp", version.__version__) + self._tracer = telemetry.get_tracer("toolbox", version.__version__) async def _send_request( self, @@ -225,16 +226,18 @@ async def tool_invoke( network_transport="tcp", ) - # Generate trace context for OpenTelemetry context propagation - # The client span becomes the parent of the server span through this context - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation - traceparent = telemetry.create_traceparent_from_context() - tracestate = telemetry.create_tracestate_from_context() - - meta = types.MCPMeta( - traceparent=traceparent, - tracestate=tracestate, - ) + meta: Optional[types.MCPMeta] = None + + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) result = await self._send_request( url=self._mcp_base_url, diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py index 4fc3b9dda..c6c76530a 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py @@ -159,7 +159,7 @@ class CallToolRequestParams(_BaseMCPModel): arguments: dict[str, Any] # OpenTelemetry trace context propagation # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation - field_meta: MCPMeta | None = Field(default=None, alias="_meta") + field_meta: MCPMeta | None = Field(default=None, serialization_alias="_meta") class CallToolRequest(MCPRequest[CallToolResult]): From 7d2e5ed38de840af2b35abd9c431fdfe72ef64b5 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Wed, 11 Mar 2026 11:49:20 -0700 Subject: [PATCH 05/13] feat: metrics as per semantic convention v20251125 --- .../toolbox_core/mcp_transport/telemetry.py | 171 ++++++++++++++- .../mcp_transport/v20251125/mcp.py | 199 ++++++++++++------ 2 files changed, 299 insertions(+), 71 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py index fad0419ec..77e1f28cc 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -21,7 +21,8 @@ from typing import Optional from urllib.parse import urlparse -from opentelemetry import trace +from opentelemetry import metrics, trace +from opentelemetry.metrics import Histogram, Meter from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator @@ -38,6 +39,14 @@ ATTR_NETWORK_TRANSPORT = "network.transport" ATTR_NETWORK_PROTOCOL_NAME = "network.protocol.name" +# Metric names following MCP semantic conventions +METRIC_CLIENT_OPERATION_DURATION = "mcp.client.operation.duration" +METRIC_CLIENT_SESSION_DURATION = "mcp.client.session.duration" + +# Histogram bucket boundaries for MCP metrics (in seconds) +# As specified in: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#metrics +MCP_DURATION_BUCKETS = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300] + def get_tracer(name: str = "toolbox", version: Optional[str] = None) -> Tracer: """Get or create a tracer for MCP operations. @@ -52,6 +61,59 @@ def get_tracer(name: str = "toolbox", version: Optional[str] = None) -> Tracer: return trace.get_tracer(name, version) +def get_meter(name: str = "toolbox-core-mcp", version: Optional[str] = None) -> Meter: + """Get or create a meter for MCP metrics. + + Args: + name: The meter name + version: The meter version + + Returns: + An OpenTelemetry Meter instance + """ + return metrics.get_meter(name, version) + + +def create_operation_duration_histogram(meter: Meter) -> Optional[Histogram]: + """Create histogram for MCP client operation duration. + + Args: + meter: The OpenTelemetry meter + + Returns: + Histogram instance or None if creation failed + """ + try: + return meter.create_histogram( + name=METRIC_CLIENT_OPERATION_DURATION, + unit="s", + description="Duration of MCP client operations (requests/notifications)", + # Note: explicit_bucket_boundaries parameter may not be available in all versions + # If not available, the SDK will use default buckets + ) + except Exception: + return None + + +def create_session_duration_histogram(meter: Meter) -> Optional[Histogram]: + """Create histogram for MCP client session duration. + + Args: + meter: The OpenTelemetry meter + + Returns: + Histogram instance or None if creation failed + """ + try: + return meter.create_histogram( + name=METRIC_CLIENT_SESSION_DURATION, + unit="s", + description="Total duration of MCP client sessions", + ) + except Exception: + return None + + def extract_server_info(url: str) -> tuple[str, Optional[int], str]: """Extract server address, port, and protocol from URL. @@ -172,3 +234,110 @@ def record_error_from_jsonrpc(span: trace.Span, error_code: int, error_message: """ span.set_status(Status(StatusCode.ERROR, error_message)) span.set_attribute(ATTR_ERROR_TYPE, f"jsonrpc.error.{error_code}") + + +def record_operation_duration( + histogram: Optional[Histogram], + duration_seconds: float, + method_name: str, + protocol_version: str, + server_url: str, + tool_name: Optional[str] = None, + network_transport: Optional[str] = None, + error: Optional[Exception] = None, +) -> None: + """Record MCP client operation duration metric. + + Args: + histogram: The operation duration histogram (can be None if metrics failed) + duration_seconds: Duration of the operation in seconds + method_name: The MCP method name (required attribute) + protocol_version: The MCP protocol version (recommended attribute) + server_url: The MCP server URL (for extracting server address/port) + tool_name: Optional tool name for tools/call operations + network_transport: Optional network transport type ("tcp" for HTTP/HTTPS) + error: Optional exception if operation failed (for error.type attribute) + """ + if histogram is None: + return + + try: + # Build attributes dict following MCP semantic conventions + attributes = { + ATTR_MCP_METHOD_NAME: method_name, + ATTR_MCP_PROTOCOL_VERSION: protocol_version, + } + + # Extract and add server info + server_address, server_port, protocol_name = extract_server_info(server_url) + attributes[ATTR_SERVER_ADDRESS] = server_address + attributes[ATTR_NETWORK_PROTOCOL_NAME] = protocol_name + if server_port: + attributes[ATTR_SERVER_PORT] = server_port + + # Add optional network transport + if network_transport: + attributes[ATTR_NETWORK_TRANSPORT] = network_transport + + # Add tool-related attributes for tools/call operations + if tool_name: + attributes[ATTR_GEN_AI_TOOL_NAME] = tool_name + if method_name == "tools/call": + attributes[ATTR_GEN_AI_OPERATION_NAME] = "execute_tool" + + # Add error type if operation failed + if error: + attributes[ATTR_ERROR_TYPE] = type(error).__name__ + + histogram.record(duration_seconds, attributes) + except Exception: + # Ignore metrics recording errors + pass + + +def record_session_duration( + histogram: Optional[Histogram], + duration_seconds: float, + protocol_version: str, + server_url: str, + network_transport: Optional[str] = None, + error: Optional[Exception] = None, +) -> None: + """Record MCP client session duration metric. + + Args: + histogram: The session duration histogram (can be None if metrics failed) + duration_seconds: Duration of the session in seconds + protocol_version: The MCP protocol version (recommended attribute) + server_url: The MCP server URL (for extracting server address/port) + network_transport: Optional network transport type ("tcp" for HTTP/HTTPS) + error: Optional exception if session ended with error + """ + if histogram is None: + return + + try: + # Build attributes dict following MCP semantic conventions + attributes = { + ATTR_MCP_PROTOCOL_VERSION: protocol_version, + } + + # Extract and add server info + server_address, server_port, protocol_name = extract_server_info(server_url) + attributes[ATTR_SERVER_ADDRESS] = server_address + attributes[ATTR_NETWORK_PROTOCOL_NAME] = protocol_name + if server_port: + attributes[ATTR_SERVER_PORT] = server_port + + # Add optional network transport + if network_transport: + attributes[ATTR_NETWORK_TRANSPORT] = network_transport + + # Add error type if session ended with error + if error: + attributes[ATTR_ERROR_TYPE] = type(error).__name__ + + histogram.record(duration_seconds, attributes) + except Exception: + # Ignore metrics recording errors + pass diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index 9b75eda5d..94bab22af 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import time from typing import Mapping, Optional, TypeVar from opentelemetry import trace @@ -33,6 +34,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tracer = telemetry.get_tracer("toolbox", version.__version__) + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox-core-mcp", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + async def _send_request( self, url: str, @@ -98,6 +105,9 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" + # Track session start time for session duration metric + self._session_start_time = time.time() + params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -107,7 +117,8 @@ async def _initialize_session( ), ) - # Start telemetry span for initialize operation + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "initialize", @@ -116,42 +127,48 @@ async def _initialize_session( network_transport="tcp", ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.InitializeRequest(params=params), - headers=headers, - ) - - if result is None: - error = RuntimeError("Failed to initialize session: No response from server.") - telemetry.end_span(span, error=error) - raise error + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.InitializeRequest(params=params), + headers=headers, + ) - self._server_version = result.serverInfo.version + if result is None: + raise RuntimeError("Failed to initialize session: No response from server.") - if result.protocolVersion != self._protocol_version: - error = RuntimeError( - "MCP version mismatch: client does not support server version" - f" {result.protocolVersion}" - ) - telemetry.end_span(span, error=error) - raise error + self._server_version = result.serverInfo.version - if not result.capabilities.tools: - if self._manage_session: - await self.close() - error = RuntimeError("Server does not support the 'tools' capability.") - telemetry.end_span(span, error=error) - raise error + if result.protocolVersion != self._protocol_version: + raise RuntimeError( + "MCP version mismatch: client does not support server version" + f" {result.protocolVersion}" + ) - await self._send_request( - url=self._mcp_base_url, - request=types.InitializedNotification(), - headers=headers, - ) + if not result.capabilities.tools: + if self._manage_session: + await self.close() + raise RuntimeError("Server does not support the 'tools' capability.") - # End span on success - telemetry.end_span(span) + await self._send_request( + url=self._mcp_base_url, + request=types.InitializedNotification(), + headers=headers, + ) + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, + ) + telemetry.end_span(span, error=error) async def tools_list( self, @@ -163,7 +180,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span for tools/list operation + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/list", @@ -172,28 +190,39 @@ async def tools_list( network_transport="tcp", ) - result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers - ) - if result is None: - error = RuntimeError("Failed to list tools: No response from server.") - telemetry.end_span(span, error=error) - raise error - - tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) - for t in result.tools - } - if self._server_version is None: - error = RuntimeError("Server version not available.") + error: Optional[Exception] = None + try: + result = await self._send_request( + url=url, request=types.ListToolsRequest(), headers=headers + ) + if result is None: + raise RuntimeError("Failed to list tools: No response from server.") + + tools_map = { + t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + for t in result.tools + } + if self._server_version is None: + raise RuntimeError("Server version not available.") + + return ManifestSchema( + serverVersion=self._server_version, + tools=tools_map, + ) + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return ManifestSchema( - serverVersion=self._server_version, - tools=tools_map, - ) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -209,14 +238,30 @@ async def tool_get( tools={tool_name: manifest.tools[tool_name]}, ) + async def close(self): + """Closes the MCP session and records session duration metric.""" + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) + # Call parent's close method + await super().close() + async def tool_invoke( self, tool_name: str, arguments: dict, headers: Optional[Mapping[str, str]] ) -> str: """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span following MCP semantic conventions + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/call", @@ -239,22 +284,36 @@ async def tool_invoke( tracestate=tracestate, ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.CallToolRequest( - params=types.CallToolRequestParams( - name=tool_name, arguments=arguments, field_meta=meta + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.CallToolRequest( + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) + ), + headers=headers, + ) + + if result is None: + raise RuntimeError( + f"Failed to invoke tool '{tool_name}': No response from server." ) - ), - headers=headers, - ) - if result is None: - error = RuntimeError( - f"Failed to invoke tool '{tool_name}': No response from server." + return self._process_tool_result_content(result.content) + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return self._process_tool_result_content(result.content) From 03c5a773cefefe65f93dbf34f0612990245ffde7 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Wed, 11 Mar 2026 14:05:03 -0700 Subject: [PATCH 06/13] feat(core): export metrics as per semantic convention --- .../mcp_transport/v20241105/mcp.py | 200 ++++++++++------ .../mcp_transport/v20250326/mcp.py | 224 ++++++++++++------ .../mcp_transport/v20250618/mcp.py | 209 ++++++++++------ 3 files changed, 417 insertions(+), 216 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index 5a2b75d42..2e5f8ce9d 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import time from typing import Mapping, Optional, TypeVar from opentelemetry import trace @@ -33,6 +34,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tracer = telemetry.get_tracer("toolbox", version.__version__) + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + async def _send_request( self, url: str, @@ -91,6 +98,9 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" + # Track session start time for session duration metric + self._session_start_time = time.time() + params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -100,7 +110,8 @@ async def _initialize_session( ), ) - # Start telemetry span for initialize operation + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "initialize", @@ -109,40 +120,50 @@ async def _initialize_session( network_transport="tcp", ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.InitializeRequest(params=params), - headers=headers, - ) - - if result is None: - error = RuntimeError("Failed to initialize session: No response from server.") - telemetry.end_span(span, error=error) - raise error + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.InitializeRequest(params=params), + headers=headers, + ) - self._server_version = result.serverInfo.version + if result is None: + raise RuntimeError("Failed to initialize session: No response from server.") - if result.protocolVersion != self._protocol_version: - error = RuntimeError( - f"MCP version mismatch: client does not support server version {result.protocolVersion}" - ) - telemetry.end_span(span, error=error) - raise error + self._server_version = result.serverInfo.version - if not result.capabilities.tools: - if self._manage_session: - await self.close() - error = RuntimeError("Server does not support the 'tools' capability.") - telemetry.end_span(span, error=error) - raise error + if result.protocolVersion != self._protocol_version: + raise RuntimeError( + f"MCP version mismatch: client does not support server version {result.protocolVersion}" + ) - await self._send_request( - url=self._mcp_base_url, - request=types.InitializedNotification(), - headers=headers, - ) + if not result.capabilities.tools: + if self._manage_session: + await self.close() + raise RuntimeError("Server does not support the 'tools' capability.") - telemetry.end_span(span) + await self._send_request( + url=self._mcp_base_url, + request=types.InitializedNotification(), + headers=headers, + ) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, + ) + telemetry.end_span(span, error=error) async def tools_list( self, @@ -154,7 +175,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span for tools/list operation + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/list", @@ -163,25 +185,39 @@ async def tools_list( network_transport="tcp", ) - result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers - ) - if result is None: - error = RuntimeError("Failed to list tools: No response from server.") - telemetry.end_span(span, error=error) - raise error - - tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) - for t in result.tools - } - if self._server_version is None: - error = RuntimeError("Server version not available.") + error: Optional[Exception] = None + try: + result = await self._send_request( + url=url, request=types.ListToolsRequest(), headers=headers + ) + if result is None: + raise RuntimeError("Failed to list tools: No response from server.") + + tools_map = { + t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + for t in result.tools + } + if self._server_version is None: + raise RuntimeError("Server version not available.") + + return ManifestSchema(serverVersion=self._server_version, tools=tools_map) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return ManifestSchema(serverVersion=self._server_version, tools=tools_map) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -197,15 +233,30 @@ async def tool_get( tools={tool_name: manifest.tools[tool_name]}, ) + async def close(self): + """Closes the MCP session and records session duration metric.""" + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) + # Call parent's close method + await super().close() + async def tool_invoke( self, tool_name: str, arguments: dict, headers: Optional[Mapping[str, str]] ) -> str: """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span following MCP semantic conventions + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp - # gen_ai.operation.name and gen_ai.tool.name are set automatically in start_span + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/call", @@ -228,22 +279,39 @@ async def tool_invoke( tracestate=tracestate, ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.CallToolRequest( - params=types.CallToolRequestParams( - name=tool_name, arguments=arguments, field_meta=meta + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.CallToolRequest( + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) + ), + headers=headers, + ) + + if result is None: + raise RuntimeError( + f"Failed to invoke tool '{tool_name}': No response from server." ) - ), - headers=headers, - ) - if result is None: - error = RuntimeError( - f"Failed to invoke tool '{tool_name}': No response from server." + return self._process_tool_result_content(result.content) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return self._process_tool_result_content(result.content) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index 917356013..0cd31fdff 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import time from typing import Mapping, Optional, TypeVar from opentelemetry import trace @@ -34,6 +35,12 @@ def __init__(self, *args, **kwargs): self._session_id: Optional[str] = None self._tracer = telemetry.get_tracer("toolbox", version.__version__) + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + async def _send_request( self, url: str, @@ -104,6 +111,9 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" + # Track session start time for session duration metric + self._session_start_time = time.time() + params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -113,7 +123,8 @@ async def _initialize_session( ), ) - # Start telemetry span for initialize operation + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "initialize", @@ -122,53 +133,61 @@ async def _initialize_session( network_transport="tcp", ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.InitializeRequest(params=params), - headers=headers, - ) + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.InitializeRequest(params=params), + headers=headers, + ) - if result is None: - error = RuntimeError("Failed to initialize session: No response from server.") - telemetry.end_span(span, error=error) - raise error + if result is None: + raise RuntimeError("Failed to initialize session: No response from server.") - self._server_version = result.serverInfo.version + self._server_version = result.serverInfo.version - if result.protocolVersion != self._protocol_version: - error = RuntimeError( - "MCP version mismatch: client does not support server version" - f" {result.protocolVersion}" - ) - telemetry.end_span(span, error=error) - raise error + if result.protocolVersion != self._protocol_version: + raise RuntimeError( + "MCP version mismatch: client does not support server version" + f" {result.protocolVersion}" + ) - if not result.capabilities.tools: - if self._manage_session: - await self.close() - error = RuntimeError("Server does not support the 'tools' capability.") - telemetry.end_span(span, error=error) - raise error + if not result.capabilities.tools: + if self._manage_session: + await self.close() + raise RuntimeError("Server does not support the 'tools' capability.") + + # Extract session ID from extra fields (v2025-03-26 specific) + # Session ID is captured from headers in _send_request - # Extract session ID from extra fields (v2025-03-26 specific) - # Session ID is captured from headers in _send_request + if not self._session_id: + if self._manage_session: + await self.close() + raise RuntimeError( + "Server did not return a Mcp-Session-Id during initialization." + ) - if not self._session_id: - if self._manage_session: - await self.close() - error = RuntimeError( - "Server did not return a Mcp-Session-Id during initialization." + await self._send_request( + url=self._mcp_base_url, + request=types.InitializedNotification(), + headers=headers, + ) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, ) telemetry.end_span(span, error=error) - raise error - - await self._send_request( - url=self._mcp_base_url, - request=types.InitializedNotification(), - headers=headers, - ) - - telemetry.end_span(span) async def tools_list( self, @@ -180,7 +199,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span for tools/list operation + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/list", @@ -189,28 +209,42 @@ async def tools_list( network_transport="tcp", ) - result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers - ) - if result is None: - error = RuntimeError("Failed to list tools: No response from server.") - telemetry.end_span(span, error=error) - raise error - - tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) - for t in result.tools - } - if self._server_version is None: - error = RuntimeError("Server version not available.") + error: Optional[Exception] = None + try: + result = await self._send_request( + url=url, request=types.ListToolsRequest(), headers=headers + ) + if result is None: + raise RuntimeError("Failed to list tools: No response from server.") + + tools_map = { + t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + for t in result.tools + } + if self._server_version is None: + raise RuntimeError("Server version not available.") + + return ManifestSchema( + serverVersion=self._server_version, + tools=tools_map, + ) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return ManifestSchema( - serverVersion=self._server_version, - tools=tools_map, - ) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -226,15 +260,30 @@ async def tool_get( tools={tool_name: manifest.tools[tool_name]}, ) + async def close(self): + """Closes the MCP session and records session duration metric.""" + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) + # Call parent's close method + await super().close() + async def tool_invoke( self, tool_name: str, arguments: dict, headers: Optional[Mapping[str, str]] ) -> str: """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span following MCP semantic conventions + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp - # gen_ai.operation.name and gen_ai.tool.name are set automatically in start_span + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/call", @@ -257,22 +306,39 @@ async def tool_invoke( tracestate=tracestate, ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.CallToolRequest( - params=types.CallToolRequestParams( - name=tool_name, arguments=arguments, field_meta=meta + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.CallToolRequest( + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) + ), + headers=headers, + ) + + if result is None: + raise RuntimeError( + f"Failed to invoke tool '{tool_name}': No response from server." ) - ), - headers=headers, - ) - if result is None: - error = RuntimeError( - f"Failed to invoke tool '{tool_name}': No response from server." + return self._process_tool_result_content(result.content) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return self._process_tool_result_content(result.content) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index e55a67ef6..35ea06c25 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import time from typing import Mapping, Optional, TypeVar from opentelemetry import trace @@ -33,6 +34,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tracer = telemetry.get_tracer("toolbox", version.__version__) + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + async def _send_request( self, url: str, @@ -98,6 +105,9 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" + # Track session start time for session duration metric + self._session_start_time = time.time() + params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -107,8 +117,8 @@ async def _initialize_session( ), ) - # Start telemetry span for initialize operation - # HTTP transport uses TCP as the underlying network transport + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "initialize", @@ -117,41 +127,51 @@ async def _initialize_session( network_transport="tcp", ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.InitializeRequest(params=params), - headers=headers, - ) - - if result is None: - error = RuntimeError("Failed to initialize session: No response from server.") - telemetry.end_span(span, error=error) - raise error + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.InitializeRequest(params=params), + headers=headers, + ) - self._server_version = result.serverInfo.version + if result is None: + raise RuntimeError("Failed to initialize session: No response from server.") - if result.protocolVersion != self._protocol_version: - error = RuntimeError( - "MCP version mismatch: client does not support server version" - f" {result.protocolVersion}" - ) - telemetry.end_span(span, error=error) - raise error + self._server_version = result.serverInfo.version - if not result.capabilities.tools: - if self._manage_session: - await self.close() - error = RuntimeError("Server does not support the 'tools' capability.") - telemetry.end_span(span, error=error) - raise error + if result.protocolVersion != self._protocol_version: + raise RuntimeError( + "MCP version mismatch: client does not support server version" + f" {result.protocolVersion}" + ) - await self._send_request( - url=self._mcp_base_url, - request=types.InitializedNotification(), - headers=headers, - ) + if not result.capabilities.tools: + if self._manage_session: + await self.close() + raise RuntimeError("Server does not support the 'tools' capability.") - telemetry.end_span(span) + await self._send_request( + url=self._mcp_base_url, + request=types.InitializedNotification(), + headers=headers, + ) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, + ) + telemetry.end_span(span, error=error) async def tools_list( self, @@ -163,7 +183,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span for tools/list operation + # Start telemetry span and track operation start time + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/list", @@ -172,28 +193,42 @@ async def tools_list( network_transport="tcp", ) - result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers - ) - if result is None: - error = RuntimeError("Failed to list tools: No response from server.") - telemetry.end_span(span, error=error) - raise error - - tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) - for t in result.tools - } - if self._server_version is None: - error = RuntimeError("Server version not available.") + error: Optional[Exception] = None + try: + result = await self._send_request( + url=url, request=types.ListToolsRequest(), headers=headers + ) + if result is None: + raise RuntimeError("Failed to list tools: No response from server.") + + tools_map = { + t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + for t in result.tools + } + if self._server_version is None: + raise RuntimeError("Server version not available.") + + return ManifestSchema( + serverVersion=self._server_version, + tools=tools_map, + ) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return ManifestSchema( - serverVersion=self._server_version, - tools=tools_map, - ) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -209,15 +244,30 @@ async def tool_get( tools={tool_name: manifest.tools[tool_name]}, ) + async def close(self): + """Closes the MCP session and records session duration metric.""" + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) + # Call parent's close method + await super().close() + async def tool_invoke( self, tool_name: str, arguments: dict, headers: Optional[Mapping[str, str]] ) -> str: """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span following MCP semantic conventions + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp - # gen_ai.operation.name and gen_ai.tool.name are set automatically in start_span + operation_start = time.time() span = telemetry.start_span( self._tracer, "tools/call", @@ -240,22 +290,39 @@ async def tool_invoke( tracestate=tracestate, ) - result = await self._send_request( - url=self._mcp_base_url, - request=types.CallToolRequest( - params=types.CallToolRequestParams( - name=tool_name, arguments=arguments, field_meta=meta + error: Optional[Exception] = None + try: + result = await self._send_request( + url=self._mcp_base_url, + request=types.CallToolRequest( + params=types.CallToolRequestParams( + name=tool_name, arguments=arguments, field_meta=meta + ) + ), + headers=headers, + ) + + if result is None: + raise RuntimeError( + f"Failed to invoke tool '{tool_name}': No response from server." ) - ), - headers=headers, - ) - if result is None: - error = RuntimeError( - f"Failed to invoke tool '{tool_name}': No response from server." + return self._process_tool_result_content(result.content) + except Exception as e: + error = e + raise + finally: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, ) + # End span telemetry.end_span(span, error=error) - raise error - - telemetry.end_span(span) - return self._process_tool_result_content(result.content) From 9d6f096b94864a785eb7887ae36160bc12ed344e Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Wed, 11 Mar 2026 15:00:19 -0700 Subject: [PATCH 07/13] feat(core): catch error for telemetry --- .../src/toolbox_core/mcp_transport/v20251125/mcp.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index 0629ad260..49115c7d9 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -156,6 +156,9 @@ async def _initialize_session( request=types.InitializedNotification(), headers=headers, ) + except Exception as e: + error = e + raise finally: # Record operation duration metric operation_duration = time.time() - operation_start @@ -209,6 +212,9 @@ async def tools_list( serverVersion=self._server_version, tools=tools_map, ) + except Exception as e: + error = e + raise finally: # Record operation duration metric operation_duration = time.time() - operation_start @@ -302,6 +308,9 @@ async def tool_invoke( ) return self._process_tool_result_content(result.content) + except Exception as e: + error = e + raise finally: # Record operation duration metric operation_duration = time.time() - operation_start From e70e75d1bc1a1b51cbd51a5a3c1599db7571ba52 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Wed, 11 Mar 2026 15:33:25 -0700 Subject: [PATCH 08/13] feat(core): adding buckets to histogram --- .../toolbox_core/mcp_transport/telemetry.py | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py index 2fe191ef6..848a258cd 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -27,10 +27,12 @@ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics._internal.aggregation import ExplicitBucketHistogramAggregation from opentelemetry.sdk.metrics.export import ( ConsoleMetricExporter, PeriodicExportingMetricReader, ) +from opentelemetry.sdk.metrics.view import View from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter @@ -102,7 +104,29 @@ def setup_telemetry( ) metric_readers.append(otlp_reader) - meter_provider = MeterProvider(resource=resource, metric_readers=metric_readers) + # Configure Views with MCP histogram bucket boundaries + # These Views ensure that the MCP duration metrics use the bucket boundaries + # specified in the MCP semantic conventions + mcp_histogram_aggregation = ExplicitBucketHistogramAggregation( + boundaries=MCP_DURATION_BUCKETS + ) + + views = [ + View( + instrument_name=METRIC_CLIENT_OPERATION_DURATION, + aggregation=mcp_histogram_aggregation, + ), + View( + instrument_name=METRIC_CLIENT_SESSION_DURATION, + aggregation=mcp_histogram_aggregation, + ), + ] + + meter_provider = MeterProvider( + resource=resource, + metric_readers=metric_readers, + views=views, + ) metrics.set_meter_provider(meter_provider) # Set up TracerProvider for traces @@ -151,6 +175,9 @@ def get_meter(name: str = "toolbox", version: Optional[str] = None) -> Meter: def create_operation_duration_histogram(meter: Meter) -> Optional[Histogram]: """Create histogram for MCP client operation duration. + Bucket boundaries are configured via Views in setup_telemetry() to match + the MCP semantic conventions. + Args: meter: The OpenTelemetry meter @@ -162,8 +189,6 @@ def create_operation_duration_histogram(meter: Meter) -> Optional[Histogram]: name=METRIC_CLIENT_OPERATION_DURATION, unit="s", description="Duration of MCP client operations (requests/notifications)", - # Note: explicit_bucket_boundaries parameter may not be available in all versions - # If not available, the SDK will use default buckets ) except Exception: return None @@ -172,6 +197,9 @@ def create_operation_duration_histogram(meter: Meter) -> Optional[Histogram]: def create_session_duration_histogram(meter: Meter) -> Optional[Histogram]: """Create histogram for MCP client session duration. + Bucket boundaries are configured via Views in setup_telemetry() to match + the MCP semantic conventions. + Args: meter: The OpenTelemetry meter From 47ab67f6fd9d738d83e65efdeac4abf9baa3dff1 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Wed, 11 Mar 2026 15:47:15 -0700 Subject: [PATCH 09/13] feat(core): flag for telemetry enabled --- .../toolbox-core/src/toolbox_core/client.py | 12 +- .../mcp_transport/transport_base.py | 2 + .../mcp_transport/v20241105/mcp.py | 207 +++++++++-------- .../mcp_transport/v20250326/mcp.py | 207 +++++++++-------- .../mcp_transport/v20250618/mcp.py | 207 +++++++++-------- .../mcp_transport/v20251125/mcp.py | 209 ++++++++++-------- 6 files changed, 451 insertions(+), 393 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index 0dfc468b0..3e742a31f 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -95,19 +95,23 @@ def __init__( match protocol: case Protocol.MCP_v20251125: self.__transport = McpHttpTransportV20251125( - url, session, protocol, client_name, client_version + url, session, protocol, client_name, client_version, + telemetry_enabled=bool(telemetry_url) ) case Protocol.MCP_v20250618: self.__transport = McpHttpTransportV20250618( - url, session, protocol, client_name, client_version + url, session, protocol, client_name, client_version, + telemetry_enabled=bool(telemetry_url) ) case Protocol.MCP_v20250326: self.__transport = McpHttpTransportV20250326( - url, session, protocol, client_name, client_version + url, session, protocol, client_name, client_version, + telemetry_enabled=bool(telemetry_url) ) case Protocol.MCP_v20241105: self.__transport = McpHttpTransportV20241105( - url, session, protocol, client_name, client_version + url, session, protocol, client_name, client_version, + telemetry_enabled=bool(telemetry_url) ) case _: raise ValueError(f"Unsupported MCP protocol version: {protocol}") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/transport_base.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/transport_base.py index 5a7b3d0e8..bd01075e6 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/transport_base.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/transport_base.py @@ -38,6 +38,7 @@ def __init__( protocol: Protocol = Protocol.MCP, client_name: Optional[str] = None, client_version: Optional[str] = None, + telemetry_enabled: bool = False, ): self._mcp_base_url = f"{base_url}/mcp/" self._protocol_version = protocol.value @@ -45,6 +46,7 @@ def __init__( self._client_name = client_name self._client_version = client_version + self._telemetry_enabled = telemetry_enabled self._manage_session = session is None self._session = session or ClientSession() diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index 2e5f8ce9d..2f270e223 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -32,13 +32,20 @@ class McpHttpTransportV20241105(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._tracer = telemetry.get_tracer("toolbox", version.__version__) - # Initialize metrics following MCP semantic conventions - meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) - self._session_start_time: Optional[float] = None + if self._telemetry_enabled: + self._tracer = telemetry.get_tracer("toolbox", version.__version__) + + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + else: + self._tracer = None + self._operation_duration_histogram = None + self._session_duration_histogram = None + self._session_start_time = None async def _send_request( self, @@ -98,9 +105,6 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - # Track session start time for session duration metric - self._session_start_time = time.time() - params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -110,15 +114,18 @@ async def _initialize_session( ), ) - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Track session start time for session duration metric + self._session_start_time = time.time() + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -152,18 +159,19 @@ async def _initialize_session( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - error=error, - ) - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, + ) + telemetry.end_span(span, error=error) async def tools_list( self, @@ -175,15 +183,16 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -205,19 +214,20 @@ async def tools_list( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -235,16 +245,17 @@ async def tool_get( async def close(self): """Closes the MCP session and records session duration metric.""" - # Record session duration if session was initialized - if self._session_start_time is not None: - session_duration = time.time() - self._session_start_time - telemetry.record_session_duration( - self._session_duration_histogram, - session_duration, - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) # Call parent's close method await super().close() @@ -254,31 +265,32 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span and track operation start time - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - ) - meta: Optional[types.MCPMeta] = None - # CRITICAL: Make the span active in the context before generating trace context - with trace.use_span(span, end_on_exit=False): - # The client span becomes the parent of the server span through this context - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation - traceparent = telemetry.create_traceparent_from_context() - tracestate = telemetry.create_tracestate_from_context() - meta = types.MCPMeta( - traceparent=traceparent, - tracestate=tracestate, + if self._telemetry_enabled: + # Start telemetry span and track operation start time + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -301,17 +313,18 @@ async def tool_invoke( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index 0cd31fdff..edd77dc28 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -33,13 +33,20 @@ class McpHttpTransportV20250326(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._session_id: Optional[str] = None - self._tracer = telemetry.get_tracer("toolbox", version.__version__) - # Initialize metrics following MCP semantic conventions - meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) - self._session_start_time: Optional[float] = None + if self._telemetry_enabled: + self._tracer = telemetry.get_tracer("toolbox", version.__version__) + + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + else: + self._tracer = None + self._operation_duration_histogram = None + self._session_duration_histogram = None + self._session_start_time = None async def _send_request( self, @@ -111,9 +118,6 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - # Track session start time for session duration metric - self._session_start_time = time.time() - params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -123,15 +127,18 @@ async def _initialize_session( ), ) - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Track session start time for session duration metric + self._session_start_time = time.time() + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -176,18 +183,19 @@ async def _initialize_session( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - error=error, - ) - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, + ) + telemetry.end_span(span, error=error) async def tools_list( self, @@ -199,15 +207,16 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -232,19 +241,20 @@ async def tools_list( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -262,16 +272,17 @@ async def tool_get( async def close(self): """Closes the MCP session and records session duration metric.""" - # Record session duration if session was initialized - if self._session_start_time is not None: - session_duration = time.time() - self._session_start_time - telemetry.record_session_duration( - self._session_duration_histogram, - session_duration, - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) # Call parent's close method await super().close() @@ -281,31 +292,32 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span and track operation start time - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - ) - meta: Optional[types.MCPMeta] = None - # CRITICAL: Make the span active in the context before generating trace context - with trace.use_span(span, end_on_exit=False): - # The client span becomes the parent of the server span through this context - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation - traceparent = telemetry.create_traceparent_from_context() - tracestate = telemetry.create_tracestate_from_context() - meta = types.MCPMeta( - traceparent=traceparent, - tracestate=tracestate, + if self._telemetry_enabled: + # Start telemetry span and track operation start time + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -328,17 +340,18 @@ async def tool_invoke( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index 35ea06c25..0bcf9e98e 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -32,13 +32,20 @@ class McpHttpTransportV20250618(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._tracer = telemetry.get_tracer("toolbox", version.__version__) - # Initialize metrics following MCP semantic conventions - meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) - self._session_start_time: Optional[float] = None + if self._telemetry_enabled: + self._tracer = telemetry.get_tracer("toolbox", version.__version__) + + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + else: + self._tracer = None + self._operation_duration_histogram = None + self._session_duration_histogram = None + self._session_start_time = None async def _send_request( self, @@ -105,9 +112,6 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - # Track session start time for session duration metric - self._session_start_time = time.time() - params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -117,15 +121,18 @@ async def _initialize_session( ), ) - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Track session start time for session duration metric + self._session_start_time = time.time() + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -160,18 +167,19 @@ async def _initialize_session( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - error=error, - ) - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, + ) + telemetry.end_span(span, error=error) async def tools_list( self, @@ -183,15 +191,16 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -216,19 +225,20 @@ async def tools_list( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -246,16 +256,17 @@ async def tool_get( async def close(self): """Closes the MCP session and records session duration metric.""" - # Record session duration if session was initialized - if self._session_start_time is not None: - session_duration = time.time() - self._session_start_time - telemetry.record_session_duration( - self._session_duration_histogram, - session_duration, - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) # Call parent's close method await super().close() @@ -265,31 +276,32 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span and track operation start time - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - ) - meta: Optional[types.MCPMeta] = None - # CRITICAL: Make the span active in the context before generating trace context - with trace.use_span(span, end_on_exit=False): - # The client span becomes the parent of the server span through this context - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation - traceparent = telemetry.create_traceparent_from_context() - tracestate = telemetry.create_tracestate_from_context() - meta = types.MCPMeta( - traceparent=traceparent, - tracestate=tracestate, + if self._telemetry_enabled: + # Start telemetry span and track operation start time + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -312,17 +324,18 @@ async def tool_invoke( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index 49115c7d9..cf86304ea 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -32,13 +32,20 @@ class McpHttpTransportV20251125(_McpHttpTransportBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._tracer = telemetry.get_tracer("toolbox", version.__version__) - # Initialize metrics following MCP semantic conventions - meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) - self._session_start_time: Optional[float] = None + if self._telemetry_enabled: + self._tracer = telemetry.get_tracer("toolbox", version.__version__) + + # Initialize metrics following MCP semantic conventions + meter = telemetry.get_meter("toolbox", version.__version__) + self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) + self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._session_start_time: Optional[float] = None + else: + self._tracer = None + self._operation_duration_histogram = None + self._session_duration_histogram = None + self._session_start_time = None async def _send_request( self, @@ -105,9 +112,6 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - # Track session start time for session duration metric - self._session_start_time = time.time() - params = types.InitializeRequestParams( protocolVersion=self._protocol_version, capabilities=types.ClientCapabilities(), @@ -117,15 +121,18 @@ async def _initialize_session( ), ) - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Track session start time for session duration metric + self._session_start_time = time.time() + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -160,18 +167,19 @@ async def _initialize_session( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "initialize", - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - error=error, - ) - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "initialize", + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + error=error, + ) + telemetry.end_span(span, error=error) async def tools_list( self, @@ -183,15 +191,16 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") - # Start telemetry span and track operation start time - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Start telemetry span and track operation start time + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + ) error: Optional[Exception] = None try: @@ -216,19 +225,20 @@ async def tools_list( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/list", - self._protocol_version, - url, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/list", + self._protocol_version, + url, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) async def tool_get( self, tool_name: str, headers: Optional[Mapping[str, str]] = None @@ -246,16 +256,17 @@ async def tool_get( async def close(self): """Closes the MCP session and records session duration metric.""" - # Record session duration if session was initialized - if self._session_start_time is not None: - session_duration = time.time() - self._session_start_time - telemetry.record_session_duration( - self._session_duration_histogram, - session_duration, - self._protocol_version, - self._mcp_base_url, - network_transport="tcp", - ) + if self._telemetry_enabled: + # Record session duration if session was initialized + if self._session_start_time is not None: + session_duration = time.time() - self._session_start_time + telemetry.record_session_duration( + self._session_duration_histogram, + session_duration, + self._protocol_version, + self._mcp_base_url, + network_transport="tcp", + ) # Call parent's close method await super().close() @@ -265,31 +276,32 @@ async def tool_invoke( """Invokes a specific tool on the server using the MCP protocol.""" await self._ensure_initialized(headers=headers) - # Start telemetry span and track operation start time - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp - operation_start = time.time() - span = telemetry.start_span( - self._tracer, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - ) - meta: Optional[types.MCPMeta] = None - - # CRITICAL: Make the span active in the context before generating trace context - with trace.use_span(span, end_on_exit=False): - # The client span becomes the parent of the server span through this context - # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation - traceparent = telemetry.create_traceparent_from_context() - tracestate = telemetry.create_tracestate_from_context() - meta = types.MCPMeta( - traceparent=traceparent, - tracestate=tracestate, + + if self._telemetry_enabled: + # Start telemetry span and track operation start time + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + operation_start = time.time() + span = telemetry.start_span( + self._tracer, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -312,17 +324,18 @@ async def tool_invoke( error = e raise finally: - # Record operation duration metric - operation_duration = time.time() - operation_start - telemetry.record_operation_duration( - self._operation_duration_histogram, - operation_duration, - "tools/call", - self._protocol_version, - self._mcp_base_url, - tool_name=tool_name, - network_transport="tcp", - error=error, - ) - # End span - telemetry.end_span(span, error=error) + if self._telemetry_enabled: + # Record operation duration metric + operation_duration = time.time() - operation_start + telemetry.record_operation_duration( + self._operation_duration_histogram, + operation_duration, + "tools/call", + self._protocol_version, + self._mcp_base_url, + tool_name=tool_name, + network_transport="tcp", + error=error, + ) + # End span + telemetry.end_span(span, error=error) From 7138d88c01490b6dd4f9e95b2bda99a1d40533cf Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Wed, 11 Mar 2026 17:23:13 -0700 Subject: [PATCH 10/13] feat(core): add telemetry optional --- packages/toolbox-core/pyproject.toml | 10 ++- .../toolbox-core/src/toolbox_core/client.py | 28 +++--- .../toolbox_core/mcp_transport/telemetry.py | 85 ++++++++++++++----- 3 files changed, 87 insertions(+), 36 deletions(-) diff --git a/packages/toolbox-core/pyproject.toml b/packages/toolbox-core/pyproject.toml index fe698712c..595652ed8 100644 --- a/packages/toolbox-core/pyproject.toml +++ b/packages/toolbox-core/pyproject.toml @@ -14,10 +14,7 @@ dependencies = [ "aiohttp>=3.8.6,<4.0.0", "deprecated>=1.2.15,<2.0.0", "google-auth>=2.0.0,<3.0.0", - "requests>=2.19.0,<3.0.0", - "opentelemetry-api>=1.20.0,<2.0.0", - "opentelemetry-sdk>=1.20.0,<2.0.0", - "opentelemetry-exporter-otlp>=1.20.0,<2.0.0" + "requests>=2.19.0,<3.0.0" ] classifiers = [ @@ -46,6 +43,11 @@ Repository = "https://github.com/googleapis/mcp-toolbox-sdk-python.git" Changelog = "https://github.com/googleapis/mcp-toolbox-sdk-python/blob/main/packages/toolbox-core/CHANGELOG.md" [project.optional-dependencies] +telemetry = [ + "opentelemetry-api>=1.20.0,<2.0.0", + "opentelemetry-sdk>=1.20.0,<2.0.0", + "opentelemetry-exporter-otlp>=1.20.0,<2.0.0" +] test = [ "black[jupyter]==26.1.0", "isort==8.0.0", diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index 3e742a31f..802b743cc 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -29,7 +29,7 @@ McpHttpTransportV20251125, ) from . import version -from .mcp_transport.telemetry import setup_telemetry +from .mcp_transport.telemetry import TELEMETRY_AVAILABLE, setup_telemetry from .protocol import Protocol, ToolSchema from .tool import ToolboxTool from .utils import identify_auth_requirements, resolve_value, warn_if_http_and_headers @@ -79,12 +79,15 @@ def __init__( # Setup OpenTelemetry (metrics and traces) if endpoint is provided if telemetry_url: - setup_telemetry( - service_name="toolbox", - service_version=version.__version__, - use_console_exporter=False, # Using OTLP endpoint instead - otlp_endpoint=telemetry_url, - ) + try: + setup_telemetry( + service_name="toolbox", + service_version=version.__version__, + use_console_exporter=False, # Using OTLP endpoint instead + otlp_endpoint=telemetry_url, + ) + except Exception as e: + logging.warning(f"Failed to setup telemetry: {e}") if protocol != Protocol.MCP_LATEST: logging.warning( @@ -92,26 +95,29 @@ def __init__( "Please use Protocol.MCP_LATEST to use the latest features." ) + # Telemetry is only enabled if URL is provided AND OpenTelemetry is available + telemetry_enabled = bool(telemetry_url) and TELEMETRY_AVAILABLE + match protocol: case Protocol.MCP_v20251125: self.__transport = McpHttpTransportV20251125( url, session, protocol, client_name, client_version, - telemetry_enabled=bool(telemetry_url) + telemetry_enabled=telemetry_enabled ) case Protocol.MCP_v20250618: self.__transport = McpHttpTransportV20250618( url, session, protocol, client_name, client_version, - telemetry_enabled=bool(telemetry_url) + telemetry_enabled=telemetry_enabled ) case Protocol.MCP_v20250326: self.__transport = McpHttpTransportV20250326( url, session, protocol, client_name, client_version, - telemetry_enabled=bool(telemetry_url) + telemetry_enabled=telemetry_enabled ) case Protocol.MCP_v20241105: self.__transport = McpHttpTransportV20241105( url, session, protocol, client_name, client_version, - telemetry_enabled=bool(telemetry_url) + telemetry_enabled=telemetry_enabled ) case _: raise ValueError(f"Unsupported MCP protocol version: {protocol}") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py index 848a258cd..05b21bb64 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -16,28 +16,39 @@ This module implements telemetry following the MCP Semantic Conventions: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp + +Note: OpenTelemetry is an optional dependency. Install with: + pip install toolbox-core[telemetry] """ from typing import Optional from urllib.parse import urlparse -from opentelemetry import metrics, trace -from opentelemetry.metrics import Histogram, Meter -# from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics._internal.aggregation import ExplicitBucketHistogramAggregation -from opentelemetry.sdk.metrics.export import ( - ConsoleMetricExporter, - PeriodicExportingMetricReader, -) -from opentelemetry.sdk.metrics.view import View -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter -from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer -from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +# Try to import OpenTelemetry - it's an optional dependency +try: + from opentelemetry import metrics, trace + from opentelemetry.metrics import Histogram, Meter + from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics._internal.aggregation import ExplicitBucketHistogramAggregation + from opentelemetry.sdk.metrics.export import ( + ConsoleMetricExporter, + PeriodicExportingMetricReader, + ) + from opentelemetry.sdk.metrics.view import View + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter + from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer + from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + + TELEMETRY_AVAILABLE = True +except ImportError: + TELEMETRY_AVAILABLE = False + Tracer = None # type: ignore + Meter = None # type: ignore + Histogram = None # type: ignore # Attribute names following MCP semantic conventions ATTR_MCP_METHOD_NAME = "mcp.method.name" @@ -77,7 +88,15 @@ def setup_telemetry( service_version: Version of the service use_console_exporter: If True, exports metrics/traces to console (for debugging) otlp_endpoint: Optional OTLP endpoint URL (e.g., "http://localhost:4318") + + Raises: + RuntimeError: If OpenTelemetry is not installed """ + if not TELEMETRY_AVAILABLE: + raise RuntimeError( + "Telemetry support requires OpenTelemetry. Install with: " + "pip install toolbox-core[telemetry]" + ) # Create resource with service information resource = Resource.create( { @@ -146,7 +165,7 @@ def setup_telemetry( trace.set_tracer_provider(tracer_provider) -def get_tracer(name: str = "toolbox", version: Optional[str] = None) -> Tracer: +def get_tracer(name: str = "toolbox", version: Optional[str] = None) -> Optional[Tracer]: """Get or create a tracer for MCP operations. Args: @@ -154,12 +173,20 @@ def get_tracer(name: str = "toolbox", version: Optional[str] = None) -> Tracer: version: The tracer version Returns: - An OpenTelemetry Tracer instance + An OpenTelemetry Tracer instance, or None if telemetry is not available + + Raises: + RuntimeError: If OpenTelemetry is not installed """ + if not TELEMETRY_AVAILABLE: + raise RuntimeError( + "Telemetry support requires OpenTelemetry. Install with: " + "pip install toolbox-core[telemetry]" + ) return trace.get_tracer(name, version) -def get_meter(name: str = "toolbox", version: Optional[str] = None) -> Meter: +def get_meter(name: str = "toolbox", version: Optional[str] = None) -> Optional[Meter]: """Get or create a meter for MCP metrics. Args: @@ -167,8 +194,16 @@ def get_meter(name: str = "toolbox", version: Optional[str] = None) -> Meter: version: The meter version Returns: - An OpenTelemetry Meter instance + An OpenTelemetry Meter instance, or None if telemetry is not available + + Raises: + RuntimeError: If OpenTelemetry is not installed """ + if not TELEMETRY_AVAILABLE: + raise RuntimeError( + "Telemetry support requires OpenTelemetry. Install with: " + "pip install toolbox-core[telemetry]" + ) return metrics.get_meter(name, version) @@ -452,7 +487,15 @@ def setup_otlp_tracer_provider( Args: otlp_endpoint: The OTLP endpoint URL (e.g., "http://localhost:4318") service_name: The service name for the resource + + Raises: + RuntimeError: If OpenTelemetry is not installed """ + if not TELEMETRY_AVAILABLE: + raise RuntimeError( + "Telemetry support requires OpenTelemetry. Install with: " + "pip install toolbox-core[telemetry]" + ) # Create resource with service name resource = Resource.create({"service.name": service_name}) From 866ca64a1c300743258ddaef210168445040d0d0 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Thu, 12 Mar 2026 14:53:59 -0700 Subject: [PATCH 11/13] feat(telemetry): context propagation for all fucntions --- .../mcp_transport/v20241105/mcp.py | 49 +++++++++++++++---- .../mcp_transport/v20241105/types.py | 11 ++++- .../mcp_transport/v20250326/mcp.py | 49 +++++++++++++++---- .../mcp_transport/v20250326/types.py | 11 ++++- .../mcp_transport/v20250618/mcp.py | 49 +++++++++++++++---- .../mcp_transport/v20250618/types.py | 11 ++++- .../mcp_transport/v20251125/mcp.py | 49 +++++++++++++++---- .../mcp_transport/v20251125/types.py | 11 ++++- 8 files changed, 200 insertions(+), 40 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index 2f270e223..110c95bb5 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -105,14 +105,7 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - params = types.InitializeRequestParams( - protocolVersion=self._protocol_version, - capabilities=types.ClientCapabilities(), - clientInfo=types.Implementation( - name=self._client_name or "toolbox-core-python", - version=self._client_version or version.__version__, - ), - ) + meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: # Track session start time for session duration metric @@ -127,6 +120,27 @@ async def _initialize_session( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + + params = types.InitializeRequestParams( + protocolVersion=self._protocol_version, + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation( + name=self._client_name or "toolbox-core-python", + version=self._client_version or version.__version__, + ), + field_meta=meta, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -183,6 +197,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") + meta: Optional[types.MCPMeta] = None + if self._telemetry_enabled: # Start telemetry span and track operation start time operation_start = time.time() @@ -194,10 +210,25 @@ async def tools_list( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers + url=url, + request=types.ListToolsRequest( + params=types.ListToolsRequestParams(field_meta=meta) + ), + headers=headers, ) if result is None: raise RuntimeError("Failed to list tools: No response from server.") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py index 0523c01f3..646d5b271 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/types.py @@ -77,6 +77,9 @@ class InitializeRequestParams(RequestParams): protocolVersion: str capabilities: ClientCapabilities clientInfo: Implementation + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") class ServerCapabilities(_BaseMCPModel): @@ -100,6 +103,12 @@ class ListToolsResult(_BaseMCPModel): tools: list[Tool] +class ListToolsRequestParams(_BaseMCPModel): + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") + + class TextContent(_BaseMCPModel): type: Literal["text"] text: str @@ -141,7 +150,7 @@ class InitializedNotification(MCPNotification): class ListToolsRequest(MCPRequest[ListToolsResult]): method: Literal["tools/list"] = "tools/list" - params: dict[str, Any] = {} + params: ListToolsRequestParams = Field(default_factory=ListToolsRequestParams) def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index edd77dc28..59d0d0486 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -118,14 +118,7 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - params = types.InitializeRequestParams( - protocolVersion=self._protocol_version, - capabilities=types.ClientCapabilities(), - clientInfo=types.Implementation( - name=self._client_name or "toolbox-core-python", - version=self._client_version or version.__version__, - ), - ) + meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: # Track session start time for session duration metric @@ -140,6 +133,27 @@ async def _initialize_session( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + + params = types.InitializeRequestParams( + protocolVersion=self._protocol_version, + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation( + name=self._client_name or "toolbox-core-python", + version=self._client_version or version.__version__, + ), + field_meta=meta, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -207,6 +221,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") + meta: Optional[types.MCPMeta] = None + if self._telemetry_enabled: # Start telemetry span and track operation start time operation_start = time.time() @@ -218,10 +234,25 @@ async def tools_list( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers + url=url, + request=types.ListToolsRequest( + params=types.ListToolsRequestParams(field_meta=meta) + ), + headers=headers, ) if result is None: raise RuntimeError("Failed to list tools: No response from server.") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py index 0523c01f3..646d5b271 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/types.py @@ -77,6 +77,9 @@ class InitializeRequestParams(RequestParams): protocolVersion: str capabilities: ClientCapabilities clientInfo: Implementation + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") class ServerCapabilities(_BaseMCPModel): @@ -100,6 +103,12 @@ class ListToolsResult(_BaseMCPModel): tools: list[Tool] +class ListToolsRequestParams(_BaseMCPModel): + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") + + class TextContent(_BaseMCPModel): type: Literal["text"] text: str @@ -141,7 +150,7 @@ class InitializedNotification(MCPNotification): class ListToolsRequest(MCPRequest[ListToolsResult]): method: Literal["tools/list"] = "tools/list" - params: dict[str, Any] = {} + params: ListToolsRequestParams = Field(default_factory=ListToolsRequestParams) def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index 0bcf9e98e..8163529ab 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -112,14 +112,7 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - params = types.InitializeRequestParams( - protocolVersion=self._protocol_version, - capabilities=types.ClientCapabilities(), - clientInfo=types.Implementation( - name=self._client_name or "toolbox-core-python", - version=self._client_version or version.__version__, - ), - ) + meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: # Track session start time for session duration metric @@ -134,6 +127,27 @@ async def _initialize_session( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + + params = types.InitializeRequestParams( + protocolVersion=self._protocol_version, + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation( + name=self._client_name or "toolbox-core-python", + version=self._client_version or version.__version__, + ), + field_meta=meta, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -191,6 +205,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") + meta: Optional[types.MCPMeta] = None + if self._telemetry_enabled: # Start telemetry span and track operation start time operation_start = time.time() @@ -202,10 +218,25 @@ async def tools_list( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers + url=url, + request=types.ListToolsRequest( + params=types.ListToolsRequestParams(field_meta=meta) + ), + headers=headers, ) if result is None: raise RuntimeError("Failed to list tools: No response from server.") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py index 0523c01f3..646d5b271 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/types.py @@ -77,6 +77,9 @@ class InitializeRequestParams(RequestParams): protocolVersion: str capabilities: ClientCapabilities clientInfo: Implementation + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") class ServerCapabilities(_BaseMCPModel): @@ -100,6 +103,12 @@ class ListToolsResult(_BaseMCPModel): tools: list[Tool] +class ListToolsRequestParams(_BaseMCPModel): + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") + + class TextContent(_BaseMCPModel): type: Literal["text"] text: str @@ -141,7 +150,7 @@ class InitializedNotification(MCPNotification): class ListToolsRequest(MCPRequest[ListToolsResult]): method: Literal["tools/list"] = "tools/list" - params: dict[str, Any] = {} + params: ListToolsRequestParams = Field(default_factory=ListToolsRequestParams) def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index cf86304ea..ccba957d6 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -112,14 +112,7 @@ async def _initialize_session( self, headers: Optional[Mapping[str, str]] = None ) -> None: """Initializes the MCP session.""" - params = types.InitializeRequestParams( - protocolVersion=self._protocol_version, - capabilities=types.ClientCapabilities(), - clientInfo=types.Implementation( - name=self._client_name or "toolbox-core-python", - version=self._client_version or version.__version__, - ), - ) + meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: # Track session start time for session duration metric @@ -134,6 +127,27 @@ async def _initialize_session( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + + params = types.InitializeRequestParams( + protocolVersion=self._protocol_version, + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation( + name=self._client_name or "toolbox-core-python", + version=self._client_version or version.__version__, + ), + field_meta=meta, + ) + error: Optional[Exception] = None try: result = await self._send_request( @@ -191,6 +205,8 @@ async def tools_list( url = self._mcp_base_url + (toolset_name if toolset_name else "") + meta: Optional[types.MCPMeta] = None + if self._telemetry_enabled: # Start telemetry span and track operation start time operation_start = time.time() @@ -202,10 +218,25 @@ async def tools_list( network_transport="tcp", ) + # CRITICAL: Make the span active in the context before generating trace context + with trace.use_span(span, end_on_exit=False): + # The client span becomes the parent of the server span through this context + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + traceparent = telemetry.create_traceparent_from_context() + tracestate = telemetry.create_tracestate_from_context() + meta = types.MCPMeta( + traceparent=traceparent, + tracestate=tracestate, + ) + error: Optional[Exception] = None try: result = await self._send_request( - url=url, request=types.ListToolsRequest(), headers=headers + url=url, + request=types.ListToolsRequest( + params=types.ListToolsRequestParams(field_meta=meta) + ), + headers=headers, ) if result is None: raise RuntimeError("Failed to list tools: No response from server.") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py index c6c76530a..443f5a474 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/types.py @@ -77,6 +77,9 @@ class InitializeRequestParams(RequestParams): protocolVersion: str capabilities: ClientCapabilities clientInfo: Implementation + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") class ServerCapabilities(_BaseMCPModel): @@ -100,6 +103,12 @@ class ListToolsResult(_BaseMCPModel): tools: list[Tool] +class ListToolsRequestParams(_BaseMCPModel): + # OpenTelemetry trace context propagation + # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#context-propagation + field_meta: "MCPMeta | None" = Field(default=None, serialization_alias="_meta") + + class TextContent(_BaseMCPModel): type: Literal["text"] text: str @@ -141,7 +150,7 @@ class InitializedNotification(MCPNotification): class ListToolsRequest(MCPRequest[ListToolsResult]): method: Literal["tools/list"] = "tools/list" - params: dict[str, Any] = {} + params: ListToolsRequestParams = Field(default_factory=ListToolsRequestParams) def get_result_model(self) -> Type[ListToolsResult]: return ListToolsResult From 6469c4f4169957b4933e77af148e0e16d3f81cee Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Fri, 13 Mar 2026 12:21:07 -0700 Subject: [PATCH 12/13] fix(core): black formatting check --- .../toolbox-core/src/toolbox_core/client.py | 32 ++++++++++++++----- .../toolbox_core/mcp_transport/telemetry.py | 25 +++++++++++---- .../mcp_transport/v20241105/mcp.py | 16 +++++++--- .../mcp_transport/v20250326/mcp.py | 16 +++++++--- .../mcp_transport/v20250618/mcp.py | 16 +++++++--- .../mcp_transport/v20251125/mcp.py | 16 +++++++--- 6 files changed, 91 insertions(+), 30 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/client.py b/packages/toolbox-core/src/toolbox_core/client.py index 802b743cc..65aecf1ae 100644 --- a/packages/toolbox-core/src/toolbox_core/client.py +++ b/packages/toolbox-core/src/toolbox_core/client.py @@ -101,23 +101,39 @@ def __init__( match protocol: case Protocol.MCP_v20251125: self.__transport = McpHttpTransportV20251125( - url, session, protocol, client_name, client_version, - telemetry_enabled=telemetry_enabled + url, + session, + protocol, + client_name, + client_version, + telemetry_enabled=telemetry_enabled, ) case Protocol.MCP_v20250618: self.__transport = McpHttpTransportV20250618( - url, session, protocol, client_name, client_version, - telemetry_enabled=telemetry_enabled + url, + session, + protocol, + client_name, + client_version, + telemetry_enabled=telemetry_enabled, ) case Protocol.MCP_v20250326: self.__transport = McpHttpTransportV20250326( - url, session, protocol, client_name, client_version, - telemetry_enabled=telemetry_enabled + url, + session, + protocol, + client_name, + client_version, + telemetry_enabled=telemetry_enabled, ) case Protocol.MCP_v20241105: self.__transport = McpHttpTransportV20241105( - url, session, protocol, client_name, client_version, - telemetry_enabled=telemetry_enabled + url, + session, + protocol, + client_name, + client_version, + telemetry_enabled=telemetry_enabled, ) case _: raise ValueError(f"Unsupported MCP protocol version: {protocol}") diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py index 05b21bb64..a07b1e014 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/telemetry.py @@ -28,10 +28,14 @@ try: from opentelemetry import metrics, trace from opentelemetry.metrics import Histogram, Meter - from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter + from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( + OTLPMetricExporter, + ) from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics._internal.aggregation import ExplicitBucketHistogramAggregation + from opentelemetry.sdk.metrics._internal.aggregation import ( + ExplicitBucketHistogramAggregation, + ) from opentelemetry.sdk.metrics.export import ( ConsoleMetricExporter, PeriodicExportingMetricReader, @@ -41,7 +45,9 @@ from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer - from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + from opentelemetry.trace.propagation.tracecontext import ( + TraceContextTextMapPropagator, + ) TELEMETRY_AVAILABLE = True except ImportError: @@ -117,7 +123,9 @@ def setup_telemetry( if otlp_endpoint: # OTLP exporter for production use - otlp_metric_exporter = OTLPMetricExporter(endpoint=f"{otlp_endpoint}/v1/metrics") + otlp_metric_exporter = OTLPMetricExporter( + endpoint=f"{otlp_endpoint}/v1/metrics" + ) otlp_reader = PeriodicExportingMetricReader( otlp_metric_exporter, export_interval_millis=5000 ) @@ -165,7 +173,9 @@ def setup_telemetry( trace.set_tracer_provider(tracer_provider) -def get_tracer(name: str = "toolbox", version: Optional[str] = None) -> Optional[Tracer]: +def get_tracer( + name: str = "toolbox", version: Optional[str] = None +) -> Optional[Tracer]: """Get or create a tracer for MCP operations. Args: @@ -361,7 +371,9 @@ def end_span(span: Optional[trace.Span], error: Optional[Exception] = None) -> N pass -def record_error_from_jsonrpc(span: trace.Span, error_code: int, error_message: str) -> None: +def record_error_from_jsonrpc( + span: trace.Span, error_code: int, error_message: str +) -> None: """Record error information from JSON-RPC error response. Args: @@ -479,6 +491,7 @@ def record_session_duration( # Ignore metrics recording errors pass + def setup_otlp_tracer_provider( otlp_endpoint: str, service_name: str = "toolbox" ) -> None: diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index 110c95bb5..c42ae87bf 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -38,8 +38,12 @@ def __init__(self, *args, **kwargs): # Initialize metrics following MCP semantic conventions meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._operation_duration_histogram = ( + telemetry.create_operation_duration_histogram(meter) + ) + self._session_duration_histogram = ( + telemetry.create_session_duration_histogram(meter) + ) self._session_start_time: Optional[float] = None else: self._tracer = None @@ -150,7 +154,9 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + raise RuntimeError( + "Failed to initialize session: No response from server." + ) self._server_version = result.serverInfo.version @@ -234,7 +240,9 @@ async def tools_list( raise RuntimeError("Failed to list tools: No response from server.") tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + t.name: self._convert_tool_schema( + t.model_dump(mode="json", by_alias=True) + ) for t in result.tools } if self._server_version is None: diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index 59d0d0486..6ccb027b4 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -39,8 +39,12 @@ def __init__(self, *args, **kwargs): # Initialize metrics following MCP semantic conventions meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._operation_duration_histogram = ( + telemetry.create_operation_duration_histogram(meter) + ) + self._session_duration_histogram = ( + telemetry.create_session_duration_histogram(meter) + ) self._session_start_time: Optional[float] = None else: self._tracer = None @@ -163,7 +167,9 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + raise RuntimeError( + "Failed to initialize session: No response from server." + ) self._server_version = result.serverInfo.version @@ -258,7 +264,9 @@ async def tools_list( raise RuntimeError("Failed to list tools: No response from server.") tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + t.name: self._convert_tool_schema( + t.model_dump(mode="json", by_alias=True) + ) for t in result.tools } if self._server_version is None: diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index 8163529ab..8e7fdac45 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -38,8 +38,12 @@ def __init__(self, *args, **kwargs): # Initialize metrics following MCP semantic conventions meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._operation_duration_histogram = ( + telemetry.create_operation_duration_histogram(meter) + ) + self._session_duration_histogram = ( + telemetry.create_session_duration_histogram(meter) + ) self._session_start_time: Optional[float] = None else: self._tracer = None @@ -157,7 +161,9 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + raise RuntimeError( + "Failed to initialize session: No response from server." + ) self._server_version = result.serverInfo.version @@ -242,7 +248,9 @@ async def tools_list( raise RuntimeError("Failed to list tools: No response from server.") tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + t.name: self._convert_tool_schema( + t.model_dump(mode="json", by_alias=True) + ) for t in result.tools } if self._server_version is None: diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index ccba957d6..56ad726a6 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -38,8 +38,12 @@ def __init__(self, *args, **kwargs): # Initialize metrics following MCP semantic conventions meter = telemetry.get_meter("toolbox", version.__version__) - self._operation_duration_histogram = telemetry.create_operation_duration_histogram(meter) - self._session_duration_histogram = telemetry.create_session_duration_histogram(meter) + self._operation_duration_histogram = ( + telemetry.create_operation_duration_histogram(meter) + ) + self._session_duration_histogram = ( + telemetry.create_session_duration_histogram(meter) + ) self._session_start_time: Optional[float] = None else: self._tracer = None @@ -157,7 +161,9 @@ async def _initialize_session( ) if result is None: - raise RuntimeError("Failed to initialize session: No response from server.") + raise RuntimeError( + "Failed to initialize session: No response from server." + ) self._server_version = result.serverInfo.version @@ -242,7 +248,9 @@ async def tools_list( raise RuntimeError("Failed to list tools: No response from server.") tools_map = { - t.name: self._convert_tool_schema(t.model_dump(mode="json", by_alias=True)) + t.name: self._convert_tool_schema( + t.model_dump(mode="json", by_alias=True) + ) for t in result.tools } if self._server_version is None: From 1ec5899bb3f0d1d3431c43023c4dcb44d19a48d8 Mon Sep 17 00:00:00 2001 From: Parth Ajmera Date: Fri, 13 Mar 2026 12:30:05 -0700 Subject: [PATCH 13/13] fix(core): import trace under flag check --- .../src/toolbox_core/mcp_transport/v20241105/mcp.py | 7 ++++++- .../src/toolbox_core/mcp_transport/v20250326/mcp.py | 7 ++++++- .../src/toolbox_core/mcp_transport/v20250618/mcp.py | 7 ++++++- .../src/toolbox_core/mcp_transport/v20251125/mcp.py | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py index c42ae87bf..84685d52d 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py @@ -15,7 +15,6 @@ import time from typing import Mapping, Optional, TypeVar -from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -112,6 +111,8 @@ async def _initialize_session( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Track session start time for session duration metric self._session_start_time = time.time() # Start telemetry span and track operation start time @@ -206,6 +207,8 @@ async def tools_list( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time operation_start = time.time() span = telemetry.start_span( @@ -307,6 +310,8 @@ async def tool_invoke( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp operation_start = time.time() diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py index 6ccb027b4..d9045a631 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py @@ -15,7 +15,6 @@ import time from typing import Mapping, Optional, TypeVar -from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -125,6 +124,8 @@ async def _initialize_session( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Track session start time for session duration metric self._session_start_time = time.time() # Start telemetry span and track operation start time @@ -230,6 +231,8 @@ async def tools_list( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time operation_start = time.time() span = telemetry.start_span( @@ -334,6 +337,8 @@ async def tool_invoke( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp operation_start = time.time() diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py index 8e7fdac45..12c507c7c 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20250618/mcp.py @@ -15,7 +15,6 @@ import time from typing import Mapping, Optional, TypeVar -from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -119,6 +118,8 @@ async def _initialize_session( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Track session start time for session duration metric self._session_start_time = time.time() # Start telemetry span and track operation start time @@ -214,6 +215,8 @@ async def tools_list( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time operation_start = time.time() span = telemetry.start_span( @@ -318,6 +321,8 @@ async def tool_invoke( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp operation_start = time.time() diff --git a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py index 56ad726a6..87080c2bd 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py +++ b/packages/toolbox-core/src/toolbox_core/mcp_transport/v20251125/mcp.py @@ -15,7 +15,6 @@ import time from typing import Mapping, Optional, TypeVar -from opentelemetry import trace from pydantic import BaseModel from ... import version @@ -119,6 +118,8 @@ async def _initialize_session( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Track session start time for session duration metric self._session_start_time = time.time() # Start telemetry span and track operation start time @@ -214,6 +215,8 @@ async def tools_list( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time operation_start = time.time() span = telemetry.start_span( @@ -318,6 +321,8 @@ async def tool_invoke( meta: Optional[types.MCPMeta] = None if self._telemetry_enabled: + from opentelemetry import trace + # Start telemetry span and track operation start time # See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp operation_start = time.time()