grafana-unified-mcp
Provides a unified interface to multiple Grafana instances, allowing tools to query Prometheus, search dashboards, and perform other Grafana operations across different instances with a single MCP server.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@grafana-unified-mcpquery CPU usage on the appstate Grafana instance"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
grafana-unified-mcp
One MCP server in front of many Grafana instances. Every tool from the standard
Grafana MCP server, plus one extra argument — instance — that says which
Grafana to run it against.
query_prometheus(instance="appstate", expr="up", datasourceUid="...")
search_dashboards(instance="uoregon", query="login latency")Why this exists
Upstream grafana/mcp-grafana binds
GRAFANA_URL once, at process start. It reads
X-Grafana-Service-Account-Token per request, but the URL is fixed — and the
header that used to override it is now explicitly inert. From upstream
validate_url.go:
Deprecated: X-Grafana-URL no longer configures the Grafana client. This middleware is retained temporarily to preserve malformed-header handling.
So one mcp-grafana process can only ever talk to one Grafana. Ten Grafanas
means ten servers, ten entries in every client config, and ten sets of tools
with identical names for the model to disambiguate.
This server fixes that by running one upstream child process per instance and
routing each call to the right one based on the instance argument. Tools are
discovered from the real binary at runtime, so you get whatever upstream
exposes — currently 65 tools — with no per-tool code here and nothing to update
when upstream adds more.
How it works
┌──────────────────────────────────┐
Claude Code / routines / │ grafana-unified-mcp │
cloud sessions │ │
│ │ ┌────────────────────────────┐ │
│ streamable-HTTP │ │ bearer auth │ │
│ Authorization: Bearer … │ │ → Principal(instances, │ │
├──────────────────────────────►│ │ read-only|read-write) │ │
│ │ └────────────┬───────────────┘ │
│ │ │ │
│ │ ┌────────────▼───────────────┐ │
│ │ │ catalog: inject `instance` │ │
│ │ │ filter by caller's grant │ │
│ │ └────────────┬───────────────┘ │
│ │ │ route on │
│ │ │ instance=… │
│ │ ┌────────────▼───────────────┐ │
│ │ │ child pool (lazy, reaped) │ │
│ │ └──┬──────────┬──────────┬───┘ │
└───────────────────────────────┴─────┼──────────┼──────────┼──────┘
│ stdio │ stdio │ stdio
┌─────▼────┐ ┌───▼──────┐ ┌▼─────────┐
│mcp-grafana│ │mcp-grafana│ │mcp-grafana│
│ appstate │ │ uoregon │ │ … │
└─────┬────┘ └───┬──────┘ └┬─────────┘
▼ ▼ ▼
appstate uoregon …GrafanaChildren start on first use, stay warm, get reaped when idle
(--idle-timeout, default 15 min), and are respawned transparently if they die.
An unreachable Grafana degrades only its own instance.
Install
Two pieces: the upstream binary, and this package.
# 1. the upstream mcp-grafana binary (needs Go 1.26+; GOTOOLCHAIN=auto fetches it)
deploy/install-mcp-grafana.sh /usr/local/bin
# 2. this server
python3 -m venv /opt/grafana-unified-mcp/.venv
/opt/grafana-unified-mcp/.venv/bin/pip install 'grafana-unified-mcp[aws] @ .'If you already have the binary, point at it with MCP_GRAFANA_BINARY=/path/to/mcp-grafana
or --mcp-grafana-binary.
Configure
Endpoints
Exactly the shape you'd expect — instance name to the upstream env vars:
{
"appstate": {
"GRAFANA_URL": "https://appstate.uw2.example.cloud/grafana",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "glsa_…"
},
"uoregon": {
"GRAFANA_URL": "https://uoregon.uw2.example.cloud/grafana",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "glsa_…",
"description": "University of Oregon production"
}
}Optional per-instance keys: GRAFANA_ORG_ID, GRAFANA_USERNAME /
GRAFANA_PASSWORD, description, extra_env, extra_args. To keep secrets
out of the document itself, use GRAFANA_SERVICE_ACCOUNT_TOKEN_ENV (read from
this process's environment) or GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE (a path the
child reads).
Auth
{
"clients": [
{
"name": "claude-routines",
"token_sha256": "3f786850e387550fdab836ed7e6dc881de23001b…",
"instances": ["appstate", "uoregon"],
"scope": "read-only"
},
{
"name": "platform-oncall",
"token_sha256": "…",
"instances": ["*"],
"scope": "read-write"
}
]
}Mint a token and its hash:
grafana-unified-mcp --hash-token # generates one
grafana-unified-mcp --hash-token 'my-existing-token'Give token to the client; put token_sha256 in the document. Tokens are
compared by digest under hmac.compare_digest, and every client is checked on
every attempt so match position doesn't leak through timing.
Two things are enforced per caller:
instances— theinstanceenum a caller sees is narrowed to its grant, and a call naming an instance outside it is refused with the same message as a nonexistent one, so a token can't enumerate what it can't reach.scope—read-onlycallers never even see mutating tools. The split comes from upstream's ownreadOnlyHintannotation (49 of 65 tools are read-only today), not a list maintained here, so tools added upstream are classified without a code change. Anything unannotated is treated as not read-only.
For belt-and-braces, add --child-arg=--disable-write to strip write tools at
the source for every caller.
Running without authentication
--auth-mode none serves every caller that can reach the port, read-only.
There is no identity to scope instances by, so all configured instances stay
readable — but nothing is writable, because an open port should not be able to
rewrite a dashboard or delete a snapshot. That's enforced at three layers:
the published catalogue omits every mutating tool;
the authorization check refuses them even if a client names one directly;
children are started with
--disable-write, so upstream refuses them too.
The third layer is what makes it more than a filter. Upstream swaps
grafana_api_request for a separate GET-only registration — no body
parameter, method narrowed to GET, non-GET rejected at runtime — so even a
bug in layers 1 and 2 could not turn into a write.
stdio is different: the local caller already holds the endpoints document and every token in it, so restricting them would be theatre. stdio gets full access.
If you need writes over HTTP, use bearer tokens with a read-write client
rather than an open port.
Where config comes from
Any of these, for both --endpoints and --auth:
Source | Example |
File |
|
Inline env var |
|
AWS Secrets Manager |
|
AWS SSM Parameter Store |
|
Both documents are re-read every --config-refresh-seconds (default 300). A
failed refresh logs and keeps the last good value, so a transient AWS error or a
half-written file can't take the server down. Adding an instance needs no
restart; removing one stops its child.
Validate before starting:
grafana-unified-mcp --endpoints … --auth … --check-configRun
# local, over stdio (no auth — the local caller already holds the config)
grafana-unified-mcp --endpoints ./examples/endpoints.json
# deployed, over streamable-HTTP behind a reverse proxy
grafana-unified-mcp \
--transport streamable-http \
--address 127.0.0.1:8900 \
--endpoints aws-secrets:prod/grafana/endpoints?region=us-west-2 \
--auth aws-secrets:prod/grafana/mcp-auth?region=us-west-2 \
--public-url https://grafana-mcp.example.com
--public-urlmatters. The SDK applies DNS-rebinding protection based on theHostheader. Behind a proxy forwarding a public hostname, that host must be allowed or every request is rejected.--public-urlallows it (and is used for RFC 9728 resource metadata);--allowed-hostadds more.
GET /healthz reports process health, live children, and catalog state without
touching Grafana.
Connect a client
.mcp.json, for local stdio use:
{
"mcpServers": {
"grafana": {
"command": "/opt/grafana-unified-mcp/.venv/bin/grafana-unified-mcp",
"args": ["--endpoints", "/etc/grafana-unified-mcp/endpoints.json"]
}
}
}For the deployed server — including Claude Code routines and cloud sessions, which is the case the bearer tokens exist for:
{
"mcpServers": {
"grafana": {
"type": "http",
"url": "https://grafana-mcp.example.com/mcp",
"headers": {
"Authorization": "Bearer ${GRAFANA_UNIFIED_MCP_TOKEN}"
}
}
}
}Set GRAFANA_UNIFIED_MCP_TOKEN in the environment the session runs in — for
Claude Code on the web, that's the environment's variables, so scheduled
routines and cloud sessions pick it up without the secret living in the repo.
Give routines a read-only client; keep read-write for humans.
Deploy as a systemd service
See deploy/. In short:
sudo deploy/install.sh # user, dirs, venv, unit file
sudo systemctl edit grafana-unified-mcp # set the source URIs / region
sudo systemctl enable --now grafana-unified-mcp
curl -s localhost:8900/healthz | jqThe unit runs as a dedicated unprivileged user with ProtectSystem=strict,
PrivateTmp, and NoNewPrivileges. TLS terminates at nginx or an ALB in front —
see deploy/nginx.conf.example, which disables response buffering (required for
SSE streaming).
Using it
Point the model at list_grafana_instances first:
list_grafana_instances()
→ { "instances": [ {"name": "appstate", "url": "…", "connection": "live"}, … ],
"routing_argument": "instance",
"access": { "client": "claude-routines", "scope": "read-only" } }Then every other tool takes that name:
search_dashboards(instance="appstate", query="latency")Pass check_health=true to also probe each Grafana — slower, since it opens a
connection to every instance.
One naming wrinkle
Upstream's grafana_api_request already has a required parameter called
endpoint (the API path). Injecting a routing argument by that name would
silently shadow it, which is why the routing argument is instance by default.
If you rename it with --routing-param endpoint, that tool's own parameter is
automatically republished as api_path and mapped back on the way through — no
tool is ever broken by the collision, whatever you choose.
Development
uv venv && uv pip install -e '.[dev,aws]'
uv run pytest # unit + integrationThe integration tests drive a real mcp-grafana child against an unreachable
Grafana: enough to prove catalog discovery, instance injection and stripping,
routing, and auth filtering, without needing live credentials. Set
MCP_GRAFANA_BINARY to point at the binary, or they skip.
Roadmap
OAuth 2.1 — the auth layer is already an interface, and the SDK already takes an OAuth provider alongside the token verifier. Filling in
OAuth2Provider.verify_tokenis the whole job;auth/oauth.pydocuments the three steps. Map IdP groups onto the existinggrafana:read/grafana:write/instance:<name>scopes and every authorization check keeps working unchanged.Fan-out —
instance: "*"to run one read-only query across every instance and merge results. Useful for "which of these is alerting?"; left out for now because result merging deserves its own design.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
An MCP server giving access to Grafana dashboards, data and more.
Remote MCP for GenAI span mapping, provider normalization, dashboard schemas, and receipts.
MCP server for interacting with the Supabase platform
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/robert-sinclair/grafana-unified-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server