Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d977376
fix: align MCP span naming and metric buckets with OTel MCP semantic …
adityamehra Apr 13, 2026
8554e62
feat: add MCPOperation type, resource/prompt instrumentation, and tra…
adityamehra Apr 14, 2026
83db0e3
refactor: address review - DRY client wrappers, shared transport dete…
adityamehra Apr 14, 2026
c09b455
fix: address review findings for MCP instrumentation
adityamehra Apr 15, 2026
acd811f
docs: document transport bridge rationale and mcp v2.x migration plan
adityamehra Apr 15, 2026
49106a3
fix: add MCPOperation to MetricsEmitter.handles() for composite dispatch
adityamehra Apr 15, 2026
7cf7c42
merge: resolve conflicts with main
adityamehra Apr 16, 2026
61abbc5
merge: integrate latest main into fix/mcp-gaps-2-3-4
adityamehra Apr 17, 2026
b62c4ff
Fix lint (HILT)
adityamehra Apr 17, 2026
ad02046
fix(langchain): pin langgraph <= 1.1.6 to avoid breaking GraphCallbac…
adityamehra Apr 17, 2026
03b7dc8
fix(fastmcp): align context propagation with MCP semconv and add weat…
adityamehra Apr 18, 2026
24e11ca
Use duraiton calc from handler
adityamehra Apr 20, 2026
5a16062
revert unintended commits
adityamehra Apr 20, 2026
ee5c8b3
docs(fastmcp): add compatibility matrix and update instrumented API s…
adityamehra Apr 21, 2026
e9029c5
feat(fastmcp): target FastMCP 3.x, bump to 0.2.0 (breaking)
adityamehra Apr 21, 2026
5d92973
fix(fastmcp): activate span context for cross-process trace propagation
adityamehra Apr 21, 2026
bd97bcb
fix(metrics): use gen_ai.tool.name instead of gen_ai.request.model fo…
adityamehra Apr 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Server session lifecycle tracking** — `server_instrumentor` now wraps `mcp.server.lowlevel.Server.run` with an `AgentInvocation(agent_type="mcp_server")` to track server session duration, enabling `mcp.server.session.duration` metric emission via `MetricsEmitter`.

### Added
- **`resources/read` and `prompts/get` instrumentation** — Server and client-side hooks for `FastMCP.read_resource` / `Client.read_resource` and `FastMCP.get_prompt` / `Client.get_prompt`. Produces `MCPOperation` spans with `{mcp.method.name} {target}` naming.
- **Transport context bridge** — `MCPRequestContext` ContextVar populated by the transport instrumentor on the server side, allowing the server instrumentor to read `jsonrpc.request.id`, `network.transport`, etc.
- **Transport detection** — Client automatically detects `pipe` vs `tcp` transport from `Client.transport` type.
- **Baggage propagation** — Transport instrumentor now extracts W3C `baggage` header alongside `traceparent`/`tracestate`.

### Changed
- **`list_tools` uses `MCPOperation` instead of `Step`** — Client `list_tools` now produces a `tools/list` span via `MCPOperation` with proper MCP semconv naming and `SpanKind.CLIENT`, instead of the previous `Step` type.
- **Server hooks on `fastmcp.server.server.FastMCP`** — Tool call hook now targets `FastMCP.call_tool` directly (in addition to the legacy `ToolManager.call_tool` path) for compatibility with FastMCP 3.x.
- **Renamed `mcp_server_name` → `sdot_mcp_server_name`** — **Breaking**: callers using `mcp_server_name=` on `MCPToolCall` must update to `sdot_mcp_server_name=`.

### Fixed
- **MCP session attributes for duration metrics** — `client_instrumentor` now sets `network.transport` and `error.type` on the `AgentInvocation` attributes dict so that `MetricsEmitter` can record `mcp.client.session.duration` with proper semconv attributes.
- **MCP span naming aligned with OTel MCP semantic conventions** — Tool call spans now use `tools/call {tool_name}` format with `SpanKind.CLIENT` (client-side) or `SpanKind.SERVER` (server-side), matching the [OTel MCP semconv spec](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/). Previously used `execute_tool {tool_name}` with `SpanKind.INTERNAL`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ OpenTelemetry FastMCP Instrumentation
This library provides automatic instrumentation for `FastMCP <https://github.com/jlowin/fastmcp>`_,
a Python library for building Model Context Protocol (MCP) servers.

Compatibility Matrix
--------------------

.. list-table::
:header-rows: 1
:widths: 20 20 20 20

* - Instrumentation
- fastmcp
- util-genai
- Notes
* - 0.1.1
- 2.x (jlowin/fastmcp)
- <= 0.1.9
- PR #147. Wraps ``ToolManager.call_tool``.
* - 0.2.0
- >= 3.0.0, < 4
- >= 0.1.12
- Wraps ``FastMCP.call_tool``, ``read_resource``, ``render_prompt``. Breaking change from 0.1.x.

Installation
------------

Expand Down Expand Up @@ -58,8 +78,16 @@ The following environment variables control the instrumentation behavior:
What is Instrumented
--------------------

Server-side:
~~~~~~~~~~~~
Server-side (v0.2.0 — FastMCP 3.x):
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

- FastMCP server initialization
- Tool execution via ``FastMCP.call_tool``
- Resource reads via ``FastMCP.read_resource``
- Prompt rendering via ``FastMCP.render_prompt``

Server-side (v0.1.x — FastMCP 2.x):
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

- FastMCP server initialization
- Tool execution via ``ToolManager.call_tool``
Expand All @@ -80,13 +108,70 @@ Trace Context Propagation
-------------------------

The instrumentation automatically propagates W3C TraceContext (traceparent, tracestate)
between MCP client and server processes. This enables distributed tracing across
process boundaries:
and baggage between MCP client and server processes. This enables distributed tracing
across process boundaries:

- Client spans and server spans share the same ``trace_id``
- Server tool execution spans are children of client tool call spans
- No code changes required in your MCP server or client

Transport bridge (``transport_instrumentor.py``)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The MCP Python SDK v1.x (current stable, up to 1.27.0) does not natively
propagate OpenTelemetry context. This instrumentation includes a
**transport-layer bridge** (``transport_instrumentor.py``) that:

- **Client side**: wraps ``BaseSession.send_request`` to inject ``traceparent``,
``tracestate``, and ``baggage`` into ``params.meta`` (serialized as ``_meta``
on the wire).
- **Server side**: wraps ``Server._handle_request`` to extract trace context
from ``request_meta`` and populate an ``MCPRequestContext`` (via
``ContextVar``) for the server instrumentor to read transport-level attributes
like ``jsonrpc.request.id`` and ``network.transport``.

Upstream native support (mcp v2.x)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Native OTel support has been merged to the upstream SDK's ``main`` branch,
targeting **v2.x** (not yet released as of Apr 2026):

- `#2298 <https://github.com/modelcontextprotocol/python-sdk/pull/2298>`_
(merged Mar 31) — propagate ``contextvars.Context`` through anyio streams.
Supersedes `#1996 <https://github.com/modelcontextprotocol/python-sdk/pull/1996>`_
(closed).
- `#2381 <https://github.com/modelcontextprotocol/python-sdk/pull/2381>`_
(merged Mar 31) — native CLIENT + SERVER spans, W3C trace-context
inject/extract via ``params.meta``, and ``opentelemetry-api`` as a mandatory
dependency.

Related open/draft PRs that may further extend the native support:

- `#2093 <https://github.com/modelcontextprotocol/python-sdk/pull/2093>`_
— enhanced inject logic (open).
- `#2133 <https://github.com/modelcontextprotocol/python-sdk/pull/2133>`_
— enhanced extract logic (draft, depends on #2298).
- `#2132 <https://github.com/modelcontextprotocol/python-sdk/pull/2132>`_
— richer CLIENT span attributes (draft, depends on #2298).

Migration plan
^^^^^^^^^^^^^^

Once ``mcp >= 2.x`` is released and the minimum supported version is raised:

- ``_send_request_wrapper`` (client-side inject) can be **removed**.
- The trace-context extract/attach portion of ``_server_handle_request_wrapper``
can be **removed**. The ``MCPRequestContext`` population
(``jsonrpc.request.id``, ``network.transport``) should remain because the
v2.x native spans (per #2381) only surface ``mcp.method.name`` and
``jsonrpc.request.id``; ``network.transport`` is not included.
Re-evaluate as the upstream spans mature.
- ``_extract_carrier_from_meta`` can be **removed**.

A feature-detection guard (similar to ``_has_native_telemetry`` in the server
instrumentor) should be added so the wrappers gracefully become no-ops when
running against ``mcp >= 2.x``, allowing a wider version range.

Telemetry
---------

Expand Down Expand Up @@ -114,6 +199,8 @@ When content capture is enabled:
References
----------

- `FastMCP <https://github.com/jlowin/fastmcp>`_
- `FastMCP 3.x <https://github.com/gofastmcp/fastmcp>`_ (>= 3.0.0)
- `FastMCP 2.x <https://github.com/jlowin/fastmcp>`_ (<= 2.14.7)
- `Model Context Protocol <https://modelcontextprotocol.io/>`_
- `OpenTelemetry GenAI MCP Semantic Conventions <https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/>`_
- `OpenTelemetry Project <https://opentelemetry.io/>`_
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ async def run_calculator_demo(server_url: str | None = None):

Args:
server_url: Optional URL of external MCP server (e.g., http://localhost:8000/sse).
If not provided, spawns server.py as a subprocess.
If not provided, spawns server_instrumented.py as a subprocess.
"""
from fastmcp import Client
from fastmcp.client.transports.stdio import PythonStdioTransport

print("\n" + "=" * 60)
print("MCP Calculator Client - OpenTelemetry Instrumentation Demo")
Expand All @@ -126,12 +127,21 @@ async def run_calculator_demo(server_url: str | None = None):
print(f"\n🌐 Connecting to external server: {server_url}")
server_target = server_url
else:
# Spawn server as subprocess
server_script = Path(__file__).parent / "server.py"
# Spawn instrumented server as subprocess.
# MCP SDK's default env only inherits a small allowlist (HOME, PATH,
# etc.), so OTEL_* vars must be passed explicitly.
server_script = Path(__file__).parent / "server_instrumented.py"
if not server_script.exists():
raise FileNotFoundError(f"Server script not found: {server_script}")
print(f"\n📡 Spawning server subprocess: {server_script.name}")
server_target = server_script
server_env = {
k: v
for k, v in os.environ.items()
if k.startswith(("OTEL_", "VIRTUAL_ENV", "FASTMCP_"))
or k in ("HOME", "PATH", "SHELL", "TERM", "USER", "LOGNAME")
}
server_env["OTEL_SERVICE_NAME"] = "mcp-calculator-server"
server_target = PythonStdioTransport(script_path=server_script, env=server_env)

# Connect to the server using FastMCP Client
async with Client(server_target) as client:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""MCP Weather Prompt Client.

Connects to the weather prompt server and demonstrates prompt operations
with OpenTelemetry instrumentation capturing traces and metrics.

Expected spans:
- "prompts/list" (SpanKind.CLIENT)
- "prompts/get weather_forecast" (SpanKind.CLIENT)
- "prompts/get travel_packing_advice" (SpanKind.CLIENT)

Usage:
# Spawn server as subprocess (single terminal)
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" \\
OTEL_SERVICE_NAME="mcp-prompt-demo" \\
OTEL_INSTRUMENTATION_GENAI_EMITTERS="span_metric" \\
python client.py --wait 10

# Connect to external SSE server
python client.py --server-url http://localhost:8001/sse --wait 10
"""

import argparse
import asyncio
import os
import sys
from pathlib import Path


def setup_telemetry():
"""Set up OpenTelemetry with OTLP and/or console exporters."""
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.resources import Resource

resource = Resource.create(
{"service.name": os.environ.get("OTEL_SERVICE_NAME", "mcp-prompt-demo")}
)

trace_provider = TracerProvider(resource=resource)
metric_readers = []

otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if otlp_endpoint:
try:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader

trace_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
metric_readers.append(PeriodicExportingMetricReader(OTLPMetricExporter()))
print(f" OTLP exporter -> {otlp_endpoint}", file=sys.stderr)
except ImportError:
print(" OTLP not available", file=sys.stderr)

from opentelemetry.sdk.trace.export import (
ConsoleSpanExporter,
SimpleSpanProcessor,
)

trace_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
print(" Console exporter enabled", file=sys.stderr)

trace.set_tracer_provider(trace_provider)
if metric_readers:
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=metric_readers)
)

from opentelemetry.instrumentation.fastmcp import FastMCPInstrumentor

FastMCPInstrumentor().instrument()
print(" FastMCP instrumentation applied", file=sys.stderr)


async def run_prompt_demo(server_url: str | None = None):
"""Connect to the weather prompt server and exercise prompt operations."""
from fastmcp import Client

print("\n" + "=" * 60)
print(" MCP Prompt Demo - OpenTelemetry Instrumentation")
print("=" * 60)

if server_url:
target = server_url
print(f"\n Connecting to: {server_url}")
else:
target = Path(__file__).parent / "server.py"
print(f"\n Spawning server: {target.name}")

async with Client(target) as client:
print(" Connected!\n")

# 1. List prompts
print(" Step 1: List prompts")
print(" " + "-" * 40)
prompts = await client.list_prompts()
for p in prompts:
args = ", ".join(a.name for a in (p.arguments or []))
print(f" {p.name}({args})")
print()

# 2. Get weather_forecast prompt
print(" Step 2: Get weather_forecast for London")
print(" " + "-" * 40)
result = await client.get_prompt(
"weather_forecast", arguments={"city": "London"}
)
for msg in result.messages:
role = msg.role
text = (
msg.content.text if hasattr(msg.content, "text") else str(msg.content)
)
print(f" [{role}] {text[:120]}")
print()

# 3. Get travel_packing_advice prompt
print(" Step 3: Get travel_packing_advice for Tokyo, 5 days")
print(" " + "-" * 40)
result = await client.get_prompt(
"travel_packing_advice",
arguments={"destination": "Tokyo", "days": "5"},
)
for msg in result.messages:
role = msg.role
text = (
msg.content.text if hasattr(msg.content, "text") else str(msg.content)
)
print(f" [{role}] {text[:120]}")
print()

print("=" * 60)
print(" Prompt demo completed!")
print("=" * 60)


async def main(wait_seconds: int = 0, server_url: str | None = None):
setup_telemetry()
print()

try:
await run_prompt_demo(server_url=server_url)
except Exception as e:
print(f"\n Demo failed: {e}")
import traceback

traceback.print_exc()

if wait_seconds > 0:
print(f"\n Waiting {wait_seconds}s for telemetry flush...")
await asyncio.sleep(wait_seconds)
print(" Done")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="MCP Prompt Client")
parser.add_argument("--server-url", type=str, default=None)
parser.add_argument("--wait", type=int, default=0)
args = parser.parse_args()

asyncio.run(main(wait_seconds=args.wait, server_url=args.server_url))
Loading
Loading