Skip to content

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.


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.

Terminal window
npm install autotel-mcp-instrumentation @modelcontextprotocol/server @modelcontextprotocol/client autotel

For a 2025-era server, install @modelcontextprotocol/sdk instead. All MCP packages are optional peers, so install only what you build against.

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.

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 automatically
const result = await traced.callTool({
name: 'get_weather',
arguments: { location: 'NYC' },
});

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.

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
}

Per the OTel MCP semantic conventions:

  • mcp.method.name: tools/call, resources/read, prompts/get
  • gen_ai.tool.name / gen_ai.prompt.name / mcp.resource.uri
  • gen_ai.operation.name: execute_tool on tool spans
  • mcp.protocol.version, mcp.session.id (era-dependent, see above)
  • error.type: tool_error when the result carries isError — on the client span as well as the server’s, so the caller’s half of the trace does not read as a success
  • mcp.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 completing
  • gen_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.

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.categoryauth, timeout, network, validation, serialization, dependency, or internal. Ordered most-specific-first, so 504 Gateway Timeout reads as timeout. 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 hash

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: inject
await client.callTool({
name: 'my-tool',
arguments: {},
_meta: injectOtelContextToMeta(),
});
// Server side: extract from the context argument, not the arguments
const handler = async (args, ctx) => {
const parent = extractOtelContextFromMeta(ctx.mcpReq._meta);
return context.with(parent, () => runHandler(args));
};

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 }
: {}),
}),
});

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 limits

Guard 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 added

A 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).

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

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_PERSIST to a libsql file path for persistence.
  • Node.js 20+
  • An MCP-compatible client

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.

{
"mcpServers": {
"autotel": {
"command": "npx",
"args": ["autotel-mcp"]
}
}
}
Terminal window
claude mcp add autotel npx autotel-mcp

Once 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.

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:

Terminal window
# 1. devtools owns port 4318: receives OTLP, serves the dashboard
npx autotel-devtools
# 2. MCP reads through devtools and binds no OTLP port of its own
AUTOTEL_BACKEND=devtools npx autotel-mcp
Terminal window
# 3. your app exports once, to devtools
OTEL_EXPORTER_OTLP_PROTOCOL=http/json \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
node app.js

Your 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.

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.

The tempo, prometheus and loki defaults above are already the ports the LGTM stack publishes, so one command covers all three signals:

Terminal window
docker compose -f docker/lgtm.yml up -d
Terminal window
AUTOTEL_BACKEND=auto \
TEMPO_BASE_URL=http://localhost:3200 \
PROMETHEUS_BASE_URL=http://localhost:9090 \
LOKI_BASE_URL=http://localhost:3100 \
npx autotel-mcp

auto 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" }
}
}
}