Repository navigation
Description
Description
The MCP send <method> [<target>] CLIENT span created in JSONRPCDispatcher.send_raw_request only carries mcp.method.name and jsonrpc.request.id:
# src/mcp/shared/jsonrpc_dispatcher.py (v2.3.0 / main)
with otel_span(
span_name,
kind=SpanKind.CLIENT,
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
) as span:A client connected to several MCP servers therefore can't tell from its own telemetry which server a call went to. The span name includes the tool name, but tool names aren't unique across servers, and when the server doesn't emit traces (third-party hosted servers, servers without OTel) the client span is the only record of the call.
Use case. I run Open WebUI with three MCP servers (a Playwright-based fetch server, a smart-home server, and a hosted third-party server). Tempo's span-metrics processor turns the MCP send tools/call <tool> spans into per-tool call counts and latency in Grafana. I'd like to group/filter those by server, but the only option today is guessing from tool-name patterns. Only one of the three servers emits its own SERVER spans, so following child spans doesn't help for the others.
What the semantic conventions say. The GenAI MCP semantic conventions define these on MCP client spans, none of which are currently set:
server.address/server.port(Recommended; for client spans, the address of the server behind any intermediaries, if known)network.transport(Recommended;tcpfor HTTP,pipefor stdio),network.protocol.name/network.protocol.version(Recommendedwhere applicable)mcp.session.id,mcp.protocol.version(Recommended)gen_ai.tool.name(Conditionally Requiredwhen the operation relates to a specific tool, i.e. everytools/call) andgen_ai.operation.name(Recommended). Both were added to the server-sideOpenTelemetryMiddlewarein Add GenAI semantic-convention attributes to OpenTelemetryMiddleware #2970 but are still missing on the client span.
server.address / server.port are the important ones here: with them, a span-metrics or service-graph processor can break tool calls down by server with no client-side changes.
Implementation note. I realise the dispatcher is transport-agnostic and doesn't know the URL. The TODO right there (move the otel span + inject into an outbound middleware once that seam exists) and #3715 point in the same direction. Some options:
- The transport could expose connection metadata (address, port, transport kind) to the session/dispatcher when it's created, and the dispatcher adds it to every outbound span. For Streamable HTTP this comes from the URL; for stdio,
server.addressdoesn't apply butnetwork.transport=pipe(and perhaps the command name) does. - Or this lands as part of the client-side outbound seam proposed in Client: expose an outbound request seam (the send_raw_request TODO) so observers can see a request before it is written #3715.
A smaller stopgap that would also help: an optional, user-supplied set of extra span attributes on Client / ClientSession (e.g. Client(..., otel_attributes={"server.address": ...})), so applications like Open WebUI can tag spans with the connection they already know about.
References
- GenAI MCP semantic conventions, client span attributes: https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md
- Add richer OTel MCP span attributes #2387: open PR adding
rpc.*,mcp.resource.uri,mcp.session.idto client/server spans (related, but no server identity) - Add GenAI semantic-convention attributes to OpenTelemetryMiddleware #2970: merged, GenAI semconv attributes on the server-side middleware only
- Client: expose an outbound request seam (the send_raw_request TODO) so observers can see a request before it is written #3715: client outbound request seam, the natural home for transport-aware span enrichment
- Middleware Support in MCP #1233: middleware support
Activity
Pranjulchaurasiya commentedon Nov 10, 2026
Hi maintainers,
I took a look at
src/mcp/shared/jsonrpc_dispatcher.py(lines 387–391) andsrc/mcp/client/session.py. Currently,send_raw_requestcreates theSpanKind.CLIENTspan with only:Because
JSONRPCDispatcheris transport-agnostic and outbound middleware (#3715) is still a work-in-progress, here is a minimal, non-invasive approach to resolve the client telemetry gap without pre-empting the architectural middleware seam:Tool Semantic Conventions (in-place):
When
method == "tools/call"andisinstance(target, str)is present, add"gen_ai.tool.name": targetdirectly to the span attributes (matching the server-side convention inOpenTelemetryMiddlewareadded in Add GenAI semantic-convention attributes to OpenTelemetryMiddleware #2970).Server & Transport Attribution (
server.address,server.port,network.transport):Add an optional
otel_attributes: Mapping[str, Any] | None = Noneparameter toJSONRPCDispatcher.__init__(copied to an immutable dict), which merges into the base attributes of every outbound client span. Expose this throughClientSession(..., otel_attributes=...).server.address,server.port, and connection metadata today.TODO(maxisbey)at line 384.Testing:
Add unit tests in
tests/shared/test_jsonrpc_dispatcher.pyverifying that:gen_ai.tool.nameontools/call.otel_attributespassed to the session/dispatcher are correctly merged onto client spans without leaking into unrelated spans.If this bounded approach fits the SDK's direction, I would be glad to be assigned and submit a focused PR with tests.
(Prepared with AI assistance)
media-ctrl commentedon Nov 10, 2026
Verified against main at 77001c9 (Nov 5), and the two attribute sets differ in a way that points at the shape of the fix rather than at the list of keys.
Client span, src/mcp/shared/jsonrpc_dispatcher.py line 390: mcp.method.name and jsonrpc.request.id, nothing else. The tool name is already local one line earlier, at line 382, where target is read out of params.
Server span, src/mcp/server/_otel.py lines 21 to 31: mcp.method.name, mcp.protocol.version, jsonrpc.request.id, then gen_ai.operation.name set to execute_tool and gen_ai.tool.name when the method is tools/call.
The gap is not only which keys are missing. The server sets mcp.protocol.version because it has a request context that carries the negotiated version. The dispatcher is transport agnostic and is handed method and params only, so the negotiated protocol version is invisible to it as well. An otel_attributes keyword argument on Client therefore fixes server.address and still leaves every client span without mcp.protocol.version, which keeps client and server spans from lining up in a span metrics processor.
That is the argument for the seam in #3715 carrying a small context object rather than an attribute dict: method, target, negotiated protocol version, and transport identity where the transport knows it. With that in place, gen_ai.operation.name and gen_ai.tool.name become a two line change mirroring the server middleware exactly.
On stdio I would set network.transport to pipe and leave server.address unset. Naming the command is tempting, but a command is not an address, and two sessions of the same binary collide the same way two tool names do.
Our own server at corpusiq.io calls several upstream business APIs in read only mode, and on hosted upstreams that emit no traces of their own the client span is the only record that the call happened, which is why the ordering matters to us: tool name first, server identity second. Happy to exercise a stopgap against real traffic if field data would help.
Pranjulchaurasiya commentedon Nov 11, 2026
Thanks @media-ctrl, this is a really helpful breakdown.
Looking at
src/mcp/server/_otel.py(lines 21–31), mirroring that exact mapping on the client side makes total sense:Immediate tool & operation attributes in
send_raw_request:Since
targetis already parsed at line 382, setting:gen_ai.operation.name = "execute_tool"andgen_ai.tool.name = target(fortools/call)gen_ai.prompt.name = target(forprompts/get)gives immediate visibility into the tool and prompt names with zero transport or context churn.
Session & protocol alignment (
mcp.protocol.versionand transport identity):Good catch on
mcp.protocol.version. BecauseClientSessionknows the negotiated protocol version afterinitialize(), as well as transport endpoint info (HTTP host/port vs.network.transport = "pipe"on stdio), feeding that down via a small session context or metadata object keeps client and server spans symmetrical in span metrics.I'd be glad to put together a PR with tests covering the tool/prompt semconv attributes first and wiring through the session context. If @davidsp, @marcelotryle, or @maxisbey could assign this issue to me, I'll open the PR and would welcome any testing against your corpusiq.io traffic!
(Prepared with AI assistance)
media-ctrl commentedon Nov 11, 2026
Sounds right to me. Two things worth pinning down in that PR, both learned from running a production MCP server:
Cardinality discipline. Put
gen_ai.tool.nameandgen_ai.prompt.nameon the span as attributes, but never build the span name fromtargetplus arguments. Tool names are bounded, arguments are not, and unbounded span names break every downstream aggregation.server.addressonly when a peer actually exists. Withstdiothere is no network peer, sonetwork.transport = "pipe"plus noserver.addressis correct and should not be filled with a placeholder. For HTTP transports setserver.addressandserver.portfrom the negotiated endpoint, not from client config, since redirects and proxies change the real target.Once the PR is open we can run it against our own MCP server traffic (multi-client, HTTP and stdio) and report back with span samples. Happy to be a test bed. No assignment needed from us, but flagging the maintainers here so this does not stall: @davidsp @marcelotryle @maxisbey.