-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat: add OpenTelemetry tracing for client and server requests #2025
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Add opentelemetry-api as a dependency and instrument BaseSession.send_request() with a CLIENT span per outgoing request. This establishes the tracing foundation for the SDK. Each request gets a span with the MCP method name and, where applicable, the target name (tool name, prompt name, resource URI). Spans record OK/ERROR status and error type attributes. Excluded methods (e.g. notifications/message) are skipped.
tests/shared/test_tracing.py
Outdated
| # pyright: reportMissingImports=false, reportUnknownVariableType=false | ||
| # pyright: reportUnknownMemberType=false, reportUnknownParameterType=false | ||
| # pyright: reportUnknownArgumentType=false | ||
| # opentelemetry-sdk does not ship type stubs, so we suppress unknown-type errors. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems it does. I can see the py.typed.
src/mcp/shared/session.py
Outdated
| # Store the callback for this request | ||
| self._progress_callbacks[request_id] = progress_callback | ||
|
|
||
| method = request_data.get("method", "") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a case that we don't have a method?
src/mcp/shared/tracing.py
Outdated
| def _build_span_name(method: str, target: str | None) -> str: | ||
| """Build a span name like 'tools/call my_tool' or just 'ping'.""" | ||
| if target: | ||
| return f"{method} {target}" | ||
| return method |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No need for this utility.
tests/shared/test_tracing.py
Outdated
| async with create_client_server_memory_streams() as (client_streams, server_streams): | ||
| client_read, client_write = client_streams | ||
| server_read, server_write = server_streams |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to use mcp.client.Client instead?
tests/shared/test_tracing.py
Outdated
| @pytest.mark.parametrize( | ||
| ("method", "target", "expected"), | ||
| [ | ||
| ("tools/call", "my_tool", "tools/call my_tool"), | ||
| ("ping", None, "ping"), | ||
| ], | ||
| ) | ||
| def test_build_span_name(method: str, target: str | None, expected: str) -> None: | ||
| assert _build_span_name(method, target) == expected |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't test private functions! Please document that in the CLAUDE.md.
tests/shared/test_tracing.py
Outdated
| @pytest.mark.parametrize( | ||
| ("method", "params", "expected"), | ||
| [ | ||
| ("tools/call", {"name": "my_tool"}, "my_tool"), | ||
| ("prompts/get", {"name": "my_prompt"}, "my_prompt"), | ||
| ("resources/read", {"uri": "file:///a.txt"}, "file:///a.txt"), | ||
| ("ping", None, None), | ||
| ("tools/call", {}, None), | ||
| ("tools/call", {"name": 123}, None), | ||
| ], | ||
| ) | ||
| def test_extract_target(method: str, params: dict[str, Any] | None, expected: str | None) -> None: | ||
| assert _extract_target(method, params) == expected |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't test private functions! Please document that in the CLAUDE.md.
- Remove pyright suppressions (opentelemetry-sdk ships py.typed)
- Use `request_data["method"]` instead of `.get("method", "")`
- Inline `_build_span_name` into `start_client_span`
- Remove unit tests for private functions, test through E2E instead
- Rewrite error test to use `Client` (timeout-based)
- Add "do not test private functions" rule to CLAUDE.md
src/mcp/shared/session.py
Outdated
| # Store the callback for this request | ||
| self._progress_callbacks[request_id] = progress_callback | ||
|
|
||
| method: str = request_data["method"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the cast needed?
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
Instrument the server side of MCP request handling with OpenTelemetry SERVER spans. The span starts after request validation in _receive_loop and ends when RequestResponder.respond() or cancel() is called, covering the full server-side request lifecycle.
src/mcp/shared/session.py
Outdated
| server_span = start_server_span( | ||
| request_data.get("method", ""), | ||
| request_data.get("params"), | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Single line please. Is the method not always available in request_data?
Summary
opentelemetry-apias a runtime dependency andopentelemetry-sdkas a dev dependencysrc/mcp/shared/tracing.pywith a module-level tracer, attribute constants, and helpers for span naming (including target extraction fortools/call,prompts/get,resources/read)BaseSession.send_request()with a singleCLIENTspan per outgoing request — records method name, target, OK/ERROR status, and error typenotifications/message) produce no spanThis is the minimal foundation for OpenTelemetry support. No context propagation, metrics, or server-side spans yet — just one client-side span per request to establish the pattern.
Test plan
_extract_target()and_build_span_name()helpers (parametrized)send_ping()tools/callrequestserror.typeattribute on JSON-RPC error responseruff format+ruff checkpasspyrightstrict passes (0 errors)