# SEP-2448: MCP server execution telemetry — Reference Implementation End-to-end reference implementation of **SEP-2448 `serverExecutionTelemetry`** — cross-organization distributed tracing via MCP. ## Overview This example demonstrates how an MCP server can **pass back OpenTelemetry spans** to the calling client, enabling full distributed tracing across organizational boundaries. Without this capability, the server side of an MCP tool call is a black box — you can see *that* it was called, but not *what happened inside*. The example includes three components: 1. **Server** (`server.py`) — An Arcade MCP server with Gmail tools that uses `TelemetryPassbackMiddleware` to collect and return spans. This shows how a **vendor adopts** the SEP. 2. **Agent** (`agent.py`) — A LangChain ReAct agent that requests span passback, receives server spans, and ingests them into Jaeger/Galileo. This shows how a **consumer uses** the SEP. 3. **Jaeger** (`docker-compose.yml`) — Local trace collector and UI for visualizing the stitched traces. ## Prerequisites - Python 3.11+ - [uv](https://docs.astral.sh/uv/) package manager - Docker (for Jaeger) - An [Arcade](https://www.arcade.dev) account ([quickstart](https://docs.arcade.dev/en/get-started/quickstarts/mcp-server-quickstart)) - An OpenAI API key (for the LangChain agent) ## Setup ```bash cd examples/mcp_servers/telemetry_passback # Copy env file and add your keys cp .env.example .env # Edit .env: set OPENAI_API_KEY, ARCADE_API_KEY, ARCADE_USER_ID # Install dependencies uv sync # Start Jaeger docker compose up -d ``` ## Usage The server and agent run as **separate processes**. Start the server first, then run the agent in another terminal. ### Start the Server ```bash # Terminal 1 uv run python src/telemetry_passback/server.py ``` The server listens at `http://127.0.0.1:8000/mcp` with OAuth 2.1 resource server auth via Arcade. ### Run the Agent In a separate terminal. On first run, the MCP SDK will open your browser for OAuth authorization (one-time). #### Act 1 — "The Black Box" (no passback) ```bash uv run python src/telemetry_passback/agent.py --no-passback "List my 3 most recent emails" ``` Open Jaeger at [http://localhost:16686](http://localhost:16686): you see agent LLM reasoning spans + one opaque `mcp.call_tool` CLIENT span. The tool call took ~3 seconds but there's no way to tell why. Is it the LLM? The network? Auth? The Gmail API? Everything inside the server is invisible. #### Act 2 — "The Revelation" (with passback) ```bash uv run python src/telemetry_passback/agent.py --detailed "List my 3 most recent emails" ``` Same call, but now the span tree reveals the server's internal structure: ``` mcp-gmail-agent ├── LangChain agent reasoning ├── ChatOpenAI (LLM decides to call tool) ├── mcp.call_tool list_emails (CLIENT) │ └── tools/call list_emails (SERVER) ← FROM SPAN PASSBACK │ ├── auth.validate (50ms) │ ├── gmail.list_messages (400ms) │ │ └── GET messages (HTTP) │ ├── gmail.fetch_details (1.6s) ← bottleneck! │ │ ├── GET messages/abc (HTTP, 520ms) │ │ ├── GET messages/def (HTTP, 510ms) │ │ └── GET messages/ghi (HTTP, 530ms) │ └── format_response (5ms) └── ChatOpenAI (LLM — final answer) ``` Now the consumer can see exactly what's happening: auth is fast, listing is fine, but **detail fetching is sequential** — three HTTP calls in a waterfall. Armed with this information, the consumer can: - **File an informed bug report** to the server vendor: "your `list_emails` has an N+1 in detail fetching — each email triggers a sequential HTTP call" - **Adjust their usage**: request fewer emails, use a query filter to reduce N - **Make an informed vendor choice**: compare span trees across MCP server providers This is the core value of the SEP — **the consumer doesn't need access to the server's code or deployment to understand its performance characteristics**. ### Granularity Control The `--detailed` flag demonstrates the SEP's span filtering. Without it, the server returns only top-level phase spans (auth, list, fetch, format). With `--detailed`, the full tree including HTTP child spans is returned. This lets the server vendor control how much internal detail is exposed. ```bash # Top-level phases only (default) uv run python src/telemetry_passback/agent.py "List my 3 most recent emails" # Full span tree including HTTP child spans uv run python src/telemetry_passback/agent.py --detailed "List my 3 most recent emails" ``` ### CLI Options | Flag | Default | Description | |------|---------|-------------| | `query` | `"List my 5 most recent emails"` | The question to ask the agent | | `--detailed` | `false` | Request full span tree | | `--no-passback` | `false` | Disable span passback (Act 1 — server is a black box) | | `--server-url` | `http://127.0.0.1:8000/mcp` | MCP server URL | ## Expected Results in Jaeger Open [http://localhost:16686](http://localhost:16686) and search for service **`mcp-gmail-agent`**. | Mode | What you see | |------|-------------| | `--no-passback` | Only agent-side spans: LLM calls + opaque `mcp.call_tool`. Server is a black box. | | Default | Server phase spans stitched into the same trace: `auth.validate`, `gmail.list_messages`, `gmail.fetch_details`, `format_response`. | | `--detailed` | Full span tree: phase spans plus HTTP child spans under each phase, revealing the sequential N+1 pattern in `gmail.fetch_details`. | ## Architecture ``` ┌─────────────────────────┐ HTTP (streamable) ┌──────────────────────────┐ │ agent.py │ ───────────────────────>│ server.py │ │ (LangChain ReAct) │ :8000/mcp │ (Arcade MCP Server) │ │ │ │ │ │ OAuth 2.1 via MCP SDK │ traceparent in _meta │ OAuth 2.1 (Arcade) │ │ OTel → Jaeger/Galileo │ ───────────────────────>│ OTel (internal only) │ │ │ spans back in _meta │ TelemetryPassback MW │ │ │ <───────────────────────│ │ └─────────────────────────┘ └──────────────────────────┘ │ │ └──────────── Stitched trace in Jaeger ───────────────┘ ``` ### How It Works **Server side** (`server.py`): 1. Validates Bearer tokens via `ArcadeResourceServerAuth` (OAuth 2.1, RFC 9728 discovery) 2. `TelemetryPassbackMiddleware` intercepts `tools/call` requests 3. Reads `_meta.traceparent` and `_meta.otel.traces.{request, detailed}` 4. Creates a SERVER span under the client's trace (via traceparent propagation) 5. Tool function creates logical-phase spans with `gen_ai.*` semantic conventions 6. httpx auto-instrumentation creates HTTP child spans for Gmail API calls 7. Middleware serializes to OTLP JSON and attaches to `response._meta.otel.traces` **Client side** (`agent.py`): 1. MCP SDK handles OAuth 2.1 automatically (discovers auth server on 401, PKCE flow, token caching) 2. Connects to the server via streamable HTTP, detects `serverExecutionTelemetry` capability 3. For each tool call, creates a CLIENT span and injects `traceparent` in `_meta` 4. Sends `_meta.otel.traces.request: true` to opt into span passback 5. Receives server spans in response `_meta.otel.traces.resourceSpans` 6. POSTs OTLP JSON to Jaeger for trace stitching 7. Optionally exports to Galileo (protobuf) if `GALILEO_API_KEY` is set ## Configuration Copy `.env.example` to `.env`: | Variable | Default | Description | |----------|---------|-------------| | `OPENAI_API_KEY` | (required) | OpenAI API key for the LangChain agent | | `ARCADE_API_KEY` | (required) | Arcade API key | | `ARCADE_USER_ID` | (required) | Your Arcade account email | | `ARCADE_API_URL` | `https://api.arcade.dev` | Arcade API endpoint | | `GALILEO_API_KEY` | (optional) | Enables export to Galileo alongside Jaeger | | `GALILEO_PROJECT` | (optional) | Galileo project name | | `GALILEO_LOG_STREAM` | `default` | Galileo log stream | | `GALILEO_OTLP_ENDPOINT` | `https://app.galileo.ai/api/galileo/otel/traces` | Galileo OTLP endpoint |