Guide · MCP Prometheus Integration
MCP Server Prometheus — PromQL instant and range queries, target listing, alert rules, and /-/ready health check
Prometheus is the standard time-series database for infrastructure and application metrics in Kubernetes and cloud-native stacks. This guide covers building TypeScript MCP tools for the Prometheus HTTP API: configuring the base URL for environments with and without reverse-proxy authentication, running instant and range PromQL queries (including the critical seconds-vs-milliseconds distinction), listing scrape targets to discover monitored services, reading firing alerts from alert rules, and wiring a /health/prometheus endpoint via /-/ready so AliveMCP can alert you when Prometheus stops evaluating your alerting rules.
TL;DR
Prometheus has no built-in authentication — protect it with a reverse proxy (nginx basic auth, OAuth2-proxy). The base URL is configurable, typically http://prometheus:9090. All query endpoints use GET (or POST for very long PromQL queries to avoid URL length limits). Prometheus timestamps are Unix seconds — not milliseconds like JavaScript's Date.now(). Metric values in query results are strings, not numbers — always parseFloat(result.value[1]). Health: GET /-/ready returns HTTP 200 only when Prometheus has loaded data and can serve queries — prefer this over /-/healthy for MCP health probes.
SDK setup and authentication
Prometheus exposes a plain HTTP JSON API — no official npm client is needed. Use axios with a configurable base URL. Prometheus has no built-in authentication: in production it sits behind a reverse proxy that handles auth (nginx with auth_basic, Traefik with BasicAuth middleware, or OAuth2-proxy for SSO). Your MCP client passes credentials to the proxy, not to Prometheus directly.
import axios from 'axios';
const prom = axios.create({
baseURL: process.env.PROMETHEUS_URL ?? 'http://localhost:9090',
// If your Prometheus is behind basic auth reverse proxy:
...(process.env.PROMETHEUS_USERNAME && {
auth: {
username: process.env.PROMETHEUS_USERNAME,
password: process.env.PROMETHEUS_PASSWORD!,
}
}),
timeout: 30_000,
});
// All Prometheus API responses share the same envelope:
// { "status": "success" | "error", "data": ..., "errorType"?: string, "error"?: string }
// On error (bad PromQL, etc.), HTTP status is still 2xx but status === "error"
| Environment variable | Example | Notes |
|---|---|---|
PROMETHEUS_URL |
http://prometheus:9090 |
Internal cluster address; no trailing slash |
PROMETHEUS_USERNAME |
admin |
Only set if reverse proxy requires basic auth |
PROMETHEUS_PASSWORD |
s3cr3t |
Only set if reverse proxy requires basic auth |
Prometheus is pull-based: it scrapes metrics from target endpoints on a configured interval — it does not receive pushed metrics. The --web.enable-admin-api flag must be passed at startup to enable destructive admin endpoints like /api/v1/admin/tsdb/delete_series. This flag is not needed for any read-only query or target listing operations covered in this guide.
Instant queries with query_instant
An instant query evaluates a PromQL expression at a single point in time and returns a vector — one value per time series that matches the selector. The endpoint is GET /api/v1/query. The time parameter is a Unix timestamp in seconds — a critical difference from JavaScript's Date.now() which returns milliseconds. Omitting time defaults to the current server time.
Gotcha: timestamps are in seconds, not milliseconds. Date.now() in JavaScript gives you milliseconds. Always divide by 1000: Math.floor(Date.now() / 1000). Passing a millisecond timestamp as the time parameter will silently query the far future and return no data.
server.tool('query_instant', {
query: z.string().describe(
'PromQL expression. Examples: "up", "rate(http_requests_total[5m])", ' +
'"histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))"'
),
time: z.number().optional().describe(
'Unix timestamp in SECONDS (not milliseconds). Omit for current time. ' +
'To convert from JS: Math.floor(Date.now() / 1000)'
)
}, async ({ query, time }) => {
const params: Record<string, string> = { query };
// IMPORTANT: Prometheus uses Unix seconds, not JavaScript milliseconds.
// If time is omitted, Prometheus uses the current server time.
if (time !== undefined) {
params.time = String(time); // seconds since epoch
}
const { data: envelope } = await prom.get('/api/v1/query', { params });
if (envelope.status === 'error') {
throw new Error(`PromQL error [${envelope.errorType}]: ${envelope.error}`);
}
// resultType is "vector" for instant queries
// result is an array — empty array means no series matched (not an error)
const results = envelope.data.result as Array<{
metric: Record<string, string>;
value: [number, string]; // [unix_seconds, "numeric_string"]
}>;
return {
content: [{
type: 'text',
text: JSON.stringify(
results.map(r => ({
labels: r.metric,
// value[0] is the timestamp (seconds), value[1] is the value as a STRING
// Always parseFloat — Prometheus always returns values as strings
timestamp: r.value[0],
value: parseFloat(r.value[1]),
// Handle special float strings Prometheus may return:
// "NaN", "+Inf", "-Inf" — parseFloat handles these correctly
})),
null, 2
)
}]
};
});
| PromQL example | What it queries |
|---|---|
up |
1 if target is being scraped successfully, 0 if down |
rate(http_requests_total[5m]) |
Per-second request rate averaged over the last 5 minutes |
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) |
99th percentile request latency over 5 minutes |
sum by (job) (rate(http_requests_total[1m])) |
Total request rate aggregated across all instances, grouped by job |
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes |
Fraction of available memory on each node |
The value[1] field is always a string — even for integer-valued metrics. Always call parseFloat(result.value[1]). Prometheus can also return the special strings "NaN", "+Inf", and "-Inf" — parseFloat handles these correctly in JavaScript, returning NaN, Infinity, and -Infinity.
Range queries with query_range
A range query evaluates a PromQL expression over a time window and returns a matrix — multiple (timestamp, value) pairs per matched series. Use this for graphing trends, computing rolling statistics, or feeding time-series data to an LLM for anomaly analysis. The endpoint is GET /api/v1/query_range.
Gotcha again: all timestamps are Unix seconds, not milliseconds. The start and end parameters are both Unix timestamps in seconds. The step is a duration string like "15s", "1m", "5m", "1h" — or a plain number of seconds as a string like "300".
server.tool('query_range', {
query: z.string().describe('PromQL expression to evaluate over the time range.'),
start: z.number().describe(
'Start of range as Unix timestamp in SECONDS (not milliseconds). ' +
'Example for 24h ago: Math.floor(Date.now() / 1000) - 86400'
),
end: z.number().describe(
'End of range as Unix timestamp in SECONDS. ' +
'Example for now: Math.floor(Date.now() / 1000)'
),
step: z.string().default('5m').describe(
'Query resolution step. Duration string: "15s", "1m", "5m", "1h". ' +
'Or plain seconds as string: "300". ' +
'Max data points = (end - start) / step_seconds — keep under 11,000 to avoid timeouts.'
)
}, async ({ query, start, end, step }) => {
// Sanity check: step in seconds for data point count estimation
const stepSeconds = parseDurationToSeconds(step);
const estimatedPoints = (end - start) / stepSeconds;
if (estimatedPoints > 11_000) {
throw new Error(
`Query would return ~${Math.round(estimatedPoints)} data points per series. ` +
`Increase step size or reduce time window to stay under 11,000 points.`
);
}
const { data: envelope } = await prom.get('/api/v1/query_range', {
params: { query, start: String(start), end: String(end), step }
});
if (envelope.status === 'error') {
throw new Error(`PromQL error [${envelope.errorType}]: ${envelope.error}`);
}
// resultType is "matrix" for range queries
const results = envelope.data.result as Array<{
metric: Record<string, string>;
values: Array<[number, string]>; // [[unix_seconds, "value_string"], ...]
}>;
return {
content: [{
type: 'text',
text: JSON.stringify(
results.map(r => ({
labels: r.metric,
// Each entry: [unix_seconds_timestamp, numeric_value]
// value[1] is always a string — parseFloat every entry
values: r.values.map(([ts, val]) => ({
timestamp: ts,
value: parseFloat(val),
})),
})),
null, 2
)
}]
};
});
// Helper to parse Prometheus duration strings to seconds
function parseDurationToSeconds(step: string): number {
const match = step.match(/^(\d+)(ms|s|m|h|d|w|y)?$/);
if (!match) return parseFloat(step); // plain number = seconds
const n = parseInt(match[1], 10);
const unit = match[2] ?? 's';
const multipliers: Record<string, number> = {
ms: 0.001, s: 1, m: 60, h: 3600, d: 86400, w: 604800, y: 31536000
};
return n * (multipliers[unit] ?? 1);
}
| Time window | Step | Data points per series | Recommended use |
|---|---|---|---|
| 1 hour | 15s |
240 | Fine-grained recent debugging |
| 6 hours | 1m |
360 | Incident timeline review |
| 24 hours | 5m |
288 | Daily trend overview |
| 7 days | 1h |
168 | Weekly capacity planning |
| 30 days | 6h |
120 | Monthly reporting |
| 90 days | 1d |
90 | Quarterly trend analysis |
The 11,000-point limit is a practical guideline based on Prometheus's default --query.max-samples setting and the response size that starts causing timeouts in typical deployments. If you consistently hit this limit, consider recording rules in Prometheus to pre-aggregate high-cardinality metrics at coarser resolutions.
Handling resultType: vector vs matrix vs scalar
Prometheus query results carry a resultType field that determines the shape of result. Instant queries (/api/v1/query) return vector or scalar; range queries (/api/v1/query_range) always return matrix. Some PromQL expressions like scalar(up{job="node"}) or plain numbers return scalar or string types.
| resultType | Endpoint | Shape of result |
Access pattern |
|---|---|---|---|
vector |
/api/v1/query |
[{ metric: {}, value: [ts, "val"] }] |
parseFloat(r.value[1]) |
matrix |
/api/v1/query_range |
[{ metric: {}, values: [[ts, "val"], ...] }] |
r.values.map(([ts, v]) => parseFloat(v)) |
scalar |
/api/v1/query |
[ts, "val"] (not an array of objects) |
parseFloat(envelope.data.result[1]) |
string |
/api/v1/query |
[ts, "text_value"] |
envelope.data.result[1] (string as-is) |
In all cases, numeric values are returned as strings. The rule is consistent: always parseFloat any value field regardless of resultType. If you need to handle multiple resultTypes in one tool, branch on envelope.data.resultType before accessing result.
Listing scrape targets with list_targets
The /api/v1/targets endpoint returns all Prometheus scrape targets and their current health status. This is how you discover every monitored service without reading Prometheus configuration files. The health field tells you if each target is being scraped successfully, and lastError explains why it failed when health is "down".
server.tool('list_targets', {
state: z.enum(['active', 'dropped', 'any']).default('active').describe(
'"active" = currently being scraped. "dropped" = matched relabeling but then dropped. ' +
'"any" = return both active and dropped targets.'
)
}, async ({ state }) => {
const params = state !== 'any' ? { state } : {};
const { data: envelope } = await prom.get('/api/v1/targets', { params });
if (envelope.status === 'error') {
throw new Error(`Prometheus error: ${envelope.error}`);
}
const active = (envelope.data.activeTargets ?? []) as Array<{
labels: Record<string, string>;
scrapeUrl: string;
health: 'up' | 'down' | 'unknown';
lastError: string;
lastScrape: string; // ISO 8601 timestamp
scrapeInterval: string; // e.g. "15s"
scrapeDuration: string; // e.g. "0.005"
}>;
const dropped = (envelope.data.droppedTargets ?? []) as Array<{
discoveredLabels: Record<string, string>;
}>;
// Targets with health === "down" have a populated lastError field
// explaining why the scrape failed (connection refused, timeout, etc.)
const unhealthy = active.filter(t => t.health === 'down');
return {
content: [{
type: 'text',
text: JSON.stringify({
summary: {
total_active: active.length,
healthy: active.filter(t => t.health === 'up').length,
unhealthy: unhealthy.length,
dropped: dropped.length,
},
unhealthy_targets: unhealthy.map(t => ({
job: t.labels.job,
instance: t.labels.instance,
scrape_url: t.scrapeUrl,
last_error: t.lastError,
last_scrape: t.lastScrape,
})),
all_active: active.map(t => ({
labels: t.labels,
health: t.health,
scrape_url: t.scrapeUrl,
last_error: t.lastError || null,
last_scrape: t.lastScrape,
scrape_interval: t.scrapeInterval,
})),
}, null, 2)
}]
};
});
The health: "down" combined with lastError is the fastest way to diagnose which services have stopped reporting metrics. Common error messages include "context deadline exceeded" (scrape timeout), "connection refused" (service crashed), and "x509: certificate has expired" (TLS issues on the target's metrics endpoint).
Listing firing alerts with list_alerts
Prometheus evaluates alert rules on a configurable interval and tracks which alerts are currently firing. The /api/v1/alerts endpoint returns all alerts in any state. Separately, /api/v1/rules returns the full list of configured alert rules with their current evaluation state. Note that Alertmanager (port 9093) is a separate service that handles routing, deduplication, and silencing — the Prometheus API only shows you whether rules are evaluating, not whether notifications were sent.
server.tool('list_alerts', {
state: z.enum(['firing', 'pending', 'inactive', 'all']).default('firing').describe(
'"firing" = threshold exceeded and alert is active. ' +
'"pending" = threshold exceeded but within the "for" duration grace period. ' +
'"inactive" = rule exists but condition is not met.'
)
}, async ({ state }) => {
const { data: envelope } = await prom.get('/api/v1/alerts');
if (envelope.status === 'error') {
throw new Error(`Prometheus error: ${envelope.error}`);
}
const alerts = envelope.data.alerts as Array<{
labels: Record<string, string>; // includes alertname, severity, etc.
annotations: Record<string, string>; // summary, description, runbook_url
state: 'firing' | 'pending' | 'inactive';
activeAt: string; // ISO 8601 — when the alert entered its current state
value: string; // the metric value that triggered the alert (as string)
}>;
const filtered = state === 'all'
? alerts
: alerts.filter(a => a.state === state);
return {
content: [{
type: 'text',
text: JSON.stringify(
filtered.map(a => ({
name: a.labels.alertname,
severity: a.labels.severity,
state: a.state,
active_since: a.activeAt,
value: parseFloat(a.value), // also a string — parseFloat applies here too
labels: a.labels,
summary: a.annotations.summary ?? null,
description: a.annotations.description ?? null,
runbook: a.annotations.runbook_url ?? null,
})),
null, 2
)
}]
};
});
// Separate tool for listing configured rules (not just currently active alerts)
server.tool('list_rules', {
type: z.enum(['alert', 'record', 'all']).default('alert')
}, async ({ type }) => {
const params = type !== 'all' ? { type } : {};
const { data: envelope } = await prom.get('/api/v1/rules', { params });
if (envelope.status === 'error') {
throw new Error(`Prometheus error: ${envelope.error}`);
}
// Rules are organized into groups from prometheus.yml rule_files
const groups = envelope.data.groups as Array<{
name: string;
file: string;
interval: number;
rules: Array<{
name: string;
type: 'alerting' | 'recording';
query: string;
duration?: number; // "for" duration in seconds (alert rules only)
health: 'ok' | 'err' | 'unknown';
lastError?: string;
lastEvaluation: string;
}>;
}>;
return {
content: [{
type: 'text',
text: JSON.stringify(
groups.flatMap(g =>
g.rules.map(r => ({
group: g.name,
name: r.name,
type: r.type,
query: r.query,
health: r.health,
last_error: r.lastError ?? null,
last_evaluation: r.lastEvaluation,
}))
),
null, 2
)
}]
};
});
Wiring /health/prometheus via /-/ready
Prometheus exposes two health endpoints: GET /-/healthy and GET /-/ready. They are not interchangeable for MCP health probes.
/-/healthy returns HTTP 200 with the plain-text body "Prometheus Server is Healthy." as soon as the process is running — even before it has loaded any data. A freshly restarted Prometheus that has not yet replayed its TSDB blocks will pass /-/healthy while failing every query with "no data".
/-/ready returns HTTP 200 only when Prometheus has loaded at least one TSDB block and is ready to serve queries. Use /-/ready in your MCP health probe so AliveMCP catches the window where Prometheus is alive but unable to evaluate your alerting rules.
app.get('/health/prometheus', async (req, res) => {
try {
// /-/ready confirms Prometheus has loaded TSDB data and can serve queries.
// /-/healthy only confirms the process is alive — insufficient for query health.
const response = await prom.get('/-/ready');
// /-/ready returns 200 with plain text "Prometheus Server is Ready."
// Any non-200 status throws in axios (default validateStatus behavior).
return res.json({
status: 'healthy',
prometheus_url: process.env.PROMETHEUS_URL ?? 'http://localhost:9090',
ready_response: response.data,
});
} catch (err: any) {
// /-/ready returns 503 when Prometheus is starting up and hasn't loaded data.
// This is the key case to detect: Prometheus runs but can't evaluate alert rules.
const httpStatus = err.response?.status;
const body = err.response?.data;
return res.status(503).json({
status: 'unhealthy',
http_status: httpStatus ?? null,
error: err.message,
detail: body ?? null,
});
}
});
| Endpoint | HTTP 200 when | HTTP 503 when | Use for MCP health? |
|---|---|---|---|
/-/healthy |
Process is running | Process has crashed | No — too permissive |
/-/ready |
Has loaded TSDB data; queries work | Starting up or TSDB unavailable | Yes — catches silent failures |
For the most thorough health check, follow the /-/ready call with a lightweight instant query — query=up&time=<now> — to confirm the query engine is actually evaluating PromQL. A Prometheus that passes /-/ready but has corrupted TSDB blocks might still fail queries.
Frequently asked questions
What's the difference between instant queries and range queries in Prometheus?
An instant query (GET /api/v1/query) evaluates your PromQL expression at a single point in time and returns one value per matching time series — the resultType is "vector". A range query (GET /api/v1/query_range) evaluates the expression repeatedly across a time window at a fixed step interval and returns a list of (timestamp, value) pairs per series — the resultType is "matrix". Use instant queries when you want the current value of a metric (e.g., current CPU usage). Use range queries when you want to graph or analyze how a metric changed over time (e.g., request rate over the last 24 hours). The PromQL expression syntax is identical for both endpoints — the difference is only in what you're asking Prometheus to evaluate against.
Why are Prometheus timestamps in seconds but JavaScript uses milliseconds?
Prometheus was written in Go, which uses time.Unix() — Unix epoch seconds — as its native time representation. The Prometheus data model and API were designed before sub-second precision was commonly needed for infrastructure metrics (most scrape intervals are 10–60 seconds). JavaScript's Date.now() returns milliseconds because browser animations and UI interactions require millisecond precision. This mismatch is one of the most common bugs when building Prometheus MCP tools: passing Date.now() directly as the time parameter sends a timestamp ~1000x larger than expected, querying a point far in the future where Prometheus has no data. Always convert with Math.floor(Date.now() / 1000) for current time, or Math.floor(date.getTime() / 1000) for a specific Date object. RFC3339 strings like "2024-01-15T12:00:00Z" are also accepted and avoid the unit confusion entirely.
How do I write PromQL to query metrics across all instances of a service?
Prometheus labels let you aggregate across instances using the sum, avg, min, and max aggregation operators with a by or without clause. To get the total request rate across all instances of a service: sum by (job) (rate(http_requests_total{job="my-service"}[5m])). To get the average CPU across all nodes: avg(rate(process_cpu_seconds_total[1m])). To select only specific instances, filter with label matchers: up{job="node", env="production"}. The {job="my-service"} selector filters by the job label Prometheus assigns based on the scrape configuration. If you want metrics from all jobs, omit the label filter entirely: rate(http_requests_total[5m]) returns data from every job that has that metric.
How do I handle the case where Prometheus returns resultType "matrix" vs "vector" vs "scalar"?
Branch on envelope.data.resultType before accessing the result. For "vector" (instant query results), result is an array of { metric, value: [ts, "val"] } objects — access the value as parseFloat(r.value[1]). For "matrix" (range query results), result is an array of { metric, values: [[ts, "val"], ...] } objects — map over r.values and parseFloat each entry. For "scalar", result is a single [ts, "val"] tuple directly (not wrapped in an array of objects) — access it as parseFloat(envelope.data.result[1]). For "string", same shape as scalar but the value is a text string, not numeric. In all four cases, numeric values are returned as strings and require parseFloat. The safest pattern is to always check resultType first and surface a clear error in your MCP tool if the type doesn't match what you expect for the given PromQL expression.