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 shorter path and fails without a clean error. Every request returns HTTP 200 with Content-Type: text/html, which is 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 raw tool is enough to cover the API surface.
Setup
Home Assistant on a Raspberry Pi 5. Two add-ons involved: 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: have Claude build a Grafana dashboard with monthly average temperatures from the outdoor sensor.
Symptom
Every high-level mcp-grafana tool (list_datasources, search_dashboards, all of them) returned the same error:
list datasources: &[] (*models.DataSourceList) is not supported by
the TextConsumer, can be resolved by supporting TextUnmarshaler interface
The message reads like a versioning bug in the Go client, but it is accurate. The client is trying to deserialize the response as a DataSourceList JSON object, and the response is not JSON. The deserializer is doing its job. The content type on the response is wrong.
Diagnosis
mcp-grafana ships a raw HTTP tool called grafana_api_request that accepts an arbitrary endpoint. Called against /api/datasources:
GET /api/datasources
→ 200 OK
→ Content-Type: text/html
→ body: <!DOCTYPE html>...<base href="/api/hassio_ingress/<token>/" />...
Two useful pieces of information sit in that response. The first is the content type. The Grafana API never returns HTML on API paths. An HTML response to /api/datasources means the request is being handled by the web application, not by the API layer. The web app returns its React boot page for any path it does not recognize as a registered route, and it returns HTTP 200 in every case. So a 200 does not confirm that the endpoint exists.
The second is the <base href> in the HTML. Grafana adds this attribute to point the browser at the correct base URL. In this case, that URL is /api/hassio_ingress/<token>/. All API endpoints are 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. A browser reads the <base href> and rewrites its subsequent requests accordingly, so navigation in the UI works transparently. API clients do not do this. They assume Grafana is at / and send the request to /api/datasources directly. The request lands on the web application’s catch-all route, which renders the React shell, so the client receives HTML with a 200 status. No 404 comes back, and no authentication error. The only downstream indication that something went wrong is that the JSON deserializer refuses a body it did not expect.
The ingress token is stable for the lifetime of the add-on installation. Extract it once from the <base href> on any Grafana page rendered through Home Assistant, and reuse it from then on.
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 expose no configuration option for a path prefix, so they stay broken. The raw grafana_api_request tool accepts arbitrary endpoints, including prefixed ones, and that covers the full API surface: datasources, dashboards, queries, alerts, provisioning, snapshots. Creating a dashboard becomes one 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. The chart looks right, but each bucket contains a different set of days than the calendar month it appears to represent, so the numbers are wrong. The correct approach with InfluxQL is one query per month with a hardcoded date 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 reduced this to a single query. Migrating from the Home Assistant bundled 1.8.x to a separate 2.x deployment is non-trivial and not always worth the effort.
The second note is about how Home Assistant writes to InfluxDB. It uses unit_of_measurement as the measurement name. Temperature sensors go into a measurement called °C, with the degree symbol as part of the identifier. Power goes into W, energy into kWh. The entity_id becomes a tag, stripped of the sensor. prefix. Once you know this convention, the queries are simple to write. The first query, before you know it, is confusing.
When the MCP detour paid off
End-to-end debugging and building took about 90 minutes. The direct alternative, clicking through the Grafana UI to add a datasource and assemble the dashboard panel by panel, would have taken 10 to 15. Counted only in minutes on this one dashboard, the MCP path lost by a wide margin.
That is the wrong comparison. What the 90 minutes produced was a reusable capability. Every dashboard after this one is one prompt rather than another click-through session. The prefix workaround applies to any other Grafana API operation, including alert provisioning, datasource provisioning, and snapshot export. Split across a second and a third dashboard, the total time invested per output drops below the click-through baseline.
The heuristic that comes out of this: AI tooling pays for itself when the alternative would have been reading documentation across a dozen browser tabs. It costs time when the alternative is clicking three buttons in a UI you already know. That call has to be made before the work starts. After you have spent 90 minutes, it is hard to weigh the two paths honestly.
One caveat. Claude did not solve this on its own. The step that actually mattered was seeing that an HTML response to an API request meant the Grafana root was somewhere other than /, and knowing that the <base href> attribute exposes the real path. That came from reading the response by hand. Claude was useful for running the diagnostic calls, parsing the HTML, assembling the dashboard JSON, and submitting the POST. Use it for the mechanical work. Keep the diagnosis on the human side.
Takeaway
An MCP “connected” status only confirms that the protocol handshake worked. It does not confirm that tool calls will produce anything useful. The most costly bugs in this category pass every health check and return HTTP 200 because the failure is above the transport layer, in how the application is dispatching the request. Verify with a known-good call (a datasource list, a version endpoint, anything that returns structured data) before assuming the rest works.
For Grafana behind a reverse proxy in particular, when a high-level client fails without a clean error, drop to a raw HTTP tool and look at the response Content-Type. If it is HTML instead of JSON, the request is landing on the web application rather than on the API, and the real endpoint is under a path prefix. That prefix is usually printed in the <base href> of the HTML that came back.