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.

Further reading

Know when your Grafana API health probe fails before your annotation pipeline goes silent

AliveMCP polls your /health/grafana endpoint and alerts you the moment /api/health returns 503 or stops responding — catching Grafana outages, database degradation, and misconfigured tokens before deployment annotations stop appearing and on-call engineers lose their event markers.

Start monitoring free