> **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.
[](https://www.npmjs.com/package/mcp-otel)
[](https://github.com/studiomeyer-io/mcp-otel/actions/workflows/ci.yml)
[](https://scorecard.dev/viewer/?uri=github.com/studiomeyer-io/mcp-otel)
[](./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