Guide · MCP Grafana Integration
MCP Server Grafana — list dashboards, create annotations, query datasources, and /health via API health endpoint
Grafana is the standard observability frontend for Prometheus, Loki, Elasticsearch, and dozens of other datasources — and its HTTP API gives MCP tools direct access to dashboards, annotations, datasource health, and live query execution. This guide covers building TypeScript MCP tools for the Grafana HTTP API: authenticating with Service Account Bearer tokens (the preferred approach since Grafana 10 deprecated legacy API keys), listing and retrieving dashboards by stable UID, creating deployment and incident annotations that appear across all dashboard viewers, proxying queries through /api/ds/query without direct datasource access, and wiring a /health/grafana endpoint that checks /api/health so AliveMCP can catch Grafana outages before your annotation pipeline goes silent.
TL;DR
Grafana's HTTP API authenticates via Authorization: Bearer <service-account-token>. Service Account tokens replaced legacy API keys in Grafana 10+ (API keys are deprecated). The base URL is configurable — typically http://grafana:3000 for self-hosted or your Grafana Cloud org URL. Use dashboard UIDs (not numeric IDs) in all tool inputs — UIDs survive backup/restore cycles while numeric IDs can change. GET /api/search?type=dash-db lists all dashboards. POST /api/annotations marks deployments and incidents. POST /api/ds/query proxies Prometheus or Loki queries. Health: GET /api/health returns { "database": "ok" } with HTTP 200 when Grafana is ready, 503 when starting or degraded.
SDK setup and authentication
Grafana's HTTP API is a straightforward REST API — no dedicated npm client is needed. axios with a configured instance covers all endpoints. The key decision is which credential type to use: Service Account tokens (preferred), legacy API keys (deprecated in Grafana 10+), or HTTP Basic Auth (admin/password — acceptable for local development, not for production).
import axios from 'axios';
const grafana = axios.create({
baseURL: process.env.GRAFANA_URL!, // e.g., 'https://grafana.example.com'
headers: {
Authorization: `Bearer ${process.env.GRAFANA_SERVICE_TOKEN!}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
// If you need Basic Auth for local dev only:
// Authorization: Basic base64(admin:password)
// import { Buffer } from 'buffer';
// const token = Buffer.from('admin:password').toString('base64');
// headers: { Authorization: `Basic ${token}` }
// For Grafana Cloud orgs the base URL is:
// https://.grafana.net
// with a Cloud API key (not a Service Account token from a self-hosted instance)
Service Account tokens carry a role that determines what the API can do. Assign the minimum role needed for your MCP tool's operations.
| Auth method | Header format | Recommended for |
|---|---|---|
| Service Account token | Authorization: Bearer glsa_xxx |
All production use — preferred since Grafana 10 |
| Legacy API key | Authorization: Bearer eyJ... |
Legacy setups; deprecated in Grafana 10+, removed in future |
| Basic Auth | Authorization: Basic base64(user:pass) |
Local dev only — never use admin credentials in production |
| Grafana Cloud API key | Authorization: Bearer glc_xxx |
Grafana Cloud orgs only — different base URL required |
| Service Account role | Allowed operations |
|---|---|
| Viewer | List/get dashboards, list datasources, query datasources, read annotations |
| Editor | Everything in Viewer, plus create/update annotations, create/update dashboards |
| Admin | Full API access including datasource creation, user management, org settings |
Create a Service Account in Grafana under Administration → Service Accounts. Generate a token from the service account detail page. Store the token in an environment variable — it is shown only once on creation.
Listing and retrieving dashboards by UID
GET /api/search returns matching dashboards and folders from Grafana's internal index. Filter by type, tag, or folder to reduce payload size — large Grafana instances can have thousands of dashboards, and the full search response is lightweight (no panel JSON). Once you have a UID, fetch the full dashboard JSON with GET /api/dashboards/uid/:uid.
server.tool('list_dashboards', {
tag: z.string().optional().describe('Filter dashboards by tag, e.g. "production" or "team-infra".'),
folder_id: z.number().int().optional().describe('Numeric folder ID to filter by folder.'),
query: z.string().optional().describe('Search string to filter dashboard titles.'),
limit: z.number().int().min(1).max(5000).default(100)
}, async ({ tag, folder_id, query, limit }) => {
const params: Record<string, string | number> = {
type: 'dash-db', // 'dash-db' = dashboards only; 'dash-folder' = folders only; omit for both
limit
};
if (tag) params.tag = tag;
if (folder_id !== undefined) params.folderIds = folder_id;
if (query) params.query = query;
const { data } = await grafana.get('/api/search', { params });
return {
content: [{
type: 'text',
text: JSON.stringify(
data.map((d: any) => ({
uid: d.uid, // Use this for subsequent get_dashboard calls — stable across restores
id: d.id, // Numeric ID — avoid using as a stable reference
title: d.title,
url: d.url,
tags: d.tags,
folder_id: d.folderId,
folder_title: d.folderTitle,
type: d.type // 'dash-db' or 'dash-folder'
})),
null, 2
)
}]
};
});
server.tool('get_dashboard', {
uid: z.string().describe('Dashboard UID from list_dashboards. Stable across Grafana backup/restore.')
}, async ({ uid }) => {
// Response has two top-level keys:
// .dashboard — the full panel/template/link configuration JSON
// .meta — title, url, slug, version, created/updated timestamps
const { data } = await grafana.get(`/api/dashboards/uid/${uid}`);
return {
content: [{
type: 'text',
text: JSON.stringify({
meta: {
title: data.meta.title,
url: data.meta.url,
slug: data.meta.slug,
version: data.meta.version,
created: data.meta.created,
updated: data.meta.updated,
created_by: data.meta.createdBy,
updated_by: data.meta.updatedBy,
folder_title: data.meta.folderTitle
},
// dashboard.panels is the array of panel configs
// dashboard.templating.list is the variable definitions
// Omit full dashboard JSON by default — can be megabytes for complex dashboards
panel_count: data.dashboard?.panels?.length ?? 0,
tags: data.dashboard?.tags ?? [],
uid: data.dashboard?.uid,
schema_version: data.dashboard?.schemaVersion
}, null, 2)
}]
};
});
UIDs are the correct stable identifier for dashboards in Grafana. When a Grafana instance is backed up and restored, numeric IDs are reassigned by the database — UIDs are stored in the dashboard JSON and preserved. Any MCP tool that accepts a dashboard reference should always use UID, not the numeric id field.
| Search param | Value | Effect |
|---|---|---|
type |
dash-db |
Dashboards only (excludes folders) |
type |
dash-folder |
Folders only |
type |
(omit) | Dashboards and folders combined |
tag |
e.g. production |
Filter to dashboards with this tag |
folderIds |
numeric folder ID | Filter to dashboards in this folder |
query |
search string | Title substring match |
Creating deployment and incident annotations
Annotations mark points in time on Grafana dashboards — vertical lines that appear across all panels with a tooltip showing the annotation text. They are the standard way to correlate infrastructure events (deployments, incidents, config changes) with metric spikes. POST /api/annotations creates an annotation visible to all dashboard viewers, so add a confirm guard to prevent accidental annotation spam.
server.tool('create_annotation', {
text: z.string().describe('Annotation label shown in the dashboard tooltip, e.g. "Deployed v1.2.3".'),
tags: z.array(z.string()).default([]).describe('Tags for filtering annotations, e.g. ["deployment", "v1.2.3"].'),
time: z.number().int().optional().describe('Unix timestamp in milliseconds. Defaults to now if omitted.'),
time_end: z.number().int().optional().describe('Unix ms end time for range annotations (e.g. an incident window). Omit for point annotations.'),
dashboard_uid: z.string().optional().describe('Pin this annotation to a specific dashboard UID. Omit to show on all dashboards.'),
panel_id: z.number().int().optional().describe('Pin to a specific panel within the dashboard (requires dashboard_uid).'),
confirm: z.literal(true).describe('Annotations are visible to all dashboard viewers. Pass true to confirm creation.')
}, async ({ text, tags, time, time_end, dashboard_uid, panel_id }) => {
const body: Record<string, any> = {
text,
tags,
time: time ?? Date.now() // Grafana expects Unix milliseconds
};
// Range annotation: supply both time and timeEnd
if (time_end !== undefined) {
body.timeEnd = time_end;
}
// Scoped to a specific dashboard (and optionally a panel within it)
if (dashboard_uid) {
body.dashboardUID = dashboard_uid;
if (panel_id !== undefined) {
body.panelId = panel_id;
}
}
const { data } = await grafana.post('/api/annotations', body);
return {
content: [{
type: 'text',
text: JSON.stringify({
id: data.id,
message: data.message // 'Annotation added' on success
}, null, 2)
}]
};
});
| Field | Type | Required | Description |
|---|---|---|---|
time |
integer | Yes | Unix timestamp in milliseconds (Date.now()) |
text |
string | Yes | Tooltip label shown on the dashboard |
tags |
string[] | No | Searchable tags for filtering in the Annotations panel |
dashboardUID |
string | No | Scope to one dashboard; omit to show on all dashboards |
panelId |
integer | No | Scope to one panel within the dashboard |
timeEnd |
integer | No | Unix ms end time — creates a shaded range instead of a vertical line |
Listing datasources and testing datasource health
GET /api/datasources returns every configured datasource with its type, URL, and default status. This is useful for MCP tools that need to discover which datasources are available before querying them. The health check endpoint (GET /api/datasources/:id/health, available since Grafana 9) proxies a connectivity test through Grafana to the backend datasource — no direct datasource access needed.
server.tool('list_datasources', {
type_filter: z.string().optional().describe('Filter by datasource type, e.g. "prometheus", "loki", "elasticsearch".')
}, async ({ type_filter }) => {
const { data } = await grafana.get('/api/datasources');
const sources = type_filter
? data.filter((ds: any) => ds.type === type_filter)
: data;
return {
content: [{
type: 'text',
text: JSON.stringify(
sources.map((ds: any) => ({
id: ds.id,
uid: ds.uid,
name: ds.name,
type: ds.type, // 'prometheus', 'loki', 'elasticsearch', 'mysql', 'postgres', etc.
url: ds.url,
is_default: ds.isDefault,
access: ds.access // 'proxy' (Grafana proxies to backend) or 'direct' (browser to backend)
})),
null, 2
)
}]
};
});
server.tool('test_datasource', {
datasource_id: z.number().int().describe('Numeric datasource ID from list_datasources.')
}, async ({ datasource_id }) => {
// GET /api/datasources/:id/health — available in Grafana 9+
// Grafana proxies a connectivity check to the backend datasource
// Returns { "status": "OK", "message": "Data source connected and labels found" } on success
try {
const { data } = await grafana.get(`/api/datasources/${datasource_id}/health`);
return {
content: [{
type: 'text',
text: JSON.stringify({
datasource_id,
status: data.status, // 'OK' or error string
message: data.message
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{
type: 'text',
text: JSON.stringify({
datasource_id,
status: 'ERROR',
http_status: err.response?.status,
message: err.response?.data?.message ?? err.message
}, null, 2)
}]
};
}
});
| Datasource type | Common type value |
Notes |
|---|---|---|
| Prometheus | prometheus |
Supports /api/ds/query with PromQL expressions |
| Loki | loki |
Supports /api/ds/query with LogQL expressions |
| Elasticsearch | elasticsearch |
Requires index name and time field in query body |
| MySQL | mysql |
SQL datasource — raw SQL queries via /api/ds/query |
| PostgreSQL | postgres |
SQL datasource |
| InfluxDB | influxdb |
Supports InfluxQL or Flux depending on version |
| Tempo | tempo |
Distributed tracing backend |
| Jaeger | jaeger |
Distributed tracing backend |
Querying a datasource via /api/ds/query
POST /api/ds/query is Grafana's unified query proxy — it routes queries through Grafana to the backend datasource without requiring direct network access to Prometheus, Loki, or any other system. This is especially useful in MCP server deployments where the agent can reach Grafana but not the individual datasource endpoints. The request body follows the data frame format Grafana uses internally.
server.tool('query_datasource', {
datasource_id: z.number().int().describe('Numeric datasource ID from list_datasources.'),
expr: z.string().describe('Query expression. PromQL for Prometheus (e.g. "up"), LogQL for Loki.'),
from: z.string().default('now-1h').describe('Time range start. Grafana-relative (now-1h) or ISO timestamp.'),
to: z.string().default('now').describe('Time range end. Grafana-relative (now) or ISO timestamp.'),
interval_ms: z.number().int().default(60000).describe('Step/interval in milliseconds for range queries.')
}, async ({ datasource_id, expr, from, to, interval_ms }) => {
// POST /api/ds/query proxies through Grafana to the datasource backend
// refId is an arbitrary label used to correlate results — 'A' by convention
const body = {
queries: [
{
datasourceId: datasource_id,
expr, // PromQL or LogQL expression
refId: 'A',
range: { from, to },
intervalMs: interval_ms,
maxDataPoints: 1000
}
],
from,
to
};
const { data } = await grafana.post('/api/ds/query', body);
// Response: { "results": { "A": { "frames": [...] } } }
// Each frame has "schema" (field names/types) and "data" (values)
const frames = data?.results?.A?.frames ?? [];
return {
content: [{
type: 'text',
text: JSON.stringify({
datasource_id,
expr,
frame_count: frames.length,
// Return first frame's schema and first 50 rows to avoid overwhelming context
schema: frames[0]?.schema ?? null,
sample_data: frames[0]?.data ?? null
}, null, 2)
}]
};
});
The response uses Grafana's data frame format. Each frame has a schema object (field names and types) and a data object (columnar arrays of values). For Prometheus, a typical Prometheus instant query returns one frame per time series; a range query returns one frame with a time column and a value column.
| Response field | Description |
|---|---|
results.A.frames |
Array of data frames for query with refId: "A" |
frames[n].schema.fields |
Array of { name, type } describing each column |
frames[n].data.values |
Columnar arrays — values[0] is first field, values[1] is second, etc. |
results.A.error |
Error message string if the datasource query failed |
Wiring /health/grafana via the /api/health endpoint
GET /api/health is Grafana's own health check endpoint — it checks whether Grafana's internal database (SQLite, MySQL, or Postgres depending on your setup) is responsive. HTTP 200 with { "database": "ok" } means Grafana is fully operational. HTTP 503 means Grafana is still starting up or its internal database is degraded. This endpoint does not check whether your configured datasources are reachable — use the datasource health endpoint for that.
app.get('/health/grafana', async (req, res) => {
try {
// GET /api/health does not require authentication — it is a public endpoint
// Use a separate axios instance without the auth header if desired,
// or rely on the same grafana instance (extra header doesn't cause errors)
const { data, status } = await grafana.get('/api/health');
// data: { "commit": "abc123", "database": "ok", "version": "10.2.0" }
if (data.database !== 'ok') {
return res.status(503).json({
status: 'unhealthy',
database: data.database,
version: data.version,
error: `Grafana internal database is not ok: ${data.database}`
});
}
return res.json({
status: 'healthy',
version: data.version,
commit: data.commit,
database: data.database
});
} catch (err: any) {
// HTTP 503: Grafana is starting up or database is degraded
// Connection refused: Grafana process is down
return res.status(503).json({
status: 'unhealthy',
http_status: err.response?.status,
error: err.response?.data?.message ?? err.message
});
}
});
| HTTP status | database value |
Meaning |
|---|---|---|
| 200 | ok |
Grafana is healthy and its internal database is responsive |
| 503 | any other value | Grafana is starting up or internal database is degraded |
| Connection refused | N/A | Grafana process is not running or port is unreachable |
| 401 | N/A | Other authenticated endpoints return 401 on invalid token — /api/health is public |
Note that a healthy /api/health response does not mean your Prometheus or Loki datasources are reachable — it only confirms Grafana itself is up and its own database is functional. Wire separate datasource health checks via GET /api/datasources/:id/health for per-datasource monitoring.
Frequently asked questions
What's the difference between a Service Account token and a legacy API key in Grafana?
Legacy API keys (deprecated in Grafana 10, targeted for removal in a future release) were simple tokens associated with the organization rather than a specific user or service account. They had a single role (Admin, Editor, or Viewer) and no fine-grained management — you couldn't rotate or revoke one key without affecting all consumers using that key. Service Account tokens are attached to a Service Account resource, which you can grant specific permissions, assign to teams, and manage independently. You can create multiple tokens per service account (for rotation without downtime), revoke individual tokens, and set expiry dates. For new integrations, always create a Service Account in Administration → Service Accounts and generate a token from it. The header format is identical — Authorization: Bearer <token> — so migration is a token swap, not a code change.
How do I query a Prometheus datasource through Grafana's API instead of hitting Prometheus directly?
Use POST /api/ds/query with the numeric datasource ID of your Prometheus datasource. Set expr to your PromQL expression and provide a range with from and to in Grafana's relative format (now-1h, now) or ISO timestamps. Grafana proxies the request to the Prometheus backend on your behalf — the MCP server only needs network access to Grafana, not to Prometheus directly. This is particularly useful when Prometheus is on an internal network accessible to Grafana's sidecar but not to the MCP server's host. The response comes back in Grafana's data frame format rather than Prometheus's native JSON, so parse results.A.frames[n].data.values to extract time series arrays.
Why should I use dashboard UIDs instead of numeric IDs?
Numeric dashboard IDs are assigned by Grafana's internal database (SQLite, MySQL, or Postgres) and are local to that database instance. When you back up dashboards as JSON and restore them — to a new Grafana instance, a different environment, or after a database migration — Grafana reassigns new numeric IDs during the import. Any hardcoded numeric ID in your MCP tool or automation will break. Dashboard UIDs, by contrast, are stored inside the dashboard JSON itself and are preserved through export/import cycles. A UID like abc123xyz identifies the same logical dashboard across all environments that have imported that dashboard. Always store and pass UIDs in MCP tool inputs, and surface UIDs (not IDs) in list_dashboards output so callers naturally use the stable identifier.
How do I handle Grafana instances behind a reverse proxy or with a path prefix like /grafana?
Set GRAFANA_URL to the full public base URL including the path prefix: https://monitoring.example.com/grafana. Grafana's HTTP API paths are then appended after the prefix — https://monitoring.example.com/grafana/api/health. Make sure the reverse proxy (nginx, Traefik, Caddy) is configured to strip or preserve the path prefix correctly and that Grafana's grafana.ini has [server] root_url = https://monitoring.example.com/grafana/ — without this, Grafana generates incorrect redirect URLs for login and OAuth flows. The API itself works regardless of the root_url setting, but the meta.url field in dashboard responses will use the configured root_url to build absolute dashboard URLs, so a misconfigured root_url produces broken links in MCP tool output.