Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared._otel import build_span_attributes, extract_trace_context, otel_span
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
from mcp.shared.exceptions import MCPError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
Expand Down Expand Up @@ -91,10 +91,10 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.

Mirrors the span shape of the existing `Server._handle_request`: span name
`"MCP handle <method> [<target>]"`, `mcp.method.name` attribute, W3C
trace context extracted from `params._meta` (SEP-414), and an ERROR
status if the handler raises.
Span name: `"MCP handle <method> [<target>]"`. Attributes follow the GenAI
semconv for MCP: `gen_ai.operation.name`, `gen_ai.tool.name`, `rpc.system`,
`mcp.method.name`, `jsonrpc.request.id`. W3C trace context is extracted from
`params._meta` (SEP-414) to parent the span under the client span.
"""

async def wrapped(
Expand All @@ -114,7 +114,7 @@ async def wrapped(
parent = None
span_name = f"MCP handle {method}{f' {target}' if target else ''}"
# `otel_middleware` wraps `on_request` only, so `request_id` is always set.
attributes = {"mcp.method.name": method, "jsonrpc.request.id": str(dctx.request_id)}
attributes = build_span_attributes(method, dctx.request_id, params=params)
with otel_span(
span_name,
kind=SpanKind.SERVER,
Expand Down
51 changes: 49 additions & 2 deletions src/mcp/shared/_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from __future__ import annotations

from collections.abc import Iterator
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from typing import Any
from typing import Any, cast

from opentelemetry.context import Context
from opentelemetry.propagate import extract, inject
Expand All @@ -13,6 +13,53 @@
_tracer = get_tracer("mcp-python-sdk")


def build_span_attributes(
method: str,
request_id: Any,
*,
params: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build OTel span attributes for an MCP request.

Produces the base set of semantic convention attributes shared by both
client (`SpanKind.CLIENT`) and server (`SpanKind.SERVER`) spans.

Per the GenAI MCP semconv spec, `gen_ai.operation.name` SHOULD be set to
`execute_tool` for `tools/call` and SHOULD NOT be set for other methods.
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md
"""
attrs: dict[str, Any] = {
"rpc.system": "mcp",
"mcp.method.name": method,
"jsonrpc.request.id": str(request_id),
}

if params is not None:
if method == "tools/call":
# gen_ai.operation.name SHOULD be set to execute_tool for tools/call only.
attrs["gen_ai.operation.name"] = "execute_tool"
name = params.get("name")
if isinstance(name, str):
attrs["gen_ai.tool.name"] = name

elif method == "prompts/get":
name = params.get("name")
if isinstance(name, str):
attrs["gen_ai.prompt.name"] = name

# mcp.resource.uri — resources/read, resources/subscribe, resources/unsubscribe,
# notifications/resources/updated, and completion/complete via ref.uri
uri: Any = params.get("uri")
if uri is None:
ref = params.get("ref")
if isinstance(ref, dict):
uri = cast(dict[str, Any], ref).get("uri")
if uri is not None:
attrs["mcp.resource.uri"] = str(uri)

return attrs


@contextmanager
def otel_span(
name: str,
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/shared/jsonrpc_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from opentelemetry.trace import SpanKind
from pydantic import ValidationError

from mcp.shared._otel import inject_trace_context, otel_span
from mcp.shared._otel import build_span_attributes, inject_trace_context, otel_span
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.dispatcher import CallOptions, Dispatcher, OnNotify, OnRequest, ProgressFnT
from mcp.shared.exceptions import MCPError, NoBackChannelError
Expand Down Expand Up @@ -334,7 +334,7 @@ async def send_raw_request(
with otel_span(
span_name,
kind=SpanKind.CLIENT,
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
attributes=build_span_attributes(method, request_id, params=out_params),
):
# Inject W3C trace context into _meta (SEP-414). With a no-op
# tracer this writes nothing, but `_meta` itself is still
Expand Down
8 changes: 6 additions & 2 deletions src/mcp/shared/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from typing_extensions import Self

from mcp.shared._compat import resync_tracer
from mcp.shared._otel import inject_trace_context, otel_span
from mcp.shared._otel import build_span_attributes, 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
Expand Down Expand Up @@ -219,7 +219,11 @@ async def send_request(
with otel_span(
span_name,
kind=SpanKind.CLIENT,
attributes={"mcp.method.name": request.method, "jsonrpc.request.id": str(request_id)},
attributes=build_span_attributes(
request.method,
request_id,
params=request_data.get("params"),
),
):
# Inject W3C trace context into _meta (SEP-414).
meta: dict[str, Any] = request_data.setdefault("params", {}).setdefault("_meta", {})
Expand Down
108 changes: 108 additions & 0 deletions tests/shared/test_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,36 @@
from mcp import types
from mcp.client.client import Client
from mcp.server.mcpserver import MCPServer
from mcp.shared._otel import build_span_attributes

pytestmark = pytest.mark.anyio


def test_build_span_attributes_ref_uri() -> None:
"""build_span_attributes extracts mcp.resource.uri from nested ref.uri."""
attrs = build_span_attributes(
"completion/complete",
"1",
params={"ref": {"uri": "test://doc"}},
)
assert attrs["mcp.resource.uri"] == "test://doc"
assert "gen_ai.operation.name" not in attrs


def test_build_span_attributes_tools_call_no_name() -> None:
"""tools/call without a name param omits gen_ai.tool.name."""
attrs = build_span_attributes("tools/call", "1", params={})
assert attrs["gen_ai.operation.name"] == "execute_tool"
assert "gen_ai.tool.name" not in attrs


def test_build_span_attributes_prompts_get_no_name() -> None:
"""prompts/get without a name param omits gen_ai.prompt.name."""
attrs = build_span_attributes("prompts/get", "1", params={})
assert "gen_ai.prompt.name" not in attrs
assert "gen_ai.operation.name" not in attrs


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")
Expand All @@ -34,8 +60,90 @@ def greet(name: str) -> str:
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")

# Base RPC + MCP attributes
assert client_span["attributes"]["rpc.system"] == "mcp"
assert client_span["attributes"]["mcp.method.name"] == "tools/call"
assert client_span["attributes"]["jsonrpc.request.id"] is not None
assert server_span["attributes"]["rpc.system"] == "mcp"
assert server_span["attributes"]["mcp.method.name"] == "tools/call"

# GenAI semconv attributes — execute_tool only on tools/call
assert client_span["attributes"]["gen_ai.operation.name"] == "execute_tool"
assert client_span["attributes"]["gen_ai.tool.name"] == "greet"
assert server_span["attributes"]["gen_ai.operation.name"] == "execute_tool"
assert server_span["attributes"]["gen_ai.tool.name"] == "greet"

# 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"]


async def test_list_tools_spans(capfire: CaptureLogfire):
"""Verify that listing tools produces spans without gen_ai.operation.name."""
server = MCPServer("test")

async with Client(server) as client:
await client.list_tools()

spans = capfire.exporter.exported_spans_as_dict()

client_span = next(s for s in spans if s["name"] == "MCP send tools/list")
server_span = next(s for s in spans if s["name"] == "MCP handle tools/list")

# gen_ai.operation.name SHOULD NOT be set for non-tool-call methods per spec
assert "gen_ai.operation.name" not in client_span["attributes"]
assert "gen_ai.operation.name" not in server_span["attributes"]
assert "gen_ai.tool.name" not in client_span["attributes"]
assert "gen_ai.tool.name" not in server_span["attributes"]

assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]


async def test_resource_read_spans(capfire: CaptureLogfire):
"""Verify that reading a resource produces spans with resource URI."""
server = MCPServer("test")

@server.resource("test://greeting")
def greeting() -> str:
return "hello"

async with Client(server) as client:
await client.read_resource("test://greeting")

spans = capfire.exporter.exported_spans_as_dict()

client_span = next(s for s in spans if s["name"] == "MCP send resources/read")
server_span = next(s for s in spans if s["name"] == "MCP handle resources/read")

assert client_span["attributes"]["mcp.resource.uri"] == "test://greeting"
assert server_span["attributes"]["mcp.resource.uri"] == "test://greeting"
# gen_ai.operation.name SHOULD NOT be set for resources/read per spec
assert "gen_ai.operation.name" not in client_span["attributes"]
assert "gen_ai.operation.name" not in server_span["attributes"]

assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]


async def test_prompt_get_spans(capfire: CaptureLogfire):
"""Verify that getting a prompt produces spans with gen_ai.prompt.name."""
server = MCPServer("test")

@server.prompt()
def summarize() -> str:
"""Summarize text."""
return "Summarize the following: "

async with Client(server) as client:
await client.get_prompt("summarize", {})

spans = capfire.exporter.exported_spans_as_dict()

client_span = next(s for s in spans if s["name"] == "MCP send prompts/get summarize")
server_span = next(s for s in spans if s["name"] == "MCP handle prompts/get summarize")

assert client_span["attributes"]["gen_ai.prompt.name"] == "summarize"
assert server_span["attributes"]["gen_ai.prompt.name"] == "summarize"
# gen_ai.operation.name SHOULD NOT be set for prompts/get per spec
assert "gen_ai.operation.name" not in client_span["attributes"]
assert "gen_ai.operation.name" not in server_span["attributes"]

assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
Loading