mcp-otel
v0.1.1
Published
W3C Trace Context bridge for the Model Context Protocol — propagate traces through MCP _meta and emit OpenTelemetry spans so Host -> MCP server -> tool -> downstream shows up as one trace.
Maintainers
Readme
Part of the StudioMeyer MCP Stack — 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.
Why this exists
The MCP release candidate of 2026-07-28 nailed down distributed tracing for MCP:
- SEP-414 reserves the unprefixed
_metakeystraceparent,tracestate, andbaggagefor W3C Trace Context and W3C Baggage. These keys ride along inparams._metaof 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: https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
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
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.
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/baggagefrom the caller's_meta(it checksextra._meta— the published SDK 1.x shape — andextra.mcpReq._meta), - starts a
SERVERspan namedtools/call <toolName>as a child of the caller's span, - runs your handler with that span active (via the OTel context),
- ends the span with
OK/ERRORstatus 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:
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:
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:
docker run -d --name jaeger \
-p 16686:16686 -p 4318:4318 \
jaegertracing/all-in-one// 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 http://localhost:16686 and you'll see host → tools/call <name> → 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 bareBasicTracerProvider), spans are still created and correctly parented to the caller, butcontext.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:
npm run build
node examples/connected-trace.mjsIt prints:
- host.chat-turn [INTERNAL]
- tools/call weather.lookup [SERVER]
- GET /weather [CLIENT]
All 3 spans share one traceId: yesThe real-SDK wiring is in examples/mcp-server.ts.
API reference
Instrumentation
instrumentToolHandler(toolName, handler, options?)→ wrapped handler. Drop-in(args, extra) => resultwrapper. Options:method(default"tools/call"),tracer,attributes, andgetMeta/getRequestId/getSessionIdextractor overrides.runInToolSpan(meta, options, body)→Promise<T>. Low-level primitive: runsbody(span)inside aSERVERspan parented tometa'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). DefaultsbasetoROOT_CONTEXTfor deterministic behavior.injectTraceContext(meta, context?)→Meta. Writes trace keys into_metafromcontext(default: active context). Mutates and returnsmeta.parseTraceparent(s)→SpanContext | null. W3C-conformant parse. Version00is 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. Returnsnullfor malformed,ff-version, or all-zero (invalid trace-id / parent-id) values. Result isisRemote: true.formatTraceparent(spanContext)→string | null. Serializes to a version-00traceparent;nullfor an invalid span context.spanContextToContext(spanContext, base?)→Context. Wrap a span context you already hold into a context for injection.traceContextFields()→string[]. The_metakeys 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
_metameans propagation works identically on every transport — which is exactly why the spec reserved these keys in_metarather 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.
_metatravels 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 — runtime defense sidecar: scans tool calls, verifies signed manifests, blocks known-bad CVEs
- 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 — leak-safe SEP-2549 caching (
ttlMs+cacheScope) - skilldoctor — linter + security scanner for agent skill files
License
MIT © StudioMeyer
