Skip to content

OTel client spans: add server.address (and other semconv client attributes) so tool calls can be attributed to a server #3836

Open

Description

@mihai-dinculescu

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; tcp for HTTP, pipe for stdio), network.protocol.name / network.protocol.version (Recommended where applicable)
  • mcp.session.id, mcp.protocol.version (Recommended)
  • gen_ai.tool.name (Conditionally Required when the operation relates to a specific tool, i.e. every tools/call) and gen_ai.operation.name (Recommended). Both were added to the server-side OpenTelemetryMiddleware in 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:

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

Activity

  1. added
    enhancementRequest for a new feature that's not currently supported
    on Nov 8, 2026
  2. Pranjulchaurasiya commented on Nov 10, 2026

    @Pranjulchaurasiya

    Hi maintainers,

    I took a look at src/mcp/shared/jsonrpc_dispatcher.py (lines 387–391) and src/mcp/client/session.py. Currently, send_raw_request creates the SpanKind.CLIENT span with only:

    attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)}

    Because JSONRPCDispatcher is 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:

    1. Tool Semantic Conventions (in-place):
      When method == "tools/call" and isinstance(target, str) is present, add "gen_ai.tool.name": target directly to the span attributes (matching the server-side convention in OpenTelemetryMiddleware added in Add GenAI semantic-convention attributes to OpenTelemetryMiddleware #2970).

    2. Server & Transport Attribution (server.address, server.port, network.transport):
      Add an optional otel_attributes: Mapping[str, Any] | None = None parameter to JSONRPCDispatcher.__init__ (copied to an immutable dict), which merges into the base attributes of every outbound client span. Expose this through ClientSession(..., otel_attributes=...).

      • This allows client applications (and transport wrappers with explicit endpoint configurations) to supply server.address, server.port, and connection metadata today.
      • It remains fully forward-compatible with the future outbound middleware seam noted in the TODO(maxisbey) at line 384.
    3. Testing:
      Add unit tests in tests/shared/test_jsonrpc_dispatcher.py verifying that:

      • Span attributes include gen_ai.tool.name on tools/call.
      • Custom otel_attributes passed 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)

  3. media-ctrl commented on Nov 10, 2026

    @media-ctrl

    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.

  4. Pranjulchaurasiya commented on Nov 11, 2026

    @Pranjulchaurasiya

    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:

    1. Immediate tool & operation attributes in send_raw_request:
      Since target is already parsed at line 382, setting:

      • gen_ai.operation.name = "execute_tool" and gen_ai.tool.name = target (for tools/call)
      • gen_ai.prompt.name = target (for prompts/get)
        gives immediate visibility into the tool and prompt names with zero transport or context churn.
    2. Session & protocol alignment (mcp.protocol.version and transport identity):
      Good catch on mcp.protocol.version. Because ClientSession knows the negotiated protocol version after initialize(), 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)

  5. media-ctrl commented on Nov 11, 2026

    @media-ctrl

    Sounds right to me. Two things worth pinning down in that PR, both learned from running a production MCP server:

    1. Cardinality discipline. Put gen_ai.tool.name and gen_ai.prompt.name on the span as attributes, but never build the span name from target plus arguments. Tool names are bounded, arguments are not, and unbounded span names break every downstream aggregation.

    2. server.address only when a peer actually exists. With stdio there is no network peer, so network.transport = "pipe" plus no server.address is correct and should not be filled with a placeholder. For HTTP transports set server.address and server.port from 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequest for a new feature that's not currently supported

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions