Build-it
How to trace MCP requests with OpenTelemetry
The 2026-07-28 MCP spec carries W3C trace context in _meta, so MCP calls join one distributed trace instead of two orphans. Build-it, tested on Node 25.
Mark
Head of Marketing, MCPOrbit
- Published
- Updated
- · Updated
- Read time
- · 9 min read
To trace Model Context Protocol (MCP) requests end to end, put W3C trace context in the _meta field of every MCP message. The 2026-07-28 spec (SEP-414) standardizes three _meta keys, traceparent, tracestate, and baggage, using the exact wire format your HTTP stack already speaks. The client injects its active span into _meta, the server reads it and opens a child span, and the whole call becomes one connected trace instead of two disconnected halves.
This matters because an MCP call crosses a process boundary. Your app opens a span, calls a tool on a remote MCP server, and the server does real work: a database query, an API call, another MCP hop. Without propagation, the server work lands in a separate trace with no parent, so you cannot see which user request caused which tool call. We run MCP observability at MCPOrbit, and orphan server spans are the single most common reason a slow tool call is impossible to attribute. Trace context in _meta fixes it, and you can wire it today with zero dependencies.
Why MCP calls show up as orphan traces
Distributed tracing works by passing a trace id across every hop. HTTP does this with the traceparent header. But an MCP request rides inside a JSON-RPC body, and a tool call can be relayed by a gateway that never touches your HTTP headers. If the trace id only lives in the transport header, it gets dropped the moment the message is repackaged. The server then starts a fresh trace, and your tool call has no parent.
The fix is to carry trace context in the message itself. MCP messages already have a _meta bag for exactly this kind of cross-cutting metadata. Put the traceparent there and it survives every relay, because it travels with the payload, not the connection.
What the 2026-07-28 spec standardizes
Before the 2026-07-28 revision, teams invented their own _meta keys for trace ids and none of them agreed. SEP-414 fixes the names. It documents three _meta keys that mirror the W3C Trace Context standard: traceparent (the trace id and parent span id), tracestate (vendor-specific trace data), and baggage (key-value context like a tenant id). Because the values use the W3C format, any OpenTelemetry-compatible backend, Honeycomb, Jaeger, Datadog, or a raw collector, can ingest them without translation.
The trace-context helpers
Start with the W3C format. A traceparent is a single string: 00-<32-hex trace id>-<16-hex span id>-<2-hex flags>. These helpers generate and parse it. No dependency, just node:crypto. Save this as trace.mjs.
// Minimal W3C Trace Context helpers (https://www.w3.org/TR/trace-context/).
// The 2026-07-28 MCP spec (SEP-414) propagates OpenTelemetry context through the
// `_meta` keys `traceparent`, `tracestate`, and `baggage` on every request and
// result, using the same W3C wire format your HTTP stack already speaks.
import { randomBytes } from "node:crypto";
const hex = (n) => randomBytes(n).toString("hex");
export const newTraceId = () => hex(16); // 16 bytes -> 32 hex chars
export const newSpanId = () => hex(8); // 8 bytes -> 16 hex chars
// "00-<32-hex trace-id>-<16-hex span-id>-<2-hex flags>"
export function formatTraceparent({ traceId, spanId, sampled = true }) {
return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
}
export function parseTraceparent(value) {
if (typeof value !== "string") return null;
const m = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(value);
if (!m) return null;
return { traceId: m[1], parentSpanId: m[2], sampled: (parseInt(m[3], 16) & 1) === 1 };
}Inject trace context on the client
The client owns the active span. On each outbound call it mints a fresh call span, formats it into traceparent, and drops it into the request _meta alongside the required clientInfo. It also carries baggage so context like a tenant id reaches the server. Save this as client.mjs.
// An MCP client that INJECTS its active span context into every request's
// `_meta`, so the server can continue the same trace (2026-07-28 / SEP-414).
import { formatTraceparent, newSpanId } from "./trace.mjs";
export class TracingMcpClient {
// `span` is the caller's active span: { traceId, spanId }. In real code this
// comes from your OTel tracer's current context; here it's passed in so the
// trace is deterministic and testable.
constructor(endpoint, span, { baggage } = {}) {
this.endpoint = endpoint;
this.span = span;
this.baggage = baggage;
}
async _rpc(method, params) {
// Each outbound call is its own client span, child of the active span,
// and THAT is what we advertise to the server as the parent.
const callSpanId = newSpanId();
const _meta = {
traceparent: formatTraceparent({ traceId: this.span.traceId, spanId: callSpanId }),
"io.modelcontextprotocol/clientInfo": { name: "trace-demo-client", version: "1.0.0" },
};
if (this.baggage) _meta.baggage = this.baggage;
const res = await fetch(this.endpoint, {
method: "POST",
headers: { "content-type": "application/json", "Mcp-Method": method, "Mcp-Name": params?.name ?? "" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params, _meta }),
});
return (await res.json()).result;
}
listTools() { return this._rpc("tools/list"); }
callTool(name, args) { return this._rpc("tools/call", { name, arguments: args }); }
}Continue the trace on the server
The server is stateless, per the 2026-07-28 transport. It reads _meta.traceparent, extracts the trace id and the client's span id, and starts a server span that reuses the trace id and points its parent at the client span. That single step is what joins the two halves. It then echoes trace context back on the result _meta so the client can link the response. Save this as server.mjs.
// A stateless MCP server over Streamable HTTP that CONTINUES the caller's trace.
// It reads W3C trace context from each request's `_meta` (2026-07-28 / SEP-414),
// opens a child span under the client's span, and stamps its own serverInfo. No
// SDK needed: the released @modelcontextprotocol/sdk (1.30.0) tops out at
// protocol 2025-11-25 and doesn't wire `_meta` trace context yet, so we do it
// at the transport boundary.
import { createServer } from "node:http";
import { parseTraceparent, newSpanId } from "./trace.mjs";
// A stand-in for your real tracer (OTel SDK, Honeycomb, Jaeger, etc.). We just
// record spans in memory so the demo can assert the trace is connected.
export const recordedSpans = [];
function startServerSpan(name, meta) {
const ctx = parseTraceparent(meta?.traceparent);
const span = {
name,
traceId: ctx?.traceId ?? "orphan", // same trace as the client, or a new root
spanId: newSpanId(),
parentSpanId: ctx?.parentSpanId ?? null, // link to the client's span
baggage: meta?.baggage ?? null, // e.g. "tenant=acme"
};
recordedSpans.push(span);
return span;
}
function handle(msg) {
const { id, method, params, _meta } = msg;
const span = startServerSpan(`mcp.server ${method}`, _meta);
let result;
if (method === "tools/call") {
result = { content: [{ type: "text", text: `handled ${params?.name}` }] };
} else {
result = { tools: [{ name: "ping" }] };
}
return {
jsonrpc: "2.0",
id,
result: {
...result,
resultType: "complete",
// Echo trace context back so the client can link the response too.
_meta: {
"io.modelcontextprotocol/serverInfo": { name: "trace-demo", version: "1.0.0" },
traceparent: `00-${span.traceId}-${span.spanId}-01`,
},
},
};
}
export function createMcpServer() {
return createServer((req, res) => {
if (req.method !== "POST") return void res.writeHead(405).end();
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () =>
res
.writeHead(200, { "content-type": "application/json" })
.end(JSON.stringify(handle(JSON.parse(body)))),
);
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
createMcpServer().listen(8931, () => console.log("MCP server on :8931"));
}Run it and prove the trace is connected
The test starts the server, makes two calls under one root span, and asserts the invariants that define a connected trace: every server span shares the client's trace id, every server span has a parent span id, baggage arrived, and the echoed response context is still the same trace. Save this as demo.mjs.
// Runnable proof: the client's trace flows into the server as ONE connected
// trace (shared traceId, correct parent linkage, baggage carried). Run: `node demo.mjs`
import assert from "node:assert/strict";
import { createMcpServer, recordedSpans } from "./server.mjs";
import { TracingMcpClient } from "./client.mjs";
import { newTraceId, newSpanId, parseTraceparent } from "./trace.mjs";
const server = createMcpServer();
await new Promise((r) => server.listen(0, r));
const url = `http://127.0.0.1:${server.address().port}`;
// A request comes in with an active trace (e.g. from the user-facing app).
const rootSpan = { traceId: newTraceId(), spanId: newSpanId() };
const client = new TracingMcpClient(url, rootSpan, { baggage: "tenant=acme" });
const r1 = await client.listTools();
const r2 = await client.callTool("get_forecast", { city: "Lisbon" });
// 1) Both server spans joined the SAME trace as the client - no orphans.
assert.equal(recordedSpans.length, 2);
for (const s of recordedSpans) assert.equal(s.traceId, rootSpan.traceId, "server span shares client traceId");
// 2) Each server span is a CHILD of the client call span (parent linkage set).
for (const s of recordedSpans) assert.match(s.parentSpanId, /^[0-9a-f]{16}$/);
// 3) Baggage (tenant=acme) rode along to the server.
for (const s of recordedSpans) assert.equal(s.baggage, "tenant=acme");
// 4) The server echoed trace context back on the result, still the same trace.
assert.equal(parseTraceparent(r1._meta.traceparent).traceId, rootSpan.traceId);
assert.equal(parseTraceparent(r2._meta.traceparent).traceId, rootSpan.traceId);
server.close();
console.log(`PASS - 1 trace ${rootSpan.traceId} spans client + ${recordedSpans.length} server spans; baggage carried; response context linked.`);Run the whole thing with one command. There are no dependencies to install.
node demo.mjsPASS - 1 trace 4eac8719ec3d713f522393de1d011407 spans client + 2 server spans; baggage carried; response context linked.Two calls, one trace id, both server spans parented to the client. Point startServerSpan at your real tracer instead of the in-memory array and the same MCP call now appears as one span tree in your tracing backend.
Frequently asked questions
Frequently asked questions
- How do I add distributed tracing to an MCP server?
- Read the
traceparentvalue from each request's_meta, parse the trace id and parent span id out of the W3C format, and start your server span with that trace id and parent. The 2026-07-28 MCP spec (SEP-414) standardizestraceparent,tracestate, andbaggageas_metakeys for this. - Where does MCP put the trace id, in headers or the body?
- In the message
_meta, not the HTTP headers._metatravels with the JSON-RPC payload, so the trace id survives gateways and relays that repackage the message and would otherwise drop a transport header. - Does the MCP SDK handle trace context for me?
- Not in the released
@modelcontextprotocol/sdk1.30.0, which implements protocol 2025-11-25. Until an SDK version ships 2026-07-28 support, inject and read the three_metakeys yourself at the transport boundary, as shown here. The W3C format stays the same either way. - What is baggage in MCP trace context?
baggageis a_metakey holding W3C Baggage: comma-separated key-value pairs liketenant=acmethat ride with the request. It carries application context (tenant, request source) alongside the trace id so your spans can be filtered by it.- Do I need OpenTelemetry to use MCP trace context?
- No. The values are plain W3C Trace Context strings, so you can generate and parse them with a few lines of code, as this post does. An OpenTelemetry SDK helps once you have many services, but it is not required to make MCP calls join one trace.
About the author
Mark
Head of Marketing, MCPOrbit
Mark leads marketing at MCPOrbit and writes the build-it MCP tutorials, code tested end to end before it ships.



