feat(mcp): add MCPOperation, resource/prompt instrumentation, transport context (GAPs 2-4)#272
Open
adityamehra wants to merge 17 commits intomainfrom
Open
feat(mcp): add MCPOperation, resource/prompt instrumentation, transport context (GAPs 2-4)#272adityamehra wants to merge 17 commits intomainfrom
adityamehra wants to merge 17 commits intomainfrom
Conversation
…conventions
MCPToolCall spans now use {mcp.method.name} {tool_name} format (e.g.
"tools/call add") with CLIENT/SERVER SpanKind, matching the OTel MCP
semconv spec. Previously used "execute_tool {tool_name}" with INTERNAL.
Added explicit bucket boundaries [0.01..300] to all MCP duration
histograms per semconv specification.
Made-with: Cursor
…nsport context (GAPs 2-4) - Add MCPOperation dataclass for non-tool MCP operations (resources/read, prompts/get, tools/list) - Add new semconv attributes: jsonrpc.request.id, mcp.resource.uri, gen_ai.prompt.name, rpc.response.status_code, server.address/port, client.address/port, network.protocol.* - Rename mcp_server_name to sdot_mcp_server_name for clarity - Add MCPRequestContext ContextVar for transport-to-server metadata propagation - Detect network.transport dynamically (pipe vs tcp) instead of hardcoding - Instrument Client.read_resource, Client.get_prompt, FastMCP.read_resource, FastMCP.get_prompt - Convert tools/list from Step to MCPOperation with proper span naming - Add e2e examples for prompt (wttr.in weather) and resource (system dashboard) operations - Update TelemetryHandler, SpanEmitter, MetricsEmitter with MCPOperation dispatch Made-with: Cursor
…ction, trim MCPRequestContext - Extract _traced_mcp_operation() helper in client_instrumentor to DRY the identical start/stop/fail + duration pattern across list_tools, read_resource, and get_prompt wrappers - Consolidate _detect_client_transport and _detect_server_transport into a single detect_transport() in utils.py, used by both client and transport instrumentors - Remove 7 unused fields from MCPRequestContext (network_protocol_name/version, client_address/port, server_address/port, baggage) that were never populated by the transport instrumentor - Trim _enrich_from_request_context to only copy the 2 fields actually set (jsonrpc_request_id, network_transport) - Net reduction: -65 lines across 5 files Made-with: Cursor
HIGH-1: Hook render_prompt (not get_prompt) for prompts/get on server. FastMCP 3.x routes MCP prompts/get to render_prompt; get_prompt is only the internal lookup. Now hooks both with graceful fallback for 2.x compatibility via _try_wrap. HIGH-2: Skip SDOT server spans when FastMCP >= 3.x has native telemetry. FastMCP 3.x ships server_span() in fastmcp.server.telemetry that already creates SERVER spans for call_tool, read_resource, and render_prompt. _has_native_telemetry() detects this and our wrappers pass through to avoid duplicate instrumentation. MED-1: Fix server-side transport detection. detect_transport() now falls back to fastmcp.settings.transport when the instance has no transport attribute (which is the case for the low-level mcp.server.lowlevel.Server in _handle_request). MED-2: Fix MCPToolCall error path missing generic duration metric. on_error for MCPToolCall now records both mcp.*.operation.duration and gen_ai.client.operation.duration, using duration_s (consistent with _record_mcp_operation_metrics) instead of relying on end_time. Also: add upper version bound (fastmcp >= 2.0.0, < 4) to _instruments and pyproject.toml. New tests for render_prompt, read_resource, native-telemetry dedupe, transport detection, and metrics error path. Tests: 199 util passed, 88 fastmcp passed (up from 74). Made-with: Cursor
etserend
reviewed
Apr 15, 2026
| duration, attributes=metric_attrs, context=context | ||
| ) | ||
| return | ||
|
|
Contributor
There was a problem hiding this comment.
handles() missing MCPOperation → non-tool MCP metrics silently dropped
Contributor
Author
There was a problem hiding this comment.
Good catch! Fixed.
The transport_instrumentor is a temporary bridge for mcp SDK v1.x which lacks native OTel context propagation. Native support has landed on the upstream main branch (PRs #2298, #2381) targeting mcp v2.x. Move detailed upstream tracking and migration plan to README.rst, keep the module docstring minimal with a pointer to the README. Made-with: Cursor
MCPOperation was missing from the handles() type check, causing the CompositeEmitter to silently skip MetricsEmitter for plain MCP operations (tools/list, resources/read, prompts/get). MCPToolCall was unaffected (inherits from ToolCall) but non-tool operations lost mcp.client/server .operation.duration metrics. Add tests for handles(), on_end metrics for client/server MCPOperation, and mcp.method.name attribute correctness across all MCP operation types. Made-with: Cursor
wrisa
reviewed
Apr 15, 2026
6 tasks
Merge origin/main into fix/mcp-gaps-2-3-4, resolving conflicts in: - CHANGELOGs: combine our MCP entries with main's eval cost bucket entry - instruments.py: accept main's _GEN_AI_EVALUATION_COST_BUCKETS - span.py: keep MCPOperation import and our on_start dispatch (main's _start_tool_call MCPToolCall dispatch is superseded by MCPOperation path) - test_tool_call_span_attributes.py: keep our full test suite with MCPOperation and metrics tests Made-with: Cursor
Brings in session duration metrics (#273) and evals error-resilience test updates (#276). Resolved conflict in server_instrumentor.py by keeping both our GAP 2-3-4 instrumentation (read_resource, render_prompt, native telemetry detection) and main's new _server_run_wrapper for mcp.server.session.duration tracking. Made-with: Cursor
…kHandler change langgraph 1.1.7 introduced GraphCallbackHandler as a required base class for all callback handlers passed to graphs. This breaks our LangchainCallbackHandler which inherits from BaseCallbackHandler. Pin to <= 1.1.6 until we add proper GraphCallbackHandler support. Made-with: Cursor
…her agent example - Client instrumentor: explicitly attach/detach span context around wrapped calls so TransportInstrumentor.propagate.inject() propagates our trace instead of FastMCP's native telemetry spans (which use start_as_current_span) - Server instrumentor: use _try_wrap for FastMCP 2.x/3.x compat, remove _has_native_telemetry check (always instrument) - Span emitter: remove redundant remote_parent_context handling (transport instrumentor already attaches context via context.attach) - E2E examples: use PythonStdioTransport with explicit env to work around MCP SDK's default env allowlist stripping OTEL_* vars from subprocesses - Add weather_agent example with --manual/zero-code instrumentation modes - Pin langgraph <=1.1.6 in CI to avoid breaking GraphCallbackHandler change Made-with: Cursor
…urface Add version compatibility matrix (0.1.x for FastMCP 2.x, 0.2.0 for FastMCP 3.x), document the expanded server-side 3.x API surface, and update references to both FastMCP repos and OTel MCP semconv. Made-with: Cursor
- Bump version to 0.2.0, pin fastmcp >= 3.0.0, < 4 - server_instrumentor: wrap FastMCP.call_tool/read_resource/render_prompt (3.x API surface), remove 2.x ToolManager fallbacks - Add ContextVar reentrancy guard for FastMCP 3.x call_tool recursion - Fix compatibility matrix: util-genai <= 0.1.9 for 0.1.1 (no 0.1.10 on PyPI) - Forward FASTMCP_* env vars to server subprocess in weather_agent Made-with: Cursor
The handler's _push_current_span (PR #235) skips context_api.attach() in async contexts to avoid cross-task ValueError. Since FastMCP is async, propagate.inject() in the transport instrumentor never sees the client span, producing an empty traceparent — server spans are emitted as separate traces instead of children of the client span. Add _activate_span() context manager that does a scoped attach/detach around the wrapped call. This is safe because both operations happen in the same asyncio.Task (the exact cross-task scenario PR #235 protects against cannot occur here). Applied to all client operations: call_tool, list_tools, read_resource, get_prompt. Also pass FASTMCP_* env vars to the server subprocess in client.py example so FASTMCP_TELEMETRY_OPT_OUT reaches the server. Made-with: Cursor
…r tool calls Tool name (e.g. "add") was incorrectly passed as request_model to _get_metric_attributes, populating gen_ai.request.model on the gen_ai.client.operation.duration metric. Use gen_ai.tool.name instead. Also gate MCPToolCall duration recording to client-side only — the server already emits mcp.server.operation.duration via _record_mcp_operation_metrics. Made-with: Cursor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Builds on PR #268 to close the remaining gaps between SDOT's FastMCP instrumentation and the OTel MCP semantic conventions.
MCPOperationdataclass with all required/recommended attributes (jsonrpc.request.id,mcp.resource.uri,gen_ai.prompt.name,rpc.response.status_code,server.address/port,client.address/port,network.protocol.*). Renamemcp_server_name→sdot_mcp_server_name.network.transport(pipevstcp) instead of hardcoding. AddMCPRequestContextContextVar to propagate server-side metadata (jsonrpc request id, transport) from the transport instrumentor to the server instrumentor.resources/readandprompts/getinstrumentation: InstrumentClient.read_resource,Client.get_prompt,FastMCP.read_resource,FastMCP.get_promptwith properMCPOperationlifecycle.Additionally:
tools/listfromSteptoMCPOperationfor consistent span naming_traced_mcp_operation()helperdetect_transport()in utilsMCPRequestContextto only populated fieldsSample Telemetry
All telemetry captured by running the e2e examples under
examples/e2e/.Spans — Tool Operations
Spans — Prompt Operations
Spans — Resource Operations
Spans — Session
Metrics
Key Files Changed
util/.../types.pyMCPOperationdataclass, refactorMCPToolCallMROutil/.../handler.pystart/stop/fail_mcp_operationlifecycle methodsutil/.../emitters/span.pyMCPOperationdispatch inon_start/on_end/on_errorutil/.../emitters/metrics.pyMCPOperationdispatch, unified_record_mcp_operation_metricsfastmcp/_mcp_context.pyMCPRequestContextContextVar (3 fields)fastmcp/client_instrumentor.pyread_resource/get_prompthooks, shareddetect_transportfastmcp/server_instrumentor.pyread_resource/get_prompthooks,_enrich_from_request_contextfastmcp/transport_instrumentor.pyfastmcp/utils.pydetect_transport()examples/e2e/prompt/examples/e2e/resource/Breaking Changes
MCPToolCall.mcp_server_namerenamed toMCPToolCall.sdot_mcp_server_name(semconv field:sdot.mcp.server_name)MCPOperationfieldnameis nowtarget(to avoid MRO conflict withToolCall.name)Test Plan
pytest util/opentelemetry-util-genai/tests/— 197 passedpytest instrumentation-genai/opentelemetry-instrumentation-fastmcp/tests/— 74 passedruff check+ruff format— cleanexamples/e2e/run_demo.py— tools/list, tools/call spans verifiedexamples/e2e/prompt/client.py— prompts/get spans verifiedexamples/e2e/resource/client.py— resources/read spans verifiedMade with Cursor