Implementation guide · standalone repo fleetanalytics_mcp, hosted on the tracksolid_db Coolify host · updated 2026-06-17 · deployed & live
The decision & analytics team needs to pull fleet reporting data (fuel, utilisation,
driver behaviour, INC tickets, raw telemetry) from tracksolid_db to make
decisions — read-only, never edit/delete. The only programmatic surface today is the
dashboard_api FastAPI bridge with a fixed set of /analytics/* /
/webhook/* endpoints — too rigid for ad-hoc analysis.
This adds a hosted, read-only MCP server that lets analysts query the database
directly from Claude: a guarded general SELECT tool plus schema-introspection
tools, pointed at the existing PostgreSQL 16 + TimescaleDB + PostGIS database through a
new least-privilege analytics_ro role.
The DB is internal-only (DATABASE_URL → timescale_db:5432 on the
Docker network, not reachable from a laptop), so the server is hosted on the same Coolify
host as the DB. It ships as its own repo with its own Dockerfile
(Coolify-buildable) and joins the network that can reach timescale_db; a
deploy.sh manual fallback mirrors the proven dashboard_api bridge pattern.
analytics_ro
GRANTs (no INSERT/UPDATE/DELETE) · a session default_transaction_read_only = on
· a transaction that is rolled back (never committed) · a single-statement / keyword
SQL guard in the query tool.Ports in use: 8890 prod dashboard_api · 8891 staging dashboard_api · 8892 analytics_mcp.
| File | What |
|---|---|
analytics_mcp.py | the MCP server (FastMCP streamable-HTTP; uvicorn target analytics_mcp:app) |
Dockerfile | Coolify-buildable image (port 8892) |
pyproject.toml | deps (mcp[cli], psycopg2-binary, uvicorn) |
deploy.sh | manual host deploy (standalone Traefik bridge) — fallback to Coolify |
scripts/analytics_ro_role.sql | read-only role DDL (modelled on the backend's dashboard_ro_role.sql + hardening) |
scripts/bootstrap_analytics_ro.sh | host bootstrap: generate pw → apply role SQL |
docs/ANALYTICS_MCP.md / .html | this guide |
analytics_ro roleModelled on scripts/dashboard_ro_role.sql. Run as the postgres
superuser (it does CREATE ROLE), with the password supplied as psql var
:'ro_pw' — no secret in the repo.
scripts/analytics_ro_role.sql-- read-only LOGIN role for the analytics MCP server. Apply via bootstrap_analytics_ro.sh. \set ON_ERROR_STOP on DO $role$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'analytics_ro') THEN CREATE ROLE analytics_ro LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE; END IF; END $role$; ALTER ROLE analytics_ro WITH LOGIN PASSWORD :'ro_pw'; GRANT CONNECT ON DATABASE tracksolid_db TO analytics_ro; GRANT USAGE ON SCHEMA reporting, tracksolid, tickets, fuel TO analytics_ro; GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO analytics_ro; -- tables + views GRANT SELECT ON ALL TABLES IN SCHEMA tracksolid TO analytics_ro; GRANT SELECT ON ALL TABLES IN SCHEMA tickets TO analytics_ro; -- INC/CRQ tickets GRANT SELECT ON ALL TABLES IN SCHEMA fuel TO analytics_ro; -- fuel GRANT SELECT ON reporting.v_trips TO analytics_ro; -- matview (not in ALL TABLES) GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO analytics_ro; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tickets TO analytics_ro; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA fuel TO analytics_ro; -- future objects auto-grant; key to the OWNER role (postgres for tickets/fuel) ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT SELECT ON TABLES TO analytics_ro; ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA tracksolid GRANT SELECT ON TABLES TO analytics_ro; ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT EXECUTE ON FUNCTIONS TO analytics_ro; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA tickets GRANT SELECT ON TABLES TO analytics_ro; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA fuel GRANT SELECT ON TABLES TO analytics_ro; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA tickets GRANT EXECUTE ON FUNCTIONS TO analytics_ro; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA fuel GRANT EXECUTE ON FUNCTIONS TO analytics_ro; -- extra hardening over dashboard_ro: this role serves ad-hoc HUMAN queries ALTER ROLE analytics_ro SET default_transaction_read_only = on; ALTER ROLE analytics_ro SET statement_timeout = '30s'; ALTER ROLE analytics_ro SET idle_in_transaction_session_timeout = '60s';
scripts/bootstrap_analytics_ro.shClone of bootstrap_dashboard_ro.sh — generates ~/.analytics_ro.pw
(0600) on first run, applies the SQL via docker exec … psql -v ro_pw=…. The
password is never printed and never leaves the host.
analytics_mcp.py)FastMCP streamable-HTTP server, served by uvicorn (target analytics_mcp:app).
It uses its own read-only psycopg2 pool and a small local logger — it deliberately does
not import the backend's ts_shared_rev (that module eagerly requires the
Tracksolid ingestion secrets, which this read-only server has no business holding). Tools exposed:
| Tool | Purpose |
|---|---|
query(sql, max_rows=1000) | guarded read-only SELECT/WITH; single statement, keyword-blocked, auto-LIMIT; returns rows + truncated flag |
list_schemas() | readable schemas (reporting, tracksolid, tickets, fuel) + object counts |
list_tables(schema) | tables + views in a schema |
describe_table(schema, table) | columns, types, nullability, defaults |
list_functions(schema='reporting') | reporting.fn_* signatures |
sample_table(schema, table, n=20) | first n rows (thin wrapper over query) |
The core guard + connection logic:
# read-only pool: force read-only + statement timeout at connection level (belt + braces) _pool = psycopg2.pool.ThreadedConnectionPool(1, 8, DATABASE_URL, options="-c default_transaction_read_only=on -c statement_timeout=30000") @contextmanager def _ro_conn(): # txn is ALWAYS rolled back — never commits conn = _pool.getconn() try: conn.set_session(readonly=True, autocommit=False) yield conn finally: conn.rollback(); _pool.putconn(conn) def _guard(sql): # single SELECT/WITH, no write/DDL keywords stmt = _strip_comments(sql) parts = [p for p in stmt.split(";") if p.strip()] if len(parts) != 1: raise ValueError("Only a single statement is allowed.") stmt = parts[0].strip() if not re.match(r"^(select|with)\b", stmt, re.I): raise ValueError("Only SELECT/WITH allowed.") if _FORBIDDEN.search(stmt): raise ValueError("Forbidden (write/DDL) keyword.") return stmt
Auth is a Starlette BaseHTTPMiddleware that requires
Authorization: Bearer <token>. Tokens come from env
MCP_AUTH_TOKENS="alice:tok1,bob:tok2" (per-analyst → revocable + attributable in
logs); /healthz is exempt. The app is mounted via
app = mcp.streamable_http_app(), then app.add_middleware(BearerAuth)
and app.add_route("/healthz", …) (Starlette exposes add_route, not a
Flask-style @app.route decorator — verified against the installed mcp).
analytics_mcp.py; the excerpt
above is abridged.analytics_ro (and the helper tools, via READABLE_SCHEMAS, default
reporting,tracksolid,tickets,fuel, override with env MCP_READABLE_SCHEMAS)
can read:
| Schema | Owner | What's there |
|---|---|---|
reporting | tracksolid_owner | curated views/matviews (v_daily_summary, v_trips, v_monthly_cost_centre, …) + fn_* functions |
tracksolid | tracksolid_owner | raw ingestion tables (devices, positions, events, …) |
tickets | postgres | INC/CRQ tickets: inc, crq, closure_events, inc_daily_snapshot, geo_clusters, geo_locations, inc_open_sla (view) + 7 functions |
fuel | postgres | records, ingest_state + 7 functions |
Adding a schema later is config-only: GRANT USAGE/SELECT/EXECUTE … to
analytics_ro (persist in analytics_ro_role.sql), then add it to
MCP_READABLE_SCHEMAS and redeploy — no code change.
ALTER DEFAULT PRIVILEGES FOR ROLE … must name
the role that owns the schema's objects — postgres for
tickets/fuel, tracksolid_owner for
reporting/tracksolid — or future objects won't auto-grant.tickets.inc shape. Each row has both typed columns
(bucket, raw_status, normalized_status, sla_status,
region, cluster, owner, mttr, closed_at,
latitude/longitude, geog/geom, …) and a
raw text blob with the original source fields. Query the typed columns, not raw.Dockerfile + pyproject.tomlSelf-contained: pyproject.toml declares the deps (mcp[cli],
psycopg2-binary, uvicorn[standard]) and the Dockerfile
builds a slim image running uvicorn analytics_mcp:app on port 8892. The project is
a flat single module, so [tool.uv] package = false and the Dockerfile installs
dependencies only (uv sync --no-dev --no-install-project) — no dependency on
the backend image.
The DB is internal-only, so the server runs on the same Coolify host as
timescale_db.
Recommended — Coolify-managed app. Create a Coolify app from this repo, Dockerfile
build, app port 8892, domain fleetmcp.rahamafresh.com (prod) /
fleetmcp.fivetitude.com (staging). Set secrets
DATABASE_URL=postgresql://analytics_ro:<pw>@timescale_db:5432/tracksolid_db and
MCP_AUTH_TOKENS=alice:<tok>,bob:<tok>, then connect the app to the network
that can reach timescale_db so the hostname resolves. Coolify manages Traefik +
TLS from the domain; auto-deploys on push via the Forgejo webhook.
Fallback — deploy.sh. Check the repo out on the host and run it: it builds
the image, resolves the DB network + DSN from the running stack, swaps in the
analytics_ro credentials, and runs a standalone Traefik bridge.
# add/rotate a token (sets the full token list): cd ~/fleetanalytics_mcp && git pull MCP_AUTH_TOKENS="alice:$(openssl rand -hex 16)" bash deploy.sh # code-only redeploy (e.g. a schema change): omit MCP_AUTH_TOKENS — # deploy.sh reuses the running container's tokens, no secret to re-type: cd ~/fleetanalytics_mcp && git pull && bash deploy.sh
Optional env: MCP_READABLE_SCHEMAS (default
reporting,tracksolid,tickets,fuel) controls which schemas the introspection helpers
expose; MCP_MAX_ROWS (default 10000) the row ceiling.
scp the role SQL + bootstrap to twala.rahamafresh.com, run bootstrap_analytics_ro.sh (writes ~/.analytics_ro.pw).deploy.sh on the host. Record each analyst's token (shown once).timescale_db so the DSN host resolves.fleetmcp.* resolves to the host; Coolify/Traefik issues the cert.# Claude Code claude mcp add --transport http fireside-analytics https://fleetmcp.fivetitude.com \ --header "Authorization: Bearer <your-token>" claude mcp list # → "fireside-analytics: connected"
Claude Desktop / claude.ai: add a custom connector with the same URL and an
Authorization: Bearer <your-token> header. Example prompts: "list the
schemas", "describe reporting.v_daily_summary", "top 10 cost centres by distance
in the last 30 days", "open INC tickets by region and SLA status from tickets.inc",
"MTTR by cluster this month".
psql -U analytics_ro … "SELECT count(*) FROM reporting.v_daily_summary" succeeds.psql -U analytics_ro … "CREATE TABLE x(i int)" fails (permission denied) — proves read-only.docker build . or Coolify build); analytics_mcp is Up; the container can reach timescale_db.DATABASE_URL shows analytics_ro (pw masked); curl localhost:8892/healthz returns {"ok":true,…}.claude mcp list shows connected; list_schemas / describe_table / a real query return data.query("UPDATE reporting.refresh_log …") is rejected by the guard.docker logs analytics_mcp shows one audit line per query (caller, SQL, rows, ms).default_transaction_read_only=on (role + connection) · rolled-back txn · SQL keyword guard.analytics_ro only has USAGE+SELECT on reporting/tracksolid/tickets/fuel and EXECUTE on those schemas' functions — no other schema (e.g. infrastructure stays unreadable), no write of any kind.MCP_AUTH_TOKENS + redeploy (recreate).statement_timeout=30s, idle-txn timeout, row cap (1000 default / 10000 ceiling).tracksolid.*.Companion file: docs/ANALYTICS_MCP.md (full source for all four new files).