Home / Lab / HomeAssistant, Claude, Grafana MCP, and the ingress proxy that lied
Lab

HomeAssistant, Claude, Grafana MCP, and the ingress proxy that lied

May 24, 2026 Andrei Gosman 5 min read

TL;DR. Grafana running as a Home Assistant add-on serves its API under /api/hassio_ingress/<token>/api/..., not /api/.... Every third-party Grafana client I tested — official SDK, Terraform provider, mcp-grafana — hardcodes the latter and breaks silently: every call returns HTTP 200 with Content-Type: text/html (the React boot page). The workaround is mcp-grafana‘s raw grafana_api_request tool with the full ingress prefix prepended manually. The high-level tools stay broken; the escape hatch is enough.

Setup

Home Assistant on a Raspberry Pi 5. Two add-ons in play: InfluxDB 1.8.x (community add-on v5.0.2, InfluxQL only — no Flux) and Grafana 12.3. Claude Desktop on a laptop with the official mcp-grafana server installed via uvx, pointed at http://<ha-host>:3000 with a service account token. Goal: ask Claude to build a Grafana dashboard with monthly average temperatures from the outdoor sensor.

The symptom

Every high-level mcp-grafana tool — list_datasources, search_dashboards, anything — returned the same error:

list datasources: &[] (*models.DataSourceList) is not supported by 
the TextConsumer, can be resolved by supporting TextUnmarshaler interface

The error looks like a versioning bug. It isn’t. The Go client tries to deserialize the response as a DataSourceList JSON object, but the response isn’t JSON at all. The deserializer is fine; the data is wrong type.

Diagnosis

mcp-grafana ships a tool called grafana_api_request — raw HTTP, arbitrary endpoint. Call it against /api/datasources:

GET /api/datasources
→ 200 OK
→ Content-Type: text/html
→ body: <!DOCTYPE html>...<base href="/api/hassio_ingress/<token>/" />...

Two things to read off this:

First, the Grafana API never returns HTML. If you ask for a JSON endpoint and get HTML back, you’re not talking to the API — you’re talking to the web app, which serves its React boot page for any path it doesn’t recognize as a registered route. The 200 OK is meaningless; the web app says yes to anything.

Second, the <base href> in the HTML tells you where Grafana actually thinks its root is. In this case: /api/hassio_ingress/<token>/. Every API endpoint is mounted under that prefix, not at /.

Why it happens

Home Assistant’s Supervisor exposes every add-on through an ingress proxy at /api/hassio_ingress/<token>/.... Grafana as an add-on is configured with serve_from_sub_path = true and a root_url rooted under that ingress path. Browsers follow the <base href> and routing works transparently. API clients don’t read base hrefs — they assume Grafana lives at / and hit /api/datasources directly. The request lands on the web app’s catch-all route, which renders the React shell, which is HTML. No 404, no auth failure, no useful signal — just a wrong-shape response that breaks downstream deserialization.

The ingress token is stable for the lifetime of the add-on installation. Extract it once from any Grafana page rendered through Home Assistant (the <base href> attribute) and reuse it.

The fix

Prepend the full ingress prefix to every Grafana API call:

GET /api/hassio_ingress/<token>/api/datasources
→ 200 OK, application/json
→ {"name": "influxdb", "type": "influxdb", "uid": "ff2wvdg15yz9ce", ...}

The high-level mcp-grafana tools hardcode /api/... and offer no configuration option for a path prefix, so they remain broken. The raw grafana_api_request tool accepts arbitrary endpoints, including prefixed ones. That covers the full API surface — datasources, dashboards, queries, alerts, all of it. Creating a dashboard becomes a single POST:

POST /api/hassio_ingress/<token>/api/dashboards/db
Content-Type: application/json

{
  "dashboard": { ...panels, queries, layout... },
  "overwrite": true
}

Two related InfluxDB notes

InfluxQL 1.x has no calendar-month grouping. GROUP BY time(30d) produces 30-day buckets that drift across month boundaries — looks right, is wrong. The correct approach is one query per month with a hardcoded range:

SELECT mean("value") FROM "°C"
WHERE ("entity_id" = 'oat_snzb_02d_temperature')
  AND time >= '2026-01-01' AND time < '2026-02-01'

InfluxDB 2.x with Flux has aggregateWindow(every: 1mo) and would have made this a one-liner. Migrating from the HA bundled 1.8.x to a separate 2.x deployment is non-trivial and not always worth it.

Home Assistant writes to InfluxDB using unit_of_measurement as the measurement name. Temperature sensors live in a measurement literally named °C, degree symbol included. Power in W, energy in kWh. The entity_id becomes a tag, stripped of the sensor. prefix. Once internalized it’s fine; the first query is surprising.

When the MCP detour was worth it

The end-to-end debug-and-build took roughly 90 minutes. A direct equivalent — clicking through the Grafana UI to add a datasource and assemble the dashboard panel by panel — would have taken 10 to 15. As a comparison of raw minutes on a single dashboard, the MCP path lost cleanly.

That comparison misses the point. The 90 minutes produced a reusable capability: every subsequent dashboard is now a single prompt rather than another 15-minute click-through, and the prefix workaround applies to any other Grafana-API operation (alerts, datasource provisioning, snapshot exports). Amortized over the second and third use case, the math reverses. As a heuristic: AI tooling pays off when the dig is long enough that you would otherwise be reading docs in twenty tabs, and it actively costs you time when the alternative is clicking three buttons in a UI you already know. The skill is recognizing which is which before you start, not after.

One detail worth being honest about: Claude did not solve this autonomously. The architectural insight — that an HTML response to an API request meant the Grafana root was somewhere other than /, and that the <base href> attribute exposes the real path — came from reading the raw response by hand. Claude was useful as the layer that ran the diagnostic calls, parsed the HTML, built the dashboard JSON, and submitted the POST. Treat it as a fast operator, not as a diagnostician; the diagnostician is still you.

Takeaway

MCP “connected” status reflects only the protocol handshake. It says nothing about whether tool calls produce useful results. The most expensive bugs in this category pass every health check and return HTTP 200, because the failure is at the application layer rather than the transport. Verify with a known-good call (datasource list, version endpoint, anything that returns structured data) before trusting the rest.

For Grafana behind a reverse proxy specifically: when a high-level client fails inexplicably, drop down to a raw HTTP tool and check the Content-Type of the response. If it’s HTML, you’re hitting the web app, not the API, and the real endpoint is somewhere under a path prefix you’ll need to discover.