Skip to content
Open
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
22 changes: 18 additions & 4 deletions docs/run/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ call `MCPServer(...)`.
```

That is a complete, traced server. Call `search_books` and a span is created for it. The same is
true for the low-level `Server`: the tracing lives on both.
true when a client reads `catalog://featured` or renders `reading_prompt`. The low-level `Server`
is traced too.

## What you get

Every inbound message becomes a `SERVER` span named after the method and its target. So a
`tools/call` for `search_books` is the span `tools/call search_books`, and a bare `tools/list`
Every inbound message becomes a `SERVER` span named after the method, plus a target for named
operations. So a `tools/call` for `search_books` is the span `tools/call search_books`,
a `prompts/get` for `reading_prompt` is `prompts/get reading_prompt`, and a bare `tools/list`
is just `tools/list`.

Each span carries a few attributes:
Expand All @@ -31,13 +33,25 @@ OpenTelemetry's [GenAI semantic conventions](https://opentelemetry.io/docs/specs
* `gen_ai.operation.name`, set to `"execute_tool"`.
* `gen_ai.tool.name`, set to the tool being called.

A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. The list methods carry no
A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. `resources/read` spans are
created by the same middleware and carry the common `mcp.*` attributes; the SDK does not add the
resource URI to the span name or to a `gen_ai.*` attribute today. The list methods carry no
`gen_ai.*` keys, because there is nothing to name.

!!! tip
Those GenAI attributes are the reason a tracing UI groups your tool calls the way it groups
any other agent's. You get that grouping for free, with no extra code.

## Adding your own detail

The SDK span wraps the request handler. That means a tool, resource, or prompt function runs with
the SDK-created span already current.

If you need more detail than the default attributes provide, create child spans inside your handler
or in your own middleware. For example, a resource handler can add a child span for a database read,
and a prompt handler can add one for template assembly. Those spans nest under `resources/read` or
`prompts/get`, so a tracing backend still shows one connected request.

## It costs nothing until you want it

Here is the part that makes "on by default" a comfortable default.
Expand Down
12 changes: 12 additions & 0 deletions docs_src/opentelemetry/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,15 @@
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."


@mcp.resource("catalog://featured")
def featured_books() -> str:
"""The featured books shelf."""
return "Dune\nThe Left Hand of Darkness\nA Wizard of Earthsea"


@mcp.prompt()
def reading_prompt(topic: str) -> str:
"""Create a reading recommendation prompt."""
return f"Recommend one book about {topic}."
20 changes: 20 additions & 0 deletions tests/docs_src/test_opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ async def test_a_plain_server_is_traced_with_no_extra_code(capfire: CaptureLogfi
assert attributes["gen_ai.tool.name"] == "search_books"


async def test_resources_and_prompts_are_traced_at_the_request_level(capfire: CaptureLogfire) -> None:
"""tutorial001: resource reads and prompt renders use request-level SERVER spans."""
async with Client(tutorial001.mcp) as client:
await client.read_resource("catalog://featured")
await client.get_prompt("reading_prompt", {"topic": "Dune"})

spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()}

resource_attributes = spans["resources/read"]["attributes"]
assert resource_attributes["mcp.method.name"] == "resources/read"
assert "gen_ai.operation.name" not in resource_attributes
assert "gen_ai.prompt.name" not in resource_attributes
assert "gen_ai.tool.name" not in resource_attributes

prompt_attributes = spans["prompts/get reading_prompt"]["attributes"]
assert prompt_attributes["mcp.method.name"] == "prompts/get"
assert prompt_attributes["gen_ai.prompt.name"] == "reading_prompt"
assert "gen_ai.operation.name" not in prompt_attributes


async def test_client_and_server_share_one_trace(capfire: CaptureLogfire) -> None:
"""When both sides run the SDK, the client and server spans land in one trace (SEP-414)."""
async with Client(tutorial001.mcp, mode="legacy") as client:
Expand Down
Loading