From bbdd99bf9c347038238db56d9223a248eef98900 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 13:53:09 -0400 Subject: [PATCH 01/14] Add basic OpenTelemetry tracing for client and server requests Add opentelemetry-api as an optional dependency (mcp[otel]) and create spans for client request/response cycles and server request handling. Span names follow the pattern: - Client: "MCP {method} {target}" (e.g. "MCP tools/call my_tool") - Server: "MCP handle {method} {target}" When opentelemetry-api is not installed, tracing is a complete no-op with zero overhead. --- pyproject.toml | 3 +- src/mcp/server/lowlevel/server.py | 139 ++++++++++++++++-------------- src/mcp/shared/_otel.py | 43 +++++++++ src/mcp/shared/session.py | 42 +++++---- tests/shared/test_otel.py | 24 ++++++ uv.lock | 44 +++++++++- 6 files changed, 211 insertions(+), 84 deletions(-) create mode 100644 src/mcp/shared/_otel.py create mode 100644 tests/shared/test_otel.py diff --git a/pyproject.toml b/pyproject.toml index 7d8b4a874..82a93a239 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ rich = ["rich>=13.9.4"] cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] ws = ["websockets>=15.0.1"] +otel = ["opentelemetry-api>=1.28.0"] [project.scripts] mcp = "mcp.cli:app [cli]" @@ -57,7 +58,7 @@ required-version = ">=0.9.5" [dependency-groups] dev = [ # We add mcp[cli,ws] so `uv sync` considers the extras. - "mcp[cli,ws]", + "mcp[cli,ws,otel]", "pyright>=1.1.400", "pytest>=8.3.4", "ruff>=0.8.5", diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 0fdbbff86..1068ee99a 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -65,6 +65,7 @@ async def main(): from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared._otel import otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata, SessionMessage @@ -446,72 +447,82 @@ async def _handle_request( ): logger.info("Processing request of type %s", type(req).__name__) - if handler := self._request_handlers.get(req.method): - logger.debug("Dispatching request of type %s", type(req).__name__) + target = getattr(req.params, "name", None) if req.params else None + span_name = f"MCP handle {req.method} {target}" if target else f"MCP handle {req.method}" - try: - # Extract request context and close_sse_stream from message metadata - request_data = None - close_sse_stream_cb = None - close_standalone_sse_stream_cb = None - if message.message_metadata is not None and isinstance(message.message_metadata, ServerMessageMetadata): - request_data = message.message_metadata.request_context - close_sse_stream_cb = message.message_metadata.close_sse_stream - close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream + with otel_span( + span_name, + kind="SERVER", + attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id}, + ): + if handler := self._request_handlers.get(req.method): + logger.debug("Dispatching request of type %s", type(req).__name__) - client_capabilities = session.client_params.capabilities if session.client_params else None - task_support = self._experimental_handlers.task_support if self._experimental_handlers else None - # Get task metadata from request params if present - task_metadata = None - if hasattr(req, "params") and req.params is not None: - task_metadata = getattr(req.params, "task", None) - ctx = ServerRequestContext( - request_id=message.request_id, - meta=message.request_meta, - session=session, - lifespan_context=lifespan_context, - experimental=Experimental( - task_metadata=task_metadata, - _client_capabilities=client_capabilities, - _session=session, - _task_support=task_support, - ), - request=request_data, - close_sse_stream=close_sse_stream_cb, - close_standalone_sse_stream=close_standalone_sse_stream_cb, - ) - response = await handler(ctx, req.params) - except MCPError as err: - response = err.error - except anyio.get_cancelled_exc_class(): - if message.cancelled: - # Client sent CancelledNotification; responder.cancel() already - # sent an error response, so skip the duplicate. - logger.info("Request %s cancelled - duplicate response suppressed", message.request_id) - return - # Transport-close cancellation from the TG in run(); re-raise so the - # TG swallows its own cancellation. - raise - except Exception as err: - if raise_exceptions: # pragma: no cover - raise err - response = types.ErrorData(code=0, message=str(err)) - else: # pragma: no cover - response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found") - - try: - await message.respond(response) - except (anyio.BrokenResourceError, anyio.ClosedResourceError): - # Transport closed between handler unblocking and respond. Happens - # when _receive_loop's finally wakes a handler blocked on - # send_request: the handler runs to respond() before run()'s TG - # cancel fires, but after the write stream closed. Closed if our - # end closed (_receive_loop's async-with exit); Broken if the peer - # end closed first (streamable_http terminate()). - logger.debug("Response for %s dropped - transport closed", message.request_id) - return - - logger.debug("Response sent") + try: + # Extract request context and close_sse_stream from message metadata + request_data = None + close_sse_stream_cb = None + close_standalone_sse_stream_cb = None + if message.message_metadata is not None and isinstance( + message.message_metadata, ServerMessageMetadata + ): + request_data = message.message_metadata.request_context + close_sse_stream_cb = message.message_metadata.close_sse_stream + close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream + + client_capabilities = session.client_params.capabilities if session.client_params else None + task_support = self._experimental_handlers.task_support if self._experimental_handlers else None + # Get task metadata from request params if present + task_metadata = None + if hasattr(req, "params") and req.params is not None: + task_metadata = getattr(req.params, "task", None) + ctx = ServerRequestContext( + request_id=message.request_id, + meta=message.request_meta, + session=session, + lifespan_context=lifespan_context, + experimental=Experimental( + task_metadata=task_metadata, + _client_capabilities=client_capabilities, + _session=session, + _task_support=task_support, + ), + request=request_data, + close_sse_stream=close_sse_stream_cb, + close_standalone_sse_stream=close_standalone_sse_stream_cb, + ) + response = await handler(ctx, req.params) + except MCPError as err: + response = err.error + except anyio.get_cancelled_exc_class(): + if message.cancelled: + # Client sent CancelledNotification; responder.cancel() already + # sent an error response, so skip the duplicate. + logger.info("Request %s cancelled - duplicate response suppressed", message.request_id) + return + # Transport-close cancellation from the TG in run(); re-raise so the + # TG swallows its own cancellation. + raise + except Exception as err: + if raise_exceptions: # pragma: no cover + raise err + response = types.ErrorData(code=0, message=str(err)) + else: # pragma: no cover + response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found") + + try: + await message.respond(response) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + # Transport closed between handler unblocking and respond. Happens + # when _receive_loop's finally wakes a handler blocked on + # send_request: the handler runs to respond() before run()'s TG + # cancel fires, but after the write stream closed. Closed if our + # end closed (_receive_loop's async-with exit); Broken if the peer + # end closed first (streamable_http terminate()). + logger.debug("Response for %s dropped - transport closed", message.request_id) + return + + logger.debug("Response sent") async def _handle_notification( self, diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py new file mode 100644 index 000000000..94f02caa5 --- /dev/null +++ b/src/mcp/shared/_otel.py @@ -0,0 +1,43 @@ +"""OpenTelemetry helpers for MCP. + +Provides a context manager that creates an OpenTelemetry span when +``opentelemetry-api`` is installed, or acts as a no-op otherwise. +""" + +from __future__ import annotations + +import functools +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + + +@functools.lru_cache(maxsize=1) +def _get_tracer() -> Any: + """Return the OTel tracer for ``mcp``, or ``None``.""" + try: + from opentelemetry.trace import get_tracer + + return get_tracer("mcp-python-sdk") + except ImportError: + return None + + +@contextmanager +def otel_span( + name: str, + *, + kind: str = "INTERNAL", + attributes: dict[str, Any] | None = None, +) -> Iterator[Any]: + """Create an OTel span if ``opentelemetry-api`` is installed, else no-op.""" + tracer = _get_tracer() + if tracer is None: + yield None + return + + from opentelemetry.trace import SpanKind + + span_kind = getattr(SpanKind, kind, SpanKind.INTERNAL) + with tracer.start_as_current_span(name, kind=span_kind, attributes=attributes) as span: + yield span diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 3534fbb76..ac9706a58 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, TypeAdapter from typing_extensions import Self +from mcp.shared._otel import otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage @@ -269,23 +270,32 @@ async def send_request( try: jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) - await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata)) - # request read timeout takes precedence over session read timeout - timeout = request_read_timeout_seconds or self._session_read_timeout_seconds - - try: - with anyio.fail_after(timeout): - response_or_error = await response_stream_reader.receive() - except TimeoutError: - class_name = request.__class__.__name__ - message = f"Timed out while waiting for response to {class_name}. Waited {timeout} seconds." - raise MCPError(code=REQUEST_TIMEOUT, message=message) - - if isinstance(response_or_error, JSONRPCError): - raise MCPError.from_jsonrpc_error(response_or_error) - else: - return result_type.model_validate(response_or_error.result, by_name=False) + target = request_data.get("params", {}).get("name") + span_name = f"MCP {request.method} {target}" if target else f"MCP {request.method}" + + with otel_span( + span_name, + kind="CLIENT", + attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id}, + ): + await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata)) + + # request read timeout takes precedence over session read timeout + timeout = request_read_timeout_seconds or self._session_read_timeout_seconds + + try: + with anyio.fail_after(timeout): + response_or_error = await response_stream_reader.receive() + except TimeoutError: + class_name = request.__class__.__name__ + message = f"Timed out while waiting for response to {class_name}. Waited {timeout} seconds." + raise MCPError(code=REQUEST_TIMEOUT, message=message) + + if isinstance(response_or_error, JSONRPCError): + raise MCPError.from_jsonrpc_error(response_or_error) + else: + return result_type.model_validate(response_or_error.result, by_name=False) finally: self._response_streams.pop(request_id, None) diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py new file mode 100644 index 000000000..442953baf --- /dev/null +++ b/tests/shared/test_otel.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from mcp.shared._otel import _get_tracer, otel_span + +pytestmark = pytest.mark.anyio + + +def test_otel_span_creates_span(): + _get_tracer.cache_clear() + with otel_span("test.span", kind="CLIENT", attributes={"key": "value"}) as span: + assert span is not None + + +def test_otel_span_noop_when_unavailable(): + _get_tracer.cache_clear() + with patch.dict("sys.modules", {"opentelemetry": None, "opentelemetry.trace": None}): + _get_tracer.cache_clear() + with otel_span("test.span") as span: + assert span is None + _get_tracer.cache_clear() diff --git a/uv.lock b/uv.lock index 8f9a5396a..8ab0978c5 100644 --- a/uv.lock +++ b/uv.lock @@ -646,6 +646,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -814,6 +826,9 @@ cli = [ { name = "python-dotenv" }, { name = "typer" }, ] +otel = [ + { name = "opentelemetry-api" }, +] rich = [ { name = "rich" }, ] @@ -826,7 +841,7 @@ dev = [ { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, { name = "inline-snapshot" }, - { name = "mcp", extra = ["cli", "ws"] }, + { name = "mcp", extra = ["cli", "otel", "ws"] }, { name = "pillow" }, { name = "pyright" }, { name = "pytest" }, @@ -853,6 +868,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, { name = "httpx-sse", specifier = ">=0.4" }, { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, @@ -869,14 +885,14 @@ requires-dist = [ { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.31.1" }, { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, ] -provides-extras = ["cli", "rich", "ws"] +provides-extras = ["cli", "otel", "rich", "ws"] [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" }, - { name = "mcp", extras = ["cli", "ws"], editable = "." }, + { name = "mcp", extras = ["cli", "ws", "otel"], editable = "." }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest", specifier = ">=8.3.4" }, @@ -1642,6 +1658,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -2766,3 +2795,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From e1ebbc7e5944523e93f68adfbcef4d037c6fda6c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 13:58:17 -0400 Subject: [PATCH 02/14] Make opentelemetry-api a mandatory dependency Simplify _otel.py by removing the ImportError fallback and lru_cache since opentelemetry-api is now always available. --- pyproject.toml | 4 ++-- src/mcp/shared/_otel.py | 29 +++++------------------------ tests/shared/test_otel.py | 14 +------------- uv.lock | 12 +++++------- 4 files changed, 13 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 82a93a239..cc5b9c799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,13 +40,13 @@ dependencies = [ "pyjwt[crypto]>=2.10.1", "typing-extensions>=4.13.0", "typing-inspection>=0.4.1", + "opentelemetry-api>=1.28.0", ] [project.optional-dependencies] rich = ["rich>=13.9.4"] cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] ws = ["websockets>=15.0.1"] -otel = ["opentelemetry-api>=1.28.0"] [project.scripts] mcp = "mcp.cli:app [cli]" @@ -58,7 +58,7 @@ required-version = ">=0.9.5" [dependency-groups] dev = [ # We add mcp[cli,ws] so `uv sync` considers the extras. - "mcp[cli,ws,otel]", + "mcp[cli,ws]", "pyright>=1.1.400", "pytest>=8.3.4", "ruff>=0.8.5", diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index 94f02caa5..6d8044937 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -1,26 +1,14 @@ -"""OpenTelemetry helpers for MCP. - -Provides a context manager that creates an OpenTelemetry span when -``opentelemetry-api`` is installed, or acts as a no-op otherwise. -""" +"""OpenTelemetry helpers for MCP.""" from __future__ import annotations -import functools from collections.abc import Iterator from contextlib import contextmanager from typing import Any +from opentelemetry.trace import SpanKind, get_tracer -@functools.lru_cache(maxsize=1) -def _get_tracer() -> Any: - """Return the OTel tracer for ``mcp``, or ``None``.""" - try: - from opentelemetry.trace import get_tracer - - return get_tracer("mcp-python-sdk") - except ImportError: - return None +_tracer = get_tracer("mcp-python-sdk") @contextmanager @@ -30,14 +18,7 @@ def otel_span( kind: str = "INTERNAL", attributes: dict[str, Any] | None = None, ) -> Iterator[Any]: - """Create an OTel span if ``opentelemetry-api`` is installed, else no-op.""" - tracer = _get_tracer() - if tracer is None: - yield None - return - - from opentelemetry.trace import SpanKind - + """Create an OTel span.""" span_kind = getattr(SpanKind, kind, SpanKind.INTERNAL) - with tracer.start_as_current_span(name, kind=span_kind, attributes=attributes) as span: + with _tracer.start_as_current_span(name, kind=span_kind, attributes=attributes) as span: yield span diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 442953baf..295a2c64a 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -1,24 +1,12 @@ from __future__ import annotations -from unittest.mock import patch - import pytest -from mcp.shared._otel import _get_tracer, otel_span +from mcp.shared._otel import otel_span pytestmark = pytest.mark.anyio def test_otel_span_creates_span(): - _get_tracer.cache_clear() with otel_span("test.span", kind="CLIENT", attributes={"key": "value"}) as span: assert span is not None - - -def test_otel_span_noop_when_unavailable(): - _get_tracer.cache_clear() - with patch.dict("sys.modules", {"opentelemetry": None, "opentelemetry.trace": None}): - _get_tracer.cache_clear() - with otel_span("test.span") as span: - assert span is None - _get_tracer.cache_clear() diff --git a/uv.lock b/uv.lock index 8ab0978c5..9c6b58ccb 100644 --- a/uv.lock +++ b/uv.lock @@ -809,6 +809,7 @@ dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -826,9 +827,6 @@ cli = [ { name = "python-dotenv" }, { name = "typer" }, ] -otel = [ - { name = "opentelemetry-api" }, -] rich = [ { name = "rich" }, ] @@ -841,7 +839,7 @@ dev = [ { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, { name = "inline-snapshot" }, - { name = "mcp", extra = ["cli", "otel", "ws"] }, + { name = "mcp", extra = ["cli", "ws"] }, { name = "pillow" }, { name = "pyright" }, { name = "pytest" }, @@ -868,7 +866,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, { name = "httpx-sse", specifier = ">=0.4" }, { name = "jsonschema", specifier = ">=4.20.0" }, - { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.28.0" }, + { name = "opentelemetry-api", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, @@ -885,14 +883,14 @@ requires-dist = [ { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.31.1" }, { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, ] -provides-extras = ["cli", "otel", "rich", "ws"] +provides-extras = ["cli", "rich", "ws"] [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" }, - { name = "mcp", extras = ["cli", "ws", "otel"], editable = "." }, + { name = "mcp", extras = ["cli", "ws"], editable = "." }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest", specifier = ">=8.3.4" }, From 7795f34ec6afa4ca01a0721bf2ed49121cfd624e Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 14:07:32 -0400 Subject: [PATCH 03/14] Use logfire[testing] for OTel test assertions --- pyproject.toml | 1 + tests/shared/test_otel.py | 35 ++++++- uv.lock | 207 +++++++++++++++++++++++++++++++++++++- 3 files changed, 236 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc5b9c799..f7f8287bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ "coverage[toml]>=7.10.7,<=7.13", "pillow>=12.0", "strict-no-cover", + "logfire[testing]", ] docs = [ "mkdocs>=1.6.1", diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 295a2c64a..07a7cefd2 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -1,12 +1,39 @@ from __future__ import annotations import pytest +from logfire.testing import CaptureLogfire -from mcp.shared._otel import otel_span +from mcp.client.client import Client +from mcp.server.mcpserver import MCPServer pytestmark = pytest.mark.anyio -def test_otel_span_creates_span(): - with otel_span("test.span", kind="CLIENT", attributes={"key": "value"}) as span: - assert span is not None +async def test_client_and_server_spans(capfire: CaptureLogfire): + """Verify that calling a tool produces client and server spans with correct attributes.""" + server = MCPServer("test") + + @server.tool() + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + async with Client(server) as client: + result = await client.call_tool("greet", {"name": "World"}) + + assert result.content[0].text == "Hello, World!" # type: ignore[union-attr] + + spans = capfire.exporter.exported_spans_as_dict() + span_names = {s["name"] for s in spans} + + assert "MCP tools/call greet" in span_names + assert "MCP handle tools/call greet" in span_names + + client_span = next(s for s in spans if s["name"] == "MCP tools/call greet") + server_span = next(s for s in spans if s["name"] == "MCP handle tools/call greet") + + assert client_span["attributes"]["mcp.method.name"] == "tools/call" + assert server_span["attributes"]["mcp.method.name"] == "tools/call" + + # Server span should be in the same trace as the client span (context propagation). + assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"] diff --git a/uv.lock b/uv.lock index 9c6b58ccb..a068da578 100644 --- a/uv.lock +++ b/uv.lock @@ -579,6 +579,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.73.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, +] + [[package]] name = "griffe" version = "1.14.0" @@ -722,6 +734,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "logfire" +version = "4.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/fc/21f923243d8c3ca2ebfa97de46970ced734e66ac634c1c35b6abb41300f1/logfire-4.31.0.tar.gz", hash = "sha256:361bfda17c9d70ada5d220211033bae06b871ddac9d5b06978bc0ceca6b8e658", size = 1080609, upload-time = "2026-03-27T19:00:46.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/1a/8c860e35bf847ac0d647d94bad89dccbb66cbcafdd61d8334f8cc7cfdd58/logfire-4.31.0-py3-none-any.whl", hash = "sha256:49fad38b5e6f199a98e9c8814e860c8a42595bb81479b52a20413e53ee475b72", size = 308896, upload-time = "2026-03-27T19:00:43.107Z" }, +] + [[package]] name = "markdown" version = "3.9" @@ -839,6 +870,7 @@ dev = [ { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, { name = "inline-snapshot" }, + { name = "logfire" }, { name = "mcp", extra = ["cli", "ws"] }, { name = "pillow" }, { name = "pyright" }, @@ -890,6 +922,7 @@ dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" }, + { name = "logfire", extras = ["testing"] }, { name = "mcp", extras = ["cli", "ws"], editable = "." }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, @@ -1658,15 +1691,99 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.40.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, ] [[package]] @@ -1824,6 +1941,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -2794,6 +2926,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From e2d4b03e4cd9aebbcd159cf63b75fc979579f093 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 14:12:09 -0400 Subject: [PATCH 04/14] Use 'MCP send' prefix for outgoing request spans Both client and server can send requests, so use 'MCP send' for the sending side and 'MCP handle' for the receiving side consistently. --- src/mcp/shared/session.py | 2 +- tests/shared/test_otel.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index ac9706a58..41a2ab81d 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -272,7 +272,7 @@ async def send_request( jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) target = request_data.get("params", {}).get("name") - span_name = f"MCP {request.method} {target}" if target else f"MCP {request.method}" + span_name = f"MCP send {request.method} {target}" if target else f"MCP send {request.method}" with otel_span( span_name, diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 07a7cefd2..5d226afae 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -26,10 +26,10 @@ def greet(name: str) -> str: spans = capfire.exporter.exported_spans_as_dict() span_names = {s["name"] for s in spans} - assert "MCP tools/call greet" in span_names + assert "MCP send tools/call greet" in span_names assert "MCP handle tools/call greet" in span_names - client_span = next(s for s in spans if s["name"] == "MCP tools/call greet") + client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet") server_span = next(s for s in spans if s["name"] == "MCP handle tools/call greet") assert client_span["attributes"]["mcp.method.name"] == "tools/call" From 0e0b746b0cc203b66aec137ae00fd02f46e56caa Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 14:22:42 -0400 Subject: [PATCH 05/14] Propagate W3C trace context via _meta per SEP-414 Inject traceparent/tracestate into request _meta on send, extract it on the server side as the parent context for the handler span. This enables cross-process trace propagation over stdio and HTTP transports. --- src/mcp/server/lowlevel/server.py | 9 +++++++-- src/mcp/shared/_otel.py | 15 ++++++++++++++- src/mcp/shared/session.py | 12 +++++++++--- tests/shared/test_otel.py | 1 + 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 1068ee99a..321307c35 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -65,7 +65,7 @@ async def main(): from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared._otel import otel_span +from mcp.shared._otel import extract_trace_context, otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata, SessionMessage @@ -450,10 +450,15 @@ async def _handle_request( target = getattr(req.params, "name", None) if req.params else None span_name = f"MCP handle {req.method} {target}" if target else f"MCP handle {req.method}" + # Extract W3C trace context from _meta (SEP-414). + meta = getattr(req.params, "meta", None) if req.params else None + parent_context = extract_trace_context(meta) if isinstance(meta, dict) else None + with otel_span( span_name, kind="SERVER", attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id}, + context=parent_context, ): if handler := self._request_handlers.get(req.method): logger.debug("Dispatching request of type %s", type(req).__name__) @@ -474,7 +479,7 @@ async def _handle_request( task_support = self._experimental_handlers.task_support if self._experimental_handlers else None # Get task metadata from request params if present task_metadata = None - if hasattr(req, "params") and req.params is not None: + if hasattr(req, "params") and req.params is not None: # pragma: no branch task_metadata = getattr(req.params, "task", None) ctx = ServerRequestContext( request_id=message.request_id, diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index 6d8044937..0e5eece68 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -6,6 +6,8 @@ from contextlib import contextmanager from typing import Any +from opentelemetry.context import Context +from opentelemetry.propagate import extract, inject from opentelemetry.trace import SpanKind, get_tracer _tracer = get_tracer("mcp-python-sdk") @@ -17,8 +19,19 @@ def otel_span( *, kind: str = "INTERNAL", attributes: dict[str, Any] | None = None, + context: Context | None = None, ) -> Iterator[Any]: """Create an OTel span.""" span_kind = getattr(SpanKind, kind, SpanKind.INTERNAL) - with _tracer.start_as_current_span(name, kind=span_kind, attributes=attributes) as span: + with _tracer.start_as_current_span(name, kind=span_kind, attributes=attributes, context=context) as span: yield span + + +def inject_trace_context(meta: Any) -> None: + """Inject W3C trace context (traceparent/tracestate) into a ``_meta`` dict.""" + inject(meta) + + +def extract_trace_context(meta: Any) -> Context: + """Extract W3C trace context from a ``_meta`` dict.""" + return extract(meta) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 41a2ab81d..e0bc164cf 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, TypeAdapter from typing_extensions import Self -from mcp.shared._otel import otel_span +from mcp.shared._otel import inject_trace_context, otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage @@ -269,8 +269,6 @@ async def send_request( self._progress_callbacks[request_id] = progress_callback try: - jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) - target = request_data.get("params", {}).get("name") span_name = f"MCP send {request.method} {target}" if target else f"MCP send {request.method}" @@ -279,6 +277,14 @@ async def send_request( kind="CLIENT", attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id}, ): + # Inject W3C trace context into _meta (SEP-414). + if "params" not in request_data: + request_data["params"] = {} + if "_meta" not in request_data["params"]: + request_data["params"]["_meta"] = {} + inject_trace_context(request_data["params"]["_meta"]) + + jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata)) # request read timeout takes precedence over session read timeout diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 5d226afae..0bc722150 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -9,6 +9,7 @@ pytestmark = pytest.mark.anyio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") async def test_client_and_server_spans(capfire: CaptureLogfire): """Verify that calling a tool produces client and server spans with correct attributes.""" server = MCPServer("test") From 54f87874d2e8731567b7e44b5f44b1376231542d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 14:57:19 -0400 Subject: [PATCH 06/14] Address review feedback on type annotations and _meta injection --- src/mcp/server/lowlevel/server.py | 6 +++--- src/mcp/shared/_otel.py | 4 ++-- src/mcp/shared/session.py | 7 ++----- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 321307c35..3074dd515 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -42,7 +42,7 @@ async def main(): from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from importlib.metadata import version as importlib_version -from typing import Any, Generic +from typing import Any, Generic, cast import anyio from starlette.applications import Starlette @@ -451,8 +451,8 @@ async def _handle_request( span_name = f"MCP handle {req.method} {target}" if target else f"MCP handle {req.method}" # Extract W3C trace context from _meta (SEP-414). - meta = getattr(req.params, "meta", None) if req.params else None - parent_context = extract_trace_context(meta) if isinstance(meta, dict) else None + meta = cast(dict[str, Any] | None, getattr(req.params, "meta", None)) if req.params else None + parent_context = extract_trace_context(meta) if meta is not None else None with otel_span( span_name, diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index 0e5eece68..1acf4dd91 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -27,11 +27,11 @@ def otel_span( yield span -def inject_trace_context(meta: Any) -> None: +def inject_trace_context(meta: dict[str, Any]) -> None: """Inject W3C trace context (traceparent/tracestate) into a ``_meta`` dict.""" inject(meta) -def extract_trace_context(meta: Any) -> Context: +def extract_trace_context(meta: dict[str, Any]) -> Context: """Extract W3C trace context from a ``_meta`` dict.""" return extract(meta) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index e0bc164cf..253db8657 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -278,11 +278,8 @@ async def send_request( attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id}, ): # Inject W3C trace context into _meta (SEP-414). - if "params" not in request_data: - request_data["params"] = {} - if "_meta" not in request_data["params"]: - request_data["params"]["_meta"] = {} - inject_trace_context(request_data["params"]["_meta"]) + meta: dict[str, Any] = request_data.setdefault("params", {}).setdefault("_meta", {}) + inject_trace_context(meta) jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata)) From 2369988904570ffc2af8e0d1a90478c9118c5aef Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 15:01:55 -0400 Subject: [PATCH 07/14] Skip OTel test when logfire.testing is not available The lowest-direct CI resolution may not install a logfire version that includes the testing module. --- tests/shared/test_otel.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 0bc722150..31e353c4e 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -1,10 +1,12 @@ from __future__ import annotations import pytest -from logfire.testing import CaptureLogfire -from mcp.client.client import Client -from mcp.server.mcpserver import MCPServer +logfire_testing = pytest.importorskip("logfire.testing") +CaptureLogfire = logfire_testing.CaptureLogfire + +from mcp.client.client import Client # noqa: E402 +from mcp.server.mcpserver import MCPServer # noqa: E402 pytestmark = pytest.mark.anyio From 03918548d8b84515af22c600528bc9541af66c2d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 15:05:07 -0400 Subject: [PATCH 08/14] Pin logfire>=3.0.0 dev dependency and restore direct import --- pyproject.toml | 2 +- tests/shared/test_otel.py | 8 +++----- uv.lock | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f7f8287bd..be1200cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ dev = [ "coverage[toml]>=7.10.7,<=7.13", "pillow>=12.0", "strict-no-cover", - "logfire[testing]", + "logfire>=3.0.0", ] docs = [ "mkdocs>=1.6.1", diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 31e353c4e..0bc722150 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -1,12 +1,10 @@ from __future__ import annotations import pytest +from logfire.testing import CaptureLogfire -logfire_testing = pytest.importorskip("logfire.testing") -CaptureLogfire = logfire_testing.CaptureLogfire - -from mcp.client.client import Client # noqa: E402 -from mcp.server.mcpserver import MCPServer # noqa: E402 +from mcp.client.client import Client +from mcp.server.mcpserver import MCPServer pytestmark = pytest.mark.anyio diff --git a/uv.lock b/uv.lock index a068da578..5efbb05dc 100644 --- a/uv.lock +++ b/uv.lock @@ -922,7 +922,7 @@ dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" }, - { name = "logfire", extras = ["testing"] }, + { name = "logfire", specifier = ">=3.0.0" }, { name = "mcp", extras = ["cli", "ws"], editable = "." }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, From 1ee1dfd9a60b3e75deb1cd445f76a3e59fce69c7 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 15:06:32 -0400 Subject: [PATCH 09/14] Use single backticks in docstrings --- src/mcp/shared/_otel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index 1acf4dd91..f10192e7e 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -28,10 +28,10 @@ def otel_span( def inject_trace_context(meta: dict[str, Any]) -> None: - """Inject W3C trace context (traceparent/tracestate) into a ``_meta`` dict.""" + """Inject W3C trace context (traceparent/tracestate) into a `_meta` dict.""" inject(meta) def extract_trace_context(meta: dict[str, Any]) -> Context: - """Extract W3C trace context from a ``_meta`` dict.""" + """Extract W3C trace context from a `_meta` dict.""" return extract(meta) From d9dcc85b5bda9c74e3d1f1d9e90ea0c84675a1e0 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 15:29:00 -0400 Subject: [PATCH 10/14] Record ERROR status on server spans for error responses --- src/mcp/server/lowlevel/server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 3074dd515..bb5af077d 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -459,7 +459,7 @@ async def _handle_request( kind="SERVER", attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id}, context=parent_context, - ): + ) as span: if handler := self._request_handlers.get(req.method): logger.debug("Dispatching request of type %s", type(req).__name__) @@ -515,6 +515,11 @@ async def _handle_request( else: # pragma: no cover response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found") + if isinstance(response, types.ErrorData) and span is not None: + from opentelemetry.trace import StatusCode + + span.set_status(StatusCode.ERROR, response.message) + try: await message.respond(response) except (anyio.BrokenResourceError, anyio.ClosedResourceError): From f79767e3221c97fea2a5a23f67184907b7b76459 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 15:40:02 -0400 Subject: [PATCH 11/14] Move StatusCode import to top of file --- src/mcp/server/lowlevel/server.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index bb5af077d..4ec43a710 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -45,6 +45,7 @@ async def main(): from typing import Any, Generic, cast import anyio +from opentelemetry.trace import StatusCode from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware @@ -516,8 +517,6 @@ async def _handle_request( response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found") if isinstance(response, types.ErrorData) and span is not None: - from opentelemetry.trace import StatusCode - span.set_status(StatusCode.ERROR, response.message) try: From 8ae416e9bfa0b9a701f427b693c42d89435b8c89 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 15:43:38 -0400 Subject: [PATCH 12/14] Use SpanKind enum directly instead of string conversion --- src/mcp/server/lowlevel/server.py | 4 ++-- src/mcp/shared/_otel.py | 5 ++--- src/mcp/shared/session.py | 3 ++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 4ec43a710..59de0ace4 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -45,7 +45,7 @@ async def main(): from typing import Any, Generic, cast import anyio -from opentelemetry.trace import StatusCode +from opentelemetry.trace import SpanKind, StatusCode from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware @@ -457,7 +457,7 @@ async def _handle_request( with otel_span( span_name, - kind="SERVER", + kind=SpanKind.SERVER, attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id}, context=parent_context, ) as span: diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py index f10192e7e..170e873a0 100644 --- a/src/mcp/shared/_otel.py +++ b/src/mcp/shared/_otel.py @@ -17,13 +17,12 @@ def otel_span( name: str, *, - kind: str = "INTERNAL", + kind: SpanKind, attributes: dict[str, Any] | None = None, context: Context | None = None, ) -> Iterator[Any]: """Create an OTel span.""" - span_kind = getattr(SpanKind, kind, SpanKind.INTERNAL) - with _tracer.start_as_current_span(name, kind=span_kind, attributes=attributes, context=context) as span: + with _tracer.start_as_current_span(name, kind=kind, attributes=attributes, context=context) as span: yield span diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 253db8657..243eef5ae 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -9,6 +9,7 @@ import anyio from anyio.streams.memory import MemoryObjectSendStream +from opentelemetry.trace import SpanKind from pydantic import BaseModel, TypeAdapter from typing_extensions import Self @@ -274,7 +275,7 @@ async def send_request( with otel_span( span_name, - kind="CLIENT", + kind=SpanKind.CLIENT, attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id}, ): # Inject W3C trace context into _meta (SEP-414). From b47c9c5cf91ba232290a403227104fe444ee9a0b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 15:46:37 -0400 Subject: [PATCH 13/14] Add comment explaining filterwarnings on OTel test --- tests/shared/test_otel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 0bc722150..41ede6005 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -9,6 +9,8 @@ pytestmark = pytest.mark.anyio +# Logfire warns about propagated trace context by default (distributed_tracing=None). +# This is expected here since we're testing cross-boundary context propagation. @pytest.mark.filterwarnings("ignore::RuntimeWarning") async def test_client_and_server_spans(capfire: CaptureLogfire): """Verify that calling a tool produces client and server spans with correct attributes.""" From e00d336ee6cc26ac2dd08ca7eec4e2e7dd8ef89b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 31 Mar 2026 16:29:17 -0400 Subject: [PATCH 14/14] Use isinstance assert instead of type: ignore in OTel test --- tests/shared/test_otel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 41ede6005..ec7ff78cc 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -3,6 +3,7 @@ import pytest from logfire.testing import CaptureLogfire +from mcp import types from mcp.client.client import Client from mcp.server.mcpserver import MCPServer @@ -24,7 +25,8 @@ def greet(name: str) -> str: async with Client(server) as client: result = await client.call_tool("greet", {"name": "World"}) - assert result.content[0].text == "Hello, World!" # type: ignore[union-attr] + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == "Hello, World!" spans = capfire.exporter.exported_spans_as_dict() span_names = {s["name"] for s in spans}