MCP
The autotel monorepo ships two Model Context Protocol packages with different jobs. Pick the one you need:
| Package | Purpose |
|---|---|
autotel-mcp-instrumentation |
Instrument your own MCP servers and clients with OpenTelemetry. |
autotel-mcp |
A standalone MCP server that lets AI agents query your traces, metrics, and logs. |
If you’re building an MCP-based product → use autotel-mcp-instrumentation.
If you want Claude / Cursor / Windsurf to investigate observability data
→ install autotel-mcp.
autotel-mcp-instrumentation
Section titled “autotel-mcp-instrumentation”Automatic instrumentation for MCP servers and clients. W3C Trace Context
propagates through the _meta field, which works across stdio, Streamable HTTP,
or any custom transport. Bundle ~7KB total.
Supports both protocol eras from the same call: MCP 2026-07-28 (the v2
SDK) and the 2025-era v1 SDK. The era is detected per request, and no MCP SDK is
imported at runtime.
Installation
Section titled “Installation”npm install autotel-mcp-instrumentation @modelcontextprotocol/server @modelcontextprotocol/client autotelFor a 2025-era server, install @modelcontextprotocol/sdk instead. All MCP
packages are optional peers, so install only what you build against.
Server-side
Section titled “Server-side”2026-07-28 has no handshake and no session, so a server instance holds nothing
between requests: the SDK builds one per request from a factory. Instrument
inside the factory.
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';import { instrumentMcpServer } from 'autotel-mcp-instrumentation/server';import { init } from 'autotel';import { z } from 'zod';
// Telemetry first: OpenTelemetry must be initialised before anything traced// is constructed.init({ service: 'mcp-weather-server', endpoint: 'http://localhost:4318' });
function createServer() { const server = new McpServer( { name: 'weather', version: '1.0.0' }, { capabilities: { tools: {} } }, );
// Instrument before registering, so the proxy wraps the real handlers. const traced = instrumentMcpServer(server, { networkTransport: 'tcp', captureToolArgs: true, // opt-in captureToolResults: false, // default; results may carry PII });
traced.registerTool( 'get_weather', { title: 'Get weather', description: 'Get current weather for a location', inputSchema: z.object({ location: z.string() }), annotations: { readOnlyHint: true }, }, async ({ location }) => { // Auto-traced, parented to the caller's span via ctx.mcpReq._meta const weather = await fetchWeather(location); return { content: [{ type: 'text', text: `Temp: ${weather.temp}°F` }] }; }, );
return traced;}
export default createMcpHandler(createServer);On a 2025-era server you hold a single Server and instrument it once. Same
call, same attributes.
Client-side
Section titled “Client-side”import { Client, StreamableHTTPClientTransport,} from '@modelcontextprotocol/client';import { instrumentMcpClient } from 'autotel-mcp-instrumentation/client';import { init } from 'autotel';
init({ service: 'mcp-weather-client', endpoint: 'http://localhost:4318' });
const client = new Client({ name: 'weather-client', version: '1.0.0' });
const traced = instrumentMcpClient(client, { networkTransport: 'tcp', captureToolArgs: true,});
await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL)));
// _meta is injected with traceparent/tracestate/baggage automaticallyconst result = await traced.callTool({ name: 'get_weather', arguments: { location: 'NYC' },});Protocol eras
Section titled “Protocol eras”MCP 2026-07-28 dropped the initialize/initialized handshake (SEP-2575) and
the Mcp-Session-Id header (SEP-2567). Every request is self-describing, so any
request can land on any instance behind a plain round-robin balancer.
Propagation got easier, not harder: traceparent always rode in _meta, never
in the session or the transport. 2026-07-28 makes _meta the mandatory
per-request envelope, so there is now always somewhere for it to ride, and no
session for a trace to be orphaned from.
| Signal | 2026-07-28 | 2025-era |
|---|---|---|
mcp.protocol.version |
per request, from the _meta envelope |
not set (fixed at initialize) |
mcp.session.id |
not set — there are no sessions | from the request or the transport |
server/discover |
traced as a discovery operation | n/a — initialize instead |
mcp.input_required |
set when a handler returns inputRequired(...) |
n/a — elicitation is push-style |
A handler that returns inputRequired(...) paused rather than completed.
Its span gets mcp.input_required=true and its status is left UNSET, so
“asked the user a question” is counted as neither completed work nor a success,
and the client’s retry does not read as a duplicate call.
Configuration
Section titled “Configuration”interface McpInstrumentationConfig { captureToolArgs?: boolean; // default false (opt-in) captureToolResults?: boolean; // default false (PII risk) captureErrors?: boolean; // default true enableMetrics?: boolean; // default true captureDiscoveryOperations?: boolean; // tools/list, server/discover, … default true networkTransport?: 'pipe' | 'tcp' | string; sessionId?: string; // fallback only; the request always wins customAttributes?: (ctx: { type; name; args; result }) => Attributes; // …plus the security options below}Span attributes
Section titled “Span attributes”Per the OTel MCP semantic conventions:
mcp.method.name:tools/call,resources/read,prompts/getgen_ai.tool.name/gen_ai.prompt.name/mcp.resource.urigen_ai.operation.name:execute_toolon tool spansmcp.protocol.version,mcp.session.id(era-dependent, see above)error.type:tool_errorwhen the result carriesisError— on the client span as well as the server’s, so the caller’s half of the trace does not read as a successmcp.failure.category/mcp.failure.fingerprint: which failure, and whether it is the same one as last time (see below)mcp.input_required: the call paused for input instead of completinggen_ai.tool.call.arguments/gen_ai.tool.call.result(opt-in only)
Client spans carry the same attributes with SpanKind.CLIENT. Traced client
methods are callTool, readResource, getPrompt, plus listTools,
listResources, listPrompts, ping and discover.
Grouping failures
Section titled “Grouping failures”error.type tells you a call failed. It does not tell you whether ten failures
are one bug ten times or ten separate bugs, and the raw message cannot either —
real failure text is full of ids, ports and durations that differ every run.
Every failure path gets two more attributes, on both sides of the trace: a
handler or a call that throws, and an isError result produced or received.
That includes resources/read and prompts/get, which fail by rejecting rather
than returning isError. Both ends fingerprint identical text identically, so
one bug is one group whichever side recorded it:
mcp.failure.category—auth,timeout,network,validation,serialization,dependency, orinternal. Ordered most-specific-first, so504 Gateway Timeoutreads astimeout. Low cardinality, so it is also a label on the duration metric.mcp.failure.fingerprint— a hash of the failure text with run-specific values stripped. Two occurrences of one cause produce the same value across processes, so it works as a correlation key on a stateless deployment where there is no session to accumulate against. Span-only: it is one series per distinct bug, which is more than a metric backend should carry.
A failure with no text to group on gets neither attribute, rather than a shared fingerprint of the empty string that would read as one very frequent bug.
The same functions are exported for failures the instrumentation does not wrap:
import { classifyFailure, fingerprintFailure,} from 'autotel-mcp-instrumentation';
classifyFailure('ETIMEDOUT: upstream timed out after 30000ms'); // 'timeout'fingerprintFailure('user 0f9c2b1a-… not found after 37ms'); // stable hashDistributed tracing via _meta
Section titled “Distributed tracing via _meta”Because trace context lives in the JSON payload’s _meta field, the same
propagation works across stdio, Streamable HTTP, or any custom transport without
header plumbing. The keys are the bare traceparent / tracestate / baggage
names, not the reserved io.modelcontextprotocol/* envelope namespace, so they
survive the SDK’s envelope lift untouched.
import { injectOtelContextToMeta, extractOtelContextFromMeta,} from 'autotel-mcp-instrumentation/context';import { context } from '@opentelemetry/api';
// Client side: injectawait client.callTool({ name: 'my-tool', arguments: {}, _meta: injectOtelContextToMeta(),});
// Server side: extract from the context argument, not the argumentsconst handler = async (args, ctx) => { const parent = extractOtelContextFromMeta(ctx.mcpReq._meta); return context.with(parent, () => runHandler(args));};Custom attributes & PII redaction
Section titled “Custom attributes & PII redaction”customAttributes runs per span. Use it to redact PII or attach
business-specific tags:
const instrumented = instrumentMcpServer(server, { captureToolArgs: false, // keep raw arguments off the span customAttributes: ({ type, name, args, result }) => ({ 'tool.location': args?.location, // safe to log 'tenant.id': args?.tenantId, ...(type === 'tool' && name === 'search' ? { 'search.results.count': result?.items?.length ?? 0 } : {}), }),});Security observability
Section titled “Security observability”MCP is where untrusted data crosses into your agent. The agentic-web threat model (Chrome/Google, June 2026) has two vectors: malicious manifests (hidden instructions in a tool’s name/description/annotations) and contaminated outputs (injection smuggled inside legitimate tool results). Detecting these in production is an observability problem. And this package makes it observable at the MCP boundary.
On by default (no extra config), every tool span carries:
- Annotation hints →
mcp.tool.read_only,mcp.tool.destructive,mcp.tool.idempotent,mcp.tool.open_world,mcp.tool.untrusted_content - Payload sizes →
mcp.tool.arguments.size,mcp.tool.result.size(sizes only, no content). A token-exhaustion / contaminated-output tell
Opt-in. A pluggable classifier (Model Armor, Promptfoo, an LLM critic, or the built-in heuristic) plus output budgets:
import { instrumentMcpServer, heuristicInjectionClassifier, MCP_CHAR_BUDGETS,} from 'autotel-mcp-instrumentation';
instrumentMcpServer(server, { securityClassifier: heuristicInjectionClassifier(), // or your own (sync/async) outputCharBudget: MCP_CHAR_BUDGETS.TOOL_OUTPUT, // 1500 (WebMCP recommendation)});The classifier scans tool arguments (server + client) and results (the
contaminated-output vector the agent receives), recording mcp.security.injection.*
and emitting a mcp.security.injection_suspected event on non-clean verdicts.
Classifier failures never break the traced call. The built-in heuristic is a
cheap tripwire. Feed its signal to a real classifier, don’t gate actions on it.
Helpers (autotel-mcp-instrumentation/security, runtime-agnostic. Works in
Workers/edge):
import { spotlight, validateToolBudget,} from 'autotel-mcp-instrumentation/security';
const safe = spotlight(userComment); // <untrusted>…</untrusted> (or { method: 'base64' })const violations = validateToolBudget({ name, description, parameters }); // WebMCP char limitsGuard bridge, detection → enforcement. Pass an autotel-genai guard and
every tool call is recorded as a step; a stop rule throws to halt a runaway run:
import { createGenAiBudget } from 'autotel-genai/guard';
const guard = createGenAiBudget({ maxToolCalls: 50, maxCostUsd: 5 });instrumentMcpClient(client, { guard }); // duck-typed — no genai dependency addedA step’s error flag is the tool’s own verdict, so a result carrying isError
is recorded as a failed step even though nothing threw. Error-loop rules
therefore accumulate on the failure mode MCP tools actually use — if you have a
guard configured, expect it to trip on runs it previously let through.
Signals reference
| Signal | Kind | Meaning |
|---|---|---|
mcp.tool.* (hints) |
span attr | tool trust profile / manifest vector |
mcp.tool.{arguments,result}.size |
span attr | payload size (token-exhaustion tell) |
mcp.security.injection.* |
span attr | classifier verdict / score / categories |
mcp.security.injection_suspected |
event | non-clean classifier verdict |
mcp.security.budget_exceeded |
event | output over outputCharBudget |
mcp.security.events |
counter | aggregate security-signal count |
Query them in production with autotel security mcp (see the
investigate CLI).
Entry points
Section titled “Entry points”| Import | Contents | Size |
|---|---|---|
autotel-mcp-instrumentation |
Everything | ~7KB |
autotel-mcp-instrumentation/server |
instrumentMcpServer |
~5KB |
autotel-mcp-instrumentation/client |
instrumentMcpClient |
~4KB |
autotel-mcp-instrumentation/context |
inject*/extract*/activate* |
~2KB |
autotel-mcp-instrumentation/security |
classifier, spotlight, budgets | ~2KB |
Examples
Section titled “Examples”example-mcp-server: MCP server withinstrumentMcpServer().example-mcp-client: MCP client withinstrumentMcpClient()and distributed tracing.
autotel-mcp
Section titled “autotel-mcp”A standalone MCP server that gives AI agents (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Goose) the ability to investigate your OpenTelemetry traces, metrics, and logs. It ships with a built-in OTLP collector on port 4318, so any instrumented app can send data directly. You don’t need Jaeger, Tempo, or Grafana.
- Backend-agnostic. Built-in OTLP collector accepts data from any OTel-instrumented app.
- All three signals. Traces, metrics, and logs with cross-signal correlation.
- Agent-optimised. 41 tools designed for progressive investigation: discover → diagnose → correlate → root-cause.
- Zero infrastructure. In-memory by default; set
AUTOTEL_PERSISTto a libsql file path for persistence.
Requirements
Section titled “Requirements”- Node.js 20+
- An MCP-compatible client
Protocol
Section titled “Protocol”Speaks MCP 2026-07-28: no initialize handshake, no session header,
server/discover for capability discovery, and cacheable list results. Because
every request is self-describing, any request can land on any instance — plain
round-robin routing, no sticky sessions, no shared session store.
2025-era clients (the v1 SDK that Claude Code, Claude Desktop and Cursor still ship) are served from the same tool definitions through the SDK’s stateless legacy path, so existing MCP clients keep working unchanged.
--transport http is Streamable HTTP. There is no SSE transport: HTTP+SSE has
been deprecated since protocol 2025-03-26 and is gone.
Standard config
Section titled “Standard config”{ "mcpServers": { "autotel": { "command": "npx", "args": ["autotel-mcp"] } }}Claude Code
Section titled “Claude Code”claude mcp add autotel npx autotel-mcpOnce installed, ask the agent things like “What slowed down checkout in the
last hour?” or “Show traces with errors for service.name=api”. The agent
will discover services, query traces, correlate logs, and return a written
investigation.
Run alongside autotel-devtools
Section titled “Run alongside autotel-devtools”autotel-mcp and autotel-devtools both bind port
4318 by default, because each starts its own OTLP collector. Start both with
their defaults and the second one falls forward to a different port, so your app
sends telemetry to one receiver while the other stays empty.
Point autotel-mcp at the devtools receiver instead of starting a second
collector. One process owns 4318, the browser dashboard and the agent read the
same data:
# 1. devtools owns port 4318: receives OTLP, serves the dashboardnpx autotel-devtools
# 2. MCP reads through devtools and binds no OTLP port of its ownAUTOTEL_BACKEND=devtools npx autotel-mcp# 3. your app exports once, to devtoolsOTEL_EXPORTER_OTLP_PROTOCOL=http/json \OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \node app.jsYour app streams telemetry to devtools. The dashboard renders it live, and the
agent queries the same traces over the devtools HTTP read API. On startup the
devtools backend probes /healthz and refuses to run if a foreign process
holds the port, so a wrong answer never masquerades as your data. Set
DEVTOOLS_BASE_URL (default http://localhost:4318) when you moved devtools to
another port.
Backends
Section titled “Backends”autotel-mcp reads from one telemetry source, chosen with AUTOTEL_BACKEND
(default collector):
| Backend | Reads from | Env |
|---|---|---|
collector |
Its own built-in OTLP receiver | AUTOTEL_COLLECTOR_PORT (4318) |
devtools |
A running autotel-devtools instance | DEVTOOLS_BASE_URL (http://localhost:4318) |
jaeger |
Jaeger query API | JAEGER_BASE_URL (http://localhost:16686) |
tempo |
Grafana Tempo | TEMPO_BASE_URL (http://localhost:3200) |
prometheus |
Prometheus | PROMETHEUS_BASE_URL (http://localhost:9090) |
loki |
Grafana Loki | LOKI_BASE_URL (http://localhost:3100) |
stack |
Tempo/Jaeger + Prometheus + Loki, composed from whichever *_BASE_URL you set |
the base URLs above |
auto |
Probes Tempo, Jaeger, Prometheus and Loki, then reads from whatever answers (falls back to fixture) |
the base URLs above |
fixture |
A JSON file, for demos and tests | AUTOTEL_FIXTURE_PATH (./fixtures/telemetry.json) |
logfire |
Pydantic Logfire query API (traces only) | LOGFIRE_BASE_URL, LOGFIRE_READ_TOKEN |
datadog |
Datadog APM v2 APIs (traces only) | DD_SITE, DD_API_KEY, DD_APP_KEY |
signoz |
SigNoz Query Builder v5 API (traces only) | SIGNOZ_BASE_URL, optional SIGNOZ_API_KEY |
Hosted-vendor credentials belong in env, never command arguments. Logfire
requires a read-scope token and its regional query host; Datadog requires both
an API key and an application key. These three backends return an explicit
unsupported capability for metrics and logs.
Read from a local LGTM stack
Section titled “Read from a local LGTM stack”The tempo, prometheus and loki defaults above are already the ports
the LGTM stack publishes, so one command covers all
three signals:
docker compose -f docker/lgtm.yml up -dAUTOTEL_BACKEND=auto \TEMPO_BASE_URL=http://localhost:3200 \PROMETHEUS_BASE_URL=http://localhost:9090 \LOKI_BASE_URL=http://localhost:3100 \npx autotel-mcpauto probes each and uses whatever answers, so the same command works whether
you have the full stack up or only part of it. Use stack instead when you want
to fail loudly on a backend that should be there and is not.
Send telemetry to http://localhost:4318 and events to Loki with
LokiSubscriber, and the agent can investigate
traces, metrics and logs from one local container.
To select a backend through the MCP client config, add it to env:
{ "mcpServers": { "autotel": { "command": "npx", "args": ["autotel-mcp"], "env": { "AUTOTEL_BACKEND": "devtools" } } }}Related
Section titled “Related”- AI-Assisted Observability: patterns for letting agents investigate production traces.
- MCP example app: autotel instruments a Node app, Claude queries the traces via MCP.