mcpField
O
Otel

Otel

Local
npmjs.comDeveloper Tools0Updated 1w ago

About Otel

mcp-otel is a small library for MCP server authors and clients that connects W3C trace context in request metadata with OpenTelemetry spans. It leaves OpenTelemetry setup and exporter choices to the application.

Key features

  1. 01Propagates W3C context through _meta
  2. 02Wraps tool handlers with server spans
  3. 03Provides trace extraction and injection helpers
  4. 04Records span status and exceptions
  5. 05Supports custom span attributes

Use cases

  1. 01Connect host, tool, and downstream spans
  2. 02Instrument selected MCP tool handlers
  3. 03Forward trace context from an MCP client

MCP support

It reads and writes traceparent, tracestate, and baggage in MCP _meta. On servers, it creates tool-call spans; clients can inject active trace context into requests.

Configuration

Local server config is the same for every client. Copy it into your client’s MCP config file.

mcp.json · Universal config
{
"mcpServers": {
"otel": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"\\"
]
}
}
}
Run commanddocker run -i --rm '\'

Local servers need a runtime on your machine (e.g. Node.js 18+). Config file locations differ by client; see your client’s docs.

Packages
oci\

Tools

No tool list yet

Local servers expose tools only once running on your machine, and the maintainer has not provided a tool list.

Details

From the project 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.

npm CI OpenSSF Scorecard License: MIT


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 and W3C 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: 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:

PackageRangeRequired?
@opentelemetry/api^1.9.0yes
@modelcontextprotocol/sdk>=1.10.0 <2optional (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 / 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 <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 / 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:

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 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:

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.

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<T>. 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 — 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

Comments

Markdown supported

New to MCP?

From what the protocol is to installing your first server, these answers cover most first questions.

An open protocol that defines how AI applications connect to external tools and data in a uniform way. Clients talk to the model; MCP servers provide tools, resources and prompt templates. They exchange standard messages, so one server can be reused by many clients.