Guide · MCP Jaeger Integration
MCP Server Jaeger — search traces with PromQL, get trace by ID, list services and operations, and health check
Jaeger is the most widely deployed open-source distributed tracing backend in cloud-native environments — the natural first stop when an agent needs to debug a slow request or find where a transaction failed across microservices. This guide covers building TypeScript MCP tools for the Jaeger Query HTTP API: axios client setup with optional basic auth, understanding the critical microsecond timestamp requirement that trips up almost every first-time implementer, listing services and operations to discover the trace topology, searching traces with duration and tag filters, fetching a specific trace by ID from logs or propagation headers, and wiring a /health/jaeger endpoint that verifies both the Query API process and the storage backend before AliveMCP catches silent failures.
TL;DR
Jaeger exposes two separate API surfaces: the Query API on port 16686 (for reading traces) and the Collector API on ports 14250/14268/4317/4318 (for receiving spans). MCP tools query the Query API only — never the collector. The base URL is typically http://jaeger:16686. There is no built-in authentication — Jaeger is normally protected by a reverse proxy with basic auth. The single most important gotcha: Jaeger's start and end timestamp parameters for trace search are in microseconds, not milliseconds. JavaScript's Date.now() returns milliseconds — you must multiply by 1000 before passing to Jaeger. Forgetting this returns zero results with no error message.
Architecture overview and API surfaces
Jaeger's internal architecture separates concerns between data ingestion and data querying. The Collector process receives spans from instrumented applications; the Query process serves the UI and the HTTP API that MCP tools use. In a typical Kubernetes deployment these run as separate pods, though Jaeger's all-in-one image combines them for development.
| API surface | Port | Protocol | Purpose |
|---|---|---|---|
| Query HTTP API | 16686 | HTTP/JSON | Read traces, list services — MCP tools use this |
| Collector gRPC | 14250 | gRPC/Protobuf | Receive spans from Jaeger agents |
| Collector HTTP | 14268 | HTTP/Thrift | Receive spans via HTTP (legacy) |
| OTLP gRPC | 4317 | gRPC | Receive OpenTelemetry spans via OTLP |
| OTLP HTTP | 4318 | HTTP/Protobuf | Receive OpenTelemetry spans via OTLP |
The Query API exposes a JSON-based HTTP API under the /api/ path prefix. This is what MCP tools query — not the Jaeger UI's internal GraphQL or WebSocket connections. All endpoints return JSON with a consistent envelope: { "data": ..., "total": N, "limit": N, "offset": N, "errors": null }.
SDK setup and authentication
The Jaeger Query API has no official npm client, so MCP tools use axios directly against the /api/ endpoints. Jaeger has no built-in authentication — if your deployment sits behind a reverse proxy with HTTP basic auth, pass the credentials via axios's auth option. If Jaeger is cluster-internal with no auth, omit the auth block entirely.
import axios from 'axios';
const jaeger = axios.create({
baseURL: process.env.JAEGER_URL ?? 'http://localhost:16686',
timeout: 30_000,
// If behind basic auth:
...(process.env.JAEGER_USERNAME && {
auth: {
username: process.env.JAEGER_USERNAME,
password: process.env.JAEGER_PASSWORD!,
}
}),
});
| Environment variable | Example value | Notes |
|---|---|---|
JAEGER_URL |
http://jaeger:16686 |
Base URL for the Query API — no trailing slash |
JAEGER_USERNAME |
admin |
Only required if a reverse proxy enforces basic auth |
JAEGER_PASSWORD |
s3cr3t |
Paired with JAEGER_USERNAME |
Set timeout: 30_000 (30 seconds). Trace searches over large time windows against slow storage backends (especially Elasticsearch) can take several seconds to return. The default axios timeout of unlimited will cause hanging requests — always set an explicit timeout and surface it as a clear error to the agent.
Critical gotcha: timestamps are in microseconds
This is the single most common mistake when building Jaeger MCP tools. Jaeger's start and end query parameters for trace search are in Unix microseconds — one millionth of a second. JavaScript's Date.now() returns Unix milliseconds. If you pass milliseconds directly to Jaeger, you get a time range that appears to be in the year 1970, and Jaeger returns zero results without any error.
// WRONG — passes milliseconds, Jaeger returns 0 results silently:
const start = Date.now() - 60 * 60 * 1000; // 1 hour ago in ms
const end = Date.now(); // now in ms
// CORRECT — multiply by 1000 to convert ms → microseconds:
const nowUs = Date.now() * 1000; // now in microseconds
const startUs = Date.now() * 1000 - 60 * 60 * 1_000_000; // 1 hour ago in microseconds
// General pattern for any duration:
const hoursAgoUs = (hours: number) => Date.now() * 1000 - hours * 60 * 60 * 1_000_000;
const daysAgoUs = (days: number) => Date.now() * 1000 - days * 24 * 60 * 60 * 1_000_000;
Build a small helper module that always returns microseconds and use it throughout your MCP server — never compute raw timestamps inline in tool handlers:
// jaeger-time.ts — always work in microseconds
export const nowUs = () => Date.now() * 1000;
export const hoursAgoUs = (h: number) => nowUs() - h * 3_600_000_000;
export const minutesAgoUs = (m: number) => nowUs() - m * 60_000_000;
// Usage in tools:
import { hoursAgoUs, nowUs } from './jaeger-time.js';
const params = {
service: 'order-service',
start: hoursAgoUs(1), // 1 hour ago — in microseconds
end: nowUs(), // now — in microseconds
limit: 20
};
list_services — discover available service names
GET /api/services returns all service names that have reported at least one span to Jaeger. Call this tool first when an agent needs to discover what services exist before searching traces — the service name is a required parameter for most other queries. There is no pagination — the endpoint returns all services in a single response.
server.tool('list_services', {
// No parameters — returns all services unconditionally
}, async () => {
const response = await jaeger.get<{
data: string[];
errors: null | Array<{ code: number; msg: string }>;
}>('/api/services');
if (response.data.errors) {
throw new Error(`Jaeger error: ${JSON.stringify(response.data.errors)}`);
}
const services = response.data.data ?? [];
return {
content: [{
type: 'text',
text: JSON.stringify({
count: services.length,
services: services.sort() // alphabetical for readability
}, null, 2)
}]
};
});
The response shape is simply { "data": ["service-a", "service-b", "frontend", "payment-service", ...] }. Services appear here after their first span is written to storage — newly deployed services may not appear immediately if the storage backend has write latency.
list_operations — discover operations for a service
GET /api/operations?service=:service&spanKind=:spanKind returns all operations (span names) reported by a specific service, optionally filtered by span kind. Call this after list_services to discover what operations exist before searching traces — passing a misspelled operation name to search_traces returns zero results without an error.
server.tool('list_operations', {
service: z.string().describe('Service name from list_services output.'),
span_kind: z.enum(['client', 'server', 'producer', 'consumer', 'internal'])
.optional()
.describe('Filter by span kind. Omit to return all kinds.')
}, async ({ service, span_kind }) => {
const params: Record<string, string> = { service };
if (span_kind) params.spanKind = span_kind;
const response = await jaeger.get<{
data: Array<{ name: string; spanKind: string }>;
errors: null | Array<{ code: number; msg: string }>;
}>('/api/operations', { params });
if (response.data.errors) {
throw new Error(`Jaeger error: ${JSON.stringify(response.data.errors)}`);
}
const operations = response.data.data ?? [];
return {
content: [{
type: 'text',
text: JSON.stringify({
service,
count: operations.length,
operations: operations.sort((a, b) => a.name.localeCompare(b.name))
}, null, 2)
}]
};
});
| Span kind value | Meaning |
|---|---|
server |
Incoming RPC or HTTP request handler |
client |
Outgoing call to another service or external API |
producer |
Sent a message to a queue or topic |
consumer |
Received a message from a queue or topic |
internal |
Internal operation with no remote call |
A typical response looks like: { "data": [{ "name": "GET /api/users", "spanKind": "server" }, { "name": "POST /api/orders", "spanKind": "server" }, ...] }. The operation name is whatever the instrumentation library set on the span — for HTTP frameworks this is usually the route pattern, not the full URL.
search_traces — find traces by service, operation, duration, and tags
GET /api/traces is the main query endpoint. It accepts a rich set of filter parameters and returns matching traces with their full span trees. Remember: start and end must be in microseconds.
import { hoursAgoUs, nowUs } from './jaeger-time.js';
server.tool('search_traces', {
service: z.string().describe('Service name (required). Use list_services to discover names.'),
operation: z.string().optional().describe('Operation name filter. Use list_operations to discover.'),
limit: z.number().int().min(1).max(1500).default(20)
.describe('Maximum number of traces to return. Default 20, max 1500.'),
lookback_hours: z.number().default(1)
.describe('How many hours back to search. Default 1.'),
min_duration: z.string().optional()
.describe('Minimum trace duration filter, e.g. "500ms", "1s", "100ms".'),
max_duration: z.string().optional()
.describe('Maximum trace duration filter, e.g. "5s", "10s".'),
tags: z.string().optional()
.describe('Tag filter as key=value pairs, space-separated. e.g. "error=true http.status_code=500".')
}, async ({ service, operation, limit, lookback_hours, min_duration, max_duration, tags }) => {
const params: Record<string, string | number> = {
service,
limit,
start: hoursAgoUs(lookback_hours),
end: nowUs(),
};
if (operation) params.operation = operation;
if (min_duration) params.minDuration = min_duration;
if (max_duration) params.maxDuration = max_duration;
// Tags are URL-encoded key=value pairs, space-separated:
// "error=true http.status_code=500" → tags=error%3Dtrue+http.status_code%3D500
if (tags) params.tags = tags;
const response = await jaeger.get<{
data: JaegerTrace[];
errors: null | Array<{ code: number; msg: string }>;
}>('/api/traces', { params });
if (response.data.errors) {
throw new Error(`Jaeger error: ${JSON.stringify(response.data.errors)}`);
}
const traces = response.data.data ?? [];
// Summarise — full span trees can be very large for complex traces
const summary = traces.map(t => ({
traceID: t.traceID,
spans: t.spans.length,
services: Object.keys(t.processes).length,
duration: Math.max(...t.spans.map(s => s.startTime + s.duration)) -
Math.min(...t.spans.map(s => s.startTime)),
rootSpan: t.spans.find(s => s.references.length === 0)?.operationName ?? 'unknown',
startTime: new Date(t.spans[0].startTime / 1000).toISOString(), // us → ms → Date
hasErrors: t.spans.some(s => s.tags.some(tag => tag.key === 'error' && tag.value === true))
}));
return {
content: [{
type: 'text',
text: JSON.stringify({ count: traces.length, traces: summary }, null, 2)
}]
};
});
| Parameter | Type | Notes |
|---|---|---|
service |
string | Required. Must exactly match a value from list_services. |
operation |
string | Optional. Narrows results to a single operation within the service. |
limit |
integer | Default 20. Max 1500. Jaeger returns the most recent N matching traces. |
start |
integer (microseconds) | Unix microseconds. Must multiply Date.now() by 1000. |
end |
integer (microseconds) | Unix microseconds. Defaults to now if omitted. |
minDuration |
duration string | "1ms", "500ms", "1s", "2.5s" |
maxDuration |
duration string | Same format as minDuration. |
tags |
string | Space-separated key=value pairs. error=true http.status_code=500 |
The full trace response includes every span in the trace tree plus a processes map that associates each span's processID to the service name and its resource tags. For complex traces spanning dozens of services, the raw JSON can be several megabytes — always summarize in the MCP tool response rather than returning the raw payload to the agent.
get_trace — fetch a specific trace by ID
GET /api/traces/:traceID fetches a single trace by its 16- or 32-character hexadecimal trace ID. Trace IDs arrive from log correlation (traceId fields emitted by structured loggers), from distributed tracing propagation headers (traceparent in W3C TraceContext, X-B3-TraceId in Zipkin/B3), or from search_traces results.
server.tool('get_trace', {
trace_id: z.string()
.regex(/^[0-9a-f]{16}([0-9a-f]{16})?$/i,
'Trace ID must be 16 or 32 lowercase hex characters.')
.describe('Trace ID from a log entry, traceparent header, or search_traces result.')
}, async ({ trace_id }) => {
// Normalise to lowercase — Jaeger is case-sensitive on some backends
const id = trace_id.toLowerCase();
const response = await jaeger.get<{
data: JaegerTrace[];
errors: null | Array<{ code: number; msg: string }>;
}>(`/api/traces/${id}`);
if (response.data.errors) {
throw new Error(`Jaeger error: ${JSON.stringify(response.data.errors)}`);
}
if (!response.data.data?.length) {
throw new Error(`Trace ${id} not found. Check the ID format and retention window.`);
}
const trace = response.data.data[0];
const spans = trace.spans.map(s => ({
spanID: s.spanID,
operationName: s.operationName,
service: trace.processes[s.processID]?.serviceName ?? 'unknown',
startTime: new Date(s.startTime / 1000).toISOString(),
durationMs: s.duration / 1000,
tags: Object.fromEntries(s.tags.map(t => [t.key, t.value])),
logs: s.logs.map(l => ({
timestamp: new Date(l.timestamp / 1000).toISOString(),
fields: Object.fromEntries(l.fields.map(f => [f.key, f.value]))
})),
parentSpanID: s.references.find(r => r.refType === 'CHILD_OF')?.spanID ?? null
}));
// Sort spans by start time for readability
spans.sort((a, b) => a.startTime.localeCompare(b.startTime));
return {
content: [{
type: 'text',
text: JSON.stringify({
traceID: trace.traceID,
totalSpans: spans.length,
services: [...new Set(spans.map(s => s.service))],
spans
}, null, 2)
}]
};
});
| Format | Length | Example |
|---|---|---|
| W3C TraceContext | 32 hex chars | 4bf92f3577b34da6a3ce929d0e0e4736 |
| B3 (full) | 32 hex chars | 4bf92f3577b34da6a3ce929d0e0e4736 |
| B3 (short) | 16 hex chars | a3ce929d0e0e4736 |
Jaeger accepts both 16-character and 32-character trace IDs. When a traceparent header arrives in a log line, it takes the form 00-{traceID}-{parentID}-{flags} — extract the 32-character segment after the first hyphen. Jaeger internally pads 16-character B3 IDs with leading zeros to 32 characters in its storage layer.
Finding slow and error traces — common query patterns
The most useful patterns for debugging combine service, operation, and either a duration threshold or an error tag. These three combinations cover the majority of production debugging workflows:
// Pattern 1: Find all slow traces in a service (over 500ms) in the last hour
server.tool('find_slow_traces', {
service: z.string(),
min_duration: z.string().default('500ms').describe('e.g. "200ms", "1s", "5s"'),
lookback_hours: z.number().default(1)
}, async ({ service, min_duration, lookback_hours }) => {
const response = await jaeger.get('/api/traces', {
params: {
service,
minDuration: min_duration,
start: hoursAgoUs(lookback_hours),
end: nowUs(),
limit: 100
}
});
const traces = response.data.data ?? [];
// ... summarise and return
});
// Pattern 2: Find error traces for a specific operation
server.tool('find_error_traces', {
service: z.string(),
operation: z.string().optional(),
lookback_hours: z.number().default(1)
}, async ({ service, operation, lookback_hours }) => {
const params: Record<string, string | number> = {
service,
// The error=true tag is set by OpenTelemetry and Jaeger native instrumentation
tags: 'error=true',
start: hoursAgoUs(lookback_hours),
end: nowUs(),
limit: 50
};
if (operation) params.operation = operation;
const response = await jaeger.get('/api/traces', { params });
const traces = response.data.data ?? [];
// ... summarise and return
});
// Pattern 3: Combined — slow error traces for a specific operation (most precise)
// Useful when you know exactly which endpoint is misbehaving
const params = {
service: 'payment-service',
operation: 'POST /api/charge',
tags: 'error=true',
minDuration: '1s',
start: hoursAgoUs(3),
end: nowUs(),
limit: 20
};
When searching by tags, multiple key=value pairs are space-separated and Jaeger applies them as AND conditions — all tags must be present on at least one span in the trace. Tags are matched against span tags, not process-level resource attributes.
Wiring /health/jaeger via the services endpoint
The best health check for Jaeger is GET /api/services, not GET / (the UI HTML) and not a process-level ping. The /api/services endpoint exercises the full query stack: the Query API process must be alive, and the storage backend (Elasticsearch, Cassandra, or BadgerDB) must be reachable and responding. A successful response with at least an empty list means the entire read path is healthy.
app.get('/health/jaeger', async (req, res) => {
try {
const start = Date.now();
const response = await jaeger.get<{
data: string[];
errors: null | Array<{ code: number; msg: string }>;
}>('/api/services');
const latencyMs = Date.now() - start;
if (response.data.errors?.length) {
return res.status(503).json({
status: 'unhealthy',
error: `Jaeger storage error: ${response.data.errors[0].msg}`,
latency_ms: latencyMs
});
}
return res.json({
status: 'healthy',
services: response.data.data?.length ?? 0,
latency_ms: latencyMs,
jaeger_url: process.env.JAEGER_URL ?? 'http://localhost:16686'
});
} catch (err: any) {
// axios errors: err.code = 'ECONNREFUSED' | 'ENOTFOUND' | 'ETIMEDOUT'
// HTTP errors: err.response.status = 401 | 503 | etc.
return res.status(503).json({
status: 'unhealthy',
error: err.message,
error_code: err.code,
http_status: err.response?.status
});
}
});
| Health check endpoint | What it verifies | Recommended? |
|---|---|---|
GET /api/services |
Query API process + storage backend | Yes — use this |
GET / |
Query API process is alive (returns UI HTML) | No — does not verify storage |
GET /api/traces?service=x |
Full query path including trace search | Optional — adds latency to health probe |
If Jaeger's storage backend (Elasticsearch, Cassandra) becomes unavailable, GET / still returns 200 — the UI process is alive but no queries will work. Only GET /api/services or any data-returning endpoint will surface storage failures. Monitor the services count over time too: a sudden drop to zero services can indicate that spans are no longer being ingested, even if the Query API itself is healthy.
Frequently asked questions
Why are Jaeger timestamps in microseconds instead of milliseconds?
Jaeger inherits its timestamp precision from the OpenZipkin B3 tracing specification and the underlying Thrift data model, both of which were designed for high-throughput distributed tracing where millisecond resolution is too coarse. Span durations of sub-millisecond internal operations (memory lookups, serialization) are meaningful in performance profiling — microsecond resolution captures them. When Jaeger adopted the OpenTelemetry data model (which also uses nanoseconds internally), it kept microseconds at the API layer for backwards compatibility with existing Jaeger clients. The result is a consistent but easily missed requirement: every timestamp in the Query API — start, end, span startTime, and log timestamp — is Unix microseconds. Multiply Date.now() by 1000, always.
What's the difference between traceID and spanID in Jaeger?
A trace ID identifies the entire distributed transaction — a single logical operation that may have crossed many services and produced many spans. It is shared by every span in the trace and is propagated in request headers (traceparent or X-B3-TraceId). A span ID identifies one unit of work within a trace — a single function call, RPC, or database query. Each span has its own unique span ID and refers to its parent span via a CHILD_OF reference to the parent's span ID. When you look up a trace via GET /api/traces/:traceID you get all spans; if you want a specific span within a trace you filter client-side by spanID in the response. Span IDs are 16 hex characters; trace IDs are 16 or 32 hex characters depending on which propagation format the instrumentation used.
How do I correlate a Jaeger trace ID with a log entry?
Modern structured logging libraries (Pino, Winston with OpenTelemetry integration) automatically inject the current trace ID and span ID into every log record while a span is active. The fields are typically named traceId and spanId. In a log aggregation system (Loki, Elasticsearch, CloudWatch), filter logs by a known trace ID to find all log lines emitted during that trace. In the reverse direction — starting from a log line — extract the traceId field value and pass it to get_trace. If your logs contain W3C traceparent headers verbatim (from request logging middleware), the trace ID is the 32-character second segment: 00-4bf92f3577b34da6a3ce929d0e0e4736-a3ce929d0e0e4736-01. Strip the surrounding segments before passing to Jaeger.
Does the Jaeger MCP server work with OpenTelemetry traces or only Jaeger-native traces?
Jaeger fully supports OpenTelemetry traces. Since Jaeger 1.35, the recommended ingestion path is via the OpenTelemetry Collector exporting to Jaeger's OTLP receiver on port 4317 (gRPC) or 4318 (HTTP). Jaeger stores OTLP spans in the same storage backend as Jaeger-native spans, and the Query HTTP API at port 16686 serves them identically — your MCP tools do not need to distinguish between trace origins. The main difference you will observe is that OpenTelemetry instrumentation tends to use the W3C TraceContext 32-character trace ID format, while legacy Jaeger-native clients may emit 16-character B3 trace IDs. Both formats are accepted by GET /api/traces/:traceID. Tag names may also differ: OTLP uses http.request.method (semantic conventions 1.x) while older Jaeger clients used http.method — factor this in when building tag-based search filters.
Further reading
- MCP Server Prometheus — PromQL instant and range queries
- MCP Server Grafana — list dashboards, create annotations, query datasources
- MCP Server Datadog — query metrics, list monitors, post events
- MCP Server Health Check — wiring /health endpoints for uptime monitoring
- Building MCP Tools for DevOps Platforms