> **Part of the [StudioMeyer MCP Stack](https://studiomeyer.io)** โ€” Built in Mallorca ๐ŸŒด ยท โญ if you use it # mcp-otel W3C Trace Context bridge for the **Model Context Protocol**. It propagates trace context through MCP's `_meta` field and emits OpenTelemetry spans, so a request flowing **Host โ†’ MCP server โ†’ tool โ†’ downstream HTTP** shows up as **one connected trace** in Jaeger, Tempo, Honeycomb, or Datadog. [![npm](https://img.shields.io/npm/v/mcp-otel.svg)](https://www.npmjs.com/package/mcp-otel) [![CI](https://github.com/studiomeyer-io/mcp-otel/actions/workflows/ci.yml/badge.svg)](https://github.com/studiomeyer-io/mcp-otel/actions/workflows/ci.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/studiomeyer-io/mcp-otel/badge)](https://scorecard.dev/viewer/?uri=github.com/studiomeyer-io/mcp-otel) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) --- ## Why this exists The MCP **release candidate of 2026-07-28** nailed down distributed tracing for MCP: - **SEP-414** reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/). These keys ride along in `params._meta` of every request, and MCP transports must pass them through untouched. - **SEP-2577** deprecated MCP's logging capability and pointed at **OpenTelemetry** as the observability path going forward. Spec post: So the protocol now says *where* the trace context lives and *which* telemetry system to use โ€” but there was no small library that does the actual plumbing: read the caller's context out of `_meta`, start a correctly-parented OpenTelemetry span for the tool call, and let your downstream calls hang off it. That's all `mcp-otel` is. It is deliberately thin. It does **not** configure OpenTelemetry for you, ship an exporter, or hide your tracer. You keep full control of sampling, resources, and exporters; `mcp-otel` only bridges `_meta` โ†” spans. ## Install ```bash npm install mcp-otel @opentelemetry/api # @modelcontextprotocol/sdk is an optional peer โ€” only needed if you use the # tool-handler wrapper against the SDK (the low-level helpers don't import it). ``` Peer dependencies: | Package | Range | Required? | | --- | --- | --- | | `@opentelemetry/api` | `^1.9.0` | yes | | `@modelcontextprotocol/sdk` | `>=1.10.0 <2` | optional (only for `instrumentToolHandler`'s `extra` shape) | Node **20+**. ESM and CommonJS are both shipped. ## Quickstart โ€” server side Set OpenTelemetry up once at startup (this is your normal OTel bootstrap โ€” `mcp-otel` doesn't do it for you), then wrap each tool handler. ```ts import { instrumentToolHandler } from "mcp-otel"; import { z } from "zod"; server.registerTool( "weather.lookup", { description: "Current weather", inputSchema: { city: z.string() } }, // The only change: wrap the handler. Same (args, extra) => result signature. instrumentToolHandler("weather.lookup", async ({ city }) => { // trace.getActiveSpan() here is the MCP server span. // Any span you start, or any downstream fetch that injects the active // context, nests under it automatically. return { content: [{ type: "text", text: `Weather for ${city}` }] }; }), ); ``` `instrumentToolHandler`: - reads `traceparent` / `tracestate` / `baggage` from the caller's `_meta` (it checks `extra._meta` โ€” the published SDK 1.x shape โ€” and `extra.mcpReq._meta`), - starts a `SERVER` span named `tools/call ` as a child of the caller's span, - runs your handler with that span active (via the OTel context), - ends the span with `OK` / `ERROR` status and records thrown exceptions. Attributes set on the span: `mcp.method` (`"tools/call"`), `mcp.tool.name`, `mcp.request.id`, and `mcp.session.id` when available. Pass `attributes` for GenAI conventions like `gen_ai.system`. Prefer the explicit primitive? Use `runInToolSpan` when you have the `_meta` object directly: ```ts import { runInToolSpan } from "mcp-otel"; const result = await runInToolSpan( meta, // request.params._meta { toolName: "weather.lookup", requestId }, async (span) => { span.setAttribute("weather.city", city); return doWork(city); }, ); ``` ## Quickstart โ€” client side If you write an MCP **client**, inject your active trace context into the request `_meta` so the server can continue your trace: ```ts import { injectTraceContext } from "mcp-otel"; const result = await client.callTool({ name: "weather.lookup", arguments: { city: "Palma" }, _meta: injectTraceContext({}), // writes traceparent/tracestate/baggage from the active context }); ``` `injectTraceContext(meta, context?)` mutates and returns the `_meta` object, defaulting to the current active OpenTelemetry context. Existing `_meta` keys (like `progressToken`) are preserved. ## Send it to Jaeger `mcp-otel` emits standard OpenTelemetry spans, so any exporter works. Minimal Jaeger setup: ```bash docker run -d --name jaeger \ -p 16686:16686 -p 4318:4318 \ jaegertracing/all-in-one ``` ```ts // otel-setup.ts โ€” import this once, before anything else. import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; const sdk = new NodeSDK({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "my-mcp-server" }), traceExporter: new OTLPTraceExporter({ url: "http://localhost:4318/v1/traces" }), }); sdk.start(); ``` `NodeSDK` installs the `AsyncLocalStorageContextManager` that lets `mcp-otel` propagate the active span to your downstream calls. Open and you'll see `host โ†’ tools/call โ†’ downstream` as one trace. > **Note on context propagation:** the auto-nesting of downstream spans relies on a context manager being installed โ€” exactly what `NodeSDK` / `NodeTracerProvider.register()` does. In a process with no context manager (e.g. a bare `BasicTracerProvider`), spans are still created and correctly parented to the caller, but `context.active()` inside your handler won't carry the span. This is the standard OpenTelemetry contract, not a quirk of this library. ## Runnable example A zero-infrastructure example (in-memory exporter, prints the span tree) lives in [`examples/connected-trace.mjs`](./examples/connected-trace.mjs): ```bash npm run build node examples/connected-trace.mjs ``` It prints: ``` - host.chat-turn [INTERNAL] - tools/call weather.lookup [SERVER] - GET /weather [CLIENT] All 3 spans share one traceId: yes ``` The real-SDK wiring is in [`examples/mcp-server.ts`](./examples/mcp-server.ts). ## API reference ### Instrumentation - **`instrumentToolHandler(toolName, handler, options?)`** โ†’ wrapped handler. Drop-in `(args, extra) => result` wrapper. Options: `method` (default `"tools/call"`), `tracer`, `attributes`, and `getMeta` / `getRequestId` / `getSessionId` extractor overrides. - **`runInToolSpan(meta, options, body)`** โ†’ `Promise`. Low-level primitive: runs `body(span)` inside a `SERVER` span parented to `meta`'s trace context. Options: `toolName`, `method`, `requestId`, `sessionId`, `attributes`, `tracer`. ### Propagation helpers - **`extractTraceContext(meta, base?)`** โ†’ `Context`. Builds an OTel parent context from `_meta` (`traceparent` + `tracestate` + `baggage`). Defaults `base` to `ROOT_CONTEXT` for deterministic behavior. - **`injectTraceContext(meta, context?)`** โ†’ `Meta`. Writes trace keys into `_meta` from `context` (default: active context). Mutates and returns `meta`. - **`parseTraceparent(s)`** โ†’ `SpanContext | null`. W3C-conformant parse. Version `00` is length-strict (exactly four fields); a *higher* version is parsed forward-compatibly (first four fields read, trailing fields ignored, per the spec) so it keeps working as Trace Context evolves. Returns `null` for malformed, `ff`-version, or all-zero (invalid trace-id / parent-id) values. Result is `isRemote: true`. - **`formatTraceparent(spanContext)`** โ†’ `string | null`. Serializes to a version-`00` `traceparent`; `null` for an invalid span context. - **`spanContextToContext(spanContext, base?)`** โ†’ `Context`. Wrap a span context you already hold into a context for injection. - **`traceContextFields()`** โ†’ `string[]`. The `_meta` keys this library reads/writes (useful for stripping them before forwarding `_meta`). ### Constants `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, `TRACE_META_KEYS`, and the attribute keys `ATTR_MCP_METHOD`, `ATTR_MCP_TOOL_NAME`, `ATTR_MCP_REQUEST_ID`, `ATTR_MCP_SESSION_ID`. ## Why `_meta` and not HTTP headers? You might ask: MCP can run over Streamable HTTP, so why not just read `traceparent` from the HTTP request headers like a normal web service? - **MCP is transport-agnostic.** The same server logic runs over stdio, Streamable HTTP, and others. stdio has no headers at all. Putting trace context in `_meta` means propagation works identically on every transport โ€” which is exactly why the spec reserved these keys *in `_meta`* rather than leaning on HTTP. - **One HTTP connection carries many logical requests.** With Streamable HTTP a single long-lived connection multiplexes many JSON-RPC messages. A transport-level header describes the *connection*, not each tool call. Trace context belongs to the individual request, and in MCP that request is the JSON-RPC message โ€” whose per-message metadata is `_meta`. - **The host is usually not the HTTP client.** The thing that owns the root span (the LLM host / agent) and the thing that opens the socket can be different processes. `_meta` travels with the logical request across those hops; a hop-by-hop HTTP header does not. So `_meta` is the only place that is both per-request and transport-independent โ€” which is why SEP-414 standardized on it, and why this library reads/writes there. ## What it does not do - It does not initialize OpenTelemetry, choose an exporter, or set a sampler โ€” that's your app's job, and keeping it out means no version lock-in on the SDK side. - It does not auto-instrument the transport or patch the SDK globally; you opt in per tool handler (or call the helpers yourself). - It does not invent metrics or logs โ€” spans only, per SEP-2577's OTel direction. ## Part of the StudioMeyer MCP toolkit A small family of focused, production-grade tools for building and operating MCP servers โ€” mix and match: - [mcp-armor](https://github.com/studiomeyer-io/mcp-armor) โ€” runtime defense sidecar: scans tool calls, verifies signed manifests, blocks known-bad CVEs - [mcp-gauntlet](https://github.com/studiomeyer-io/mcp-gauntlet) โ€” pre-deploy `mcp-fuzz` (schema-aware fuzzer) + `mcp-storm` (load tester) - **mcp-otel** *(this one)* โ€” W3C Trace Context โ†’ OpenTelemetry bridge - [mcp-cache-kit](https://github.com/studiomeyer-io/mcp-cache-kit) โ€” leak-safe SEP-2549 caching (`ttlMs` + `cacheScope`) - [skilldoctor](https://github.com/studiomeyer-io/skilldoctor) โ€” linter + security scanner for agent skill files ## License MIT ยฉ StudioMeyer