Read-only Analytics MCP Server

Implementation guide · standalone repo fleetanalytics_mcp, hosted on the tracksolid_db Coolify host · updated 2026-06-17 · deployed & live

1. Purpose & context

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_URLtimescale_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.

Read-only is enforced at four layers: the 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.

Where this sits

Analyst's ClaudeCode / Desktop / claude.ai
Traefikfleetmcp.fivetitude.com · HTTPS + Bearer
analytics_mcpuvicorn :8892 · coolify net
role = analytics_ro · READ ONLY
timescale_db:5432tracksolid_db
reporting.* · tracksolid.* · tickets.* · fuel.*

Ports in use: 8890 prod dashboard_api · 8891 staging dashboard_api · 8892 analytics_mcp.

2. Repo contents

FileWhat
analytics_mcp.pythe MCP server (FastMCP streamable-HTTP; uvicorn target analytics_mcp:app)
DockerfileCoolify-buildable image (port 8892)
pyproject.tomldeps (mcp[cli], psycopg2-binary, uvicorn)
deploy.shmanual host deploy (standalone Traefik bridge) — fallback to Coolify
scripts/analytics_ro_role.sqlread-only role DDL (modelled on the backend's dashboard_ro_role.sql + hardening)
scripts/bootstrap_analytics_ro.shhost bootstrap: generate pw → apply role SQL
docs/ANALYTICS_MCP.md / .htmlthis guide

3. Step 1 — the analytics_ro role

Modelled 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.sh

Clone 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.

4. The MCP server (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:

ToolPurpose
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).

Full, current source is the repo's analytics_mcp.py; the excerpt above is abridged.

4.1 Readable data surface

analytics_ro (and the helper tools, via READABLE_SCHEMAS, default reporting,tracksolid,tickets,fuel, override with env MCP_READABLE_SCHEMAS) can read:

SchemaOwnerWhat's there
reportingtracksolid_ownercurated views/matviews (v_daily_summary, v_trips, v_monthly_cost_centre, …) + fn_* functions
tracksolidtracksolid_ownerraw ingestion tables (devices, positions, events, …)
ticketspostgresINC/CRQ tickets: inc, crq, closure_events, inc_daily_snapshot, geo_clusters, geo_locations, inc_open_sla (view) + 7 functions
fuelpostgresrecords, 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.

Owner gotcha. 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.

5. Packaging — Dockerfile + pyproject.toml

Self-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.

6. Deploy

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.

7. Deploy runbook (ordered)

  1. Role (once): scp the role SQL + bootstrap to twala.rahamafresh.com, run bootstrap_analytics_ro.sh (writes ~/.analytics_ro.pw).
  2. App: point Coolify at this repo (§6) or run deploy.sh on the host. Record each analyst's token (shown once).
  3. Network: ensure the MCP container shares a Docker network with timescale_db so the DSN host resolves.
  4. DNS/Traefik: ensure fleetmcp.* resolves to the host; Coolify/Traefik issues the cert.

8. Add to Claude (for analysts)

# 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".

9. Verification checklist

10. Security notes

Companion file: docs/ANALYTICS_MCP.md (full source for all four new files).