MCP Tracing & Telemetry
Tracing an MCP server means seeing which tool the agent called, how long it
took and what it threw. trace-mcp ships a pluggable observability bridge (P13)
that emits OpenTelemetry-compatible spans for every AI provider call and every
MCP tool invocation, so MCP tool traffic shows up in the same tracing backend
as the rest of your stack. The default sink is noop — opt-in only, and
switched on with the telemetry.* keys described in
configuration. Turning it on
also unlocks the persistent window values of analyze_perf, which is
otherwise limited to the current session (analytics).
This page is about spans you export to your own collector. It is not the anonymous daily install ping, which is a separate subsystem reporting to the maintainer. That one’s published field list — everything it sends, both ways to turn it off, and how to delete the state it keeps — is on the privacy page, with the source in
src/telemetry/usage-ping.ts. Nothing on this page phones home.
Quickstart (3 commands)
# 1. Stand up a local OTLP collector (Jaeger all-in-one).
cd ops/telemetry && docker compose up -d && cd -
# 2. Enable the bridge for this project (.trace.json is gitignored).
cat > .trace.json <<'JSON'
{
"telemetry": {
"observability": {
"enabled": true,
"sink": "otlp",
"otlp": { "endpoint": "http://localhost:4318/v1/traces" }
}
}
}
JSON
# 3. Run anything — spans land in Jaeger UI at http://localhost:16686.
pnpm run build && node dist/cli.js eval run --dataset default
Where the traces appear
Open http://localhost:16686, pick trace-mcp from the Service dropdown,
click Find Traces.
Span topology
| Span name | Origin | Key attributes |
|---|---|---|
tool.<name> |
src/server/tool-gate.ts |
tool.name, duration_ms, tool.is_error |
ai.embed |
TrackedEmbeddingService.embed |
ai.provider, ai.model, ai.url, ai.input_size, ai.output_size, duration_ms |
ai.embed_batch |
TrackedEmbeddingService.embedBatch |
as above + batch counts |
ai.generate |
TrackedInferenceService.generate |
ai.provider, ai.model, ai.max_tokens, ai.temperature, ai.input_size, duration_ms |
ai.generate_stream |
TrackedInferenceService.generateStream |
as above |
Errors thrown by the wrapped call surface as a span exception event
(exception.type, exception.message, exception.stacktrace) with span
status ERROR. The error is rethrown so caller control flow is unchanged.
Switching sinks
In .trace.json (or ~/.trace/.config.json for a global default):
| Goal | Config |
|---|---|
| Disabled (default) | telemetry.observability.enabled: false or omit the block |
| Local Jaeger / collector | sink: "otlp", otlp.endpoint: "http://localhost:4318/v1/traces" |
| Hosted OTLP (e.g. Honeycomb) | sink: "otlp", otlp.endpoint: "https://api.honeycomb.io/v1/traces", otlp.headers: { "x-honeycomb-team": "…" } |
| Langfuse cloud | sink: "langfuse", langfuse.publicKey: "pk-…", langfuse.secretKey: "sk-…" |
| Fan-out to both | sink: "multi" + both otlp and langfuse blocks |
| Probabilistic sampling | sampleRate: 0.1 (10% of spans kept) |
All sinks lazy-load — paying for noop is free, paying for otlp only loads
when sink === "otlp".
Adding a custom span from your own code
The public API lives in src/telemetry/index.ts:
import {
getGlobalTelemetrySink,
instrumentAsync,
} from './telemetry/index.js';
// Time an async function and auto-record exceptions.
await instrumentAsync(
getGlobalTelemetrySink(),
'my.operation',
{ 'my.attr': 42 },
async (span) => {
// ... your work ...
span.setAttribute('result.count', items.length);
return items;
},
);
// Manual span lifecycle.
const span = getGlobalTelemetrySink().startSpan('my.op', { foo: 'bar' });
try {
// ...
span.setStatus('ok');
} catch (err) {
span.recordError(err);
throw err;
} finally {
span.end();
}
instrumentAiCall and instrumentToolCall convenience wrappers exist for
the two standardised attribute schemas — prefer them when emitting AI or
tool-flavoured spans so dashboards stay consistent.
Cleanup
cd ops/telemetry && docker compose down -v
rm .trace.json # if you don't want telemetry enabled going forward
Performance
noopsink: a class allocation per span + 4 no-op method calls. Measured overhead is below the per-call jitter floor (~sub-microsecond).otlpsink: buffers spans in memory; flushes when 50 spans accumulate or every 5 s. Each flush is a single POST to/v1/traces. The export is fire-and-forget —onErrorlogs atwarnbut never blocks the caller.sampleRate < 1: aSamplingSinkwraps the real sink and rolls aMath.random()perstartSpan/emit. Kept spans cost the full export path; dropped spans are noop.
Frequently asked questions
What is MCP tracing?
MCP tracing instruments Model Context Protocol interactions between AI coding agents (such as Claude Code, Cursor, or Windsurf) and MCP servers. Instead of debugging tool timeouts or failed tool invocations through raw log tails, MCP tracing emits structured OpenTelemetry spans for every tool call (tool.<name>) and provider inference (ai.embed, ai.generate). This surfaces tool execution duration, error stack traces, and token costs in standard APM dashboards like Jaeger, Honeycomb, or Langfuse alongside your backend application traces.
How do I trace MCP tool calls with trace-mcp?
In your project repository, configure .trace.json to enable the observability bridge:
{
"telemetry": {
"observability": {
"enabled": true,
"sink": "otlp",
"otlp": { "endpoint": "http://localhost:4318/v1/traces" }
}
}
}
Once configured, any command or client session running through trace-mcp flushes traces asynchronously to your configured OTLP endpoint without adding blocking network overhead to tool turns. For detailed configuration parameters, see the configuration guide.
Does MCP tracing export private repository code or credentials?
No. trace-mcp telemetry spans export operational metadata only: span names, execution durations (duration_ms), error exception types, input/output payload sizes in bytes, and model token counts. Raw source code files, git diffs, and API credentials are never included in OpenTelemetry attributes or events. Furthermore, the sink defaults to noop until you explicitly opt in, and all usage ping policies are documented on the privacy page.
Troubleshooting
- No spans in Jaeger. Confirm
telemetry.observability.enabledresolves totrue(node dist/cli.js config show). Confirm the endpoint matches the Jaeger OTLP HTTP port (4318by default). Watch the trace-mcp log fortelemetry.otlp_export_failed— most often a 404 on the path or a hostname typo. - Spans appear once then never refresh. Jaeger memory store evicts after
~10k traces. Restart the container or switch to
badgerstorage. - Container can’t reach trace-mcp. This direction never happens — the
SDK is the client; Jaeger is the server. Always
http://localhost:4318from the trace-mcp side.
Last updated: September 11, 2026