fleetanalytics_mcp/docs/ANALYTICS_MCP.md
david kiania aae33fb590 docs(analytics-mcp): document tickets + fuel schemas and MCP_READABLE_SCHEMAS
Reflect the live state: readable data-surface table (reporting/tracksolid/
tickets/fuel + owners), the owner-keyed default-privilege gotcha, the
tickets.inc typed-vs-raw column note, the env knob, code-only redeploy that
reuses tokens, and tickets example prompts. Status flipped to deployed & live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:35:16 +03:00

477 lines
24 KiB
Markdown

# Read-only Analytics MCP Server — Implementation Guide
> **Audience:** engineer deploying/maintaining the server. **Status:** deployed & live at
> `https://fleetmcp.fivetitude.com/mcp`.
> **Repo:** `fleetanalytics_mcp` (standalone; `repo.rahamafresh.com/kianiadee/fleetanalytics_mcp.git`).
> Hosted on the same Coolify host as `tracksolid_db`. **Last updated:** 2026-06-17
> (added `tickets` + `fuel` schemas).
## 1. Purpose & context
The decision & analytics team needs to pull fleet reporting data (fuel, utilisation, driver
behaviour, INC/CRQ tickets, fuel, 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` is included as a manual host-deploy
fallback that 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), and a single-statement / keyword SQL guard in the `query`
tool.
### Where this sits
```
analyst's Claude ──HTTPS (Bearer)──► fleetmcp.fivetitude.com (Traefik)
analytics_mcp container (uvicorn :8892, coolify net)
│ psycopg2, role = analytics_ro, READ ONLY
timescale_db:5432 (tracksolid_db)
reporting.* · tracksolid.* · tickets.* · fuel.*
```
Ports in use: `8890` prod dashboard_api · `8891` staging dashboard_api · **`8892` analytics_mcp**.
---
## 2. Repo contents
| File | What |
|---|---|
| `analytics_mcp.py` | the MCP server (FastMCP streamable-HTTP; uvicorn target `analytics_mcp:app`) |
| `Dockerfile` | Coolify-buildable image (port 8892) |
| `pyproject.toml` | dependencies (`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 |
> The backend repo (`tracksolid_timescale_grafana_prod`) keeps only a pointer note in its
> `CLAUDE.md` recording that `analytics_ro` exists and is owned by this repo.
---
## 3. Step 1 — the `analytics_ro` role
### `scripts/analytics_ro_role.sql`
Modelled on `scripts/dashboard_ro_role.sql`. Run as the **postgres superuser** (it does
`CREATE ROLE`), supplied a password as psql var `:'ro_pw'`**no secret in the repo**.
```sql
-- analytics_ro_role.sql — dedicated read-only LOGIN role for the analytics MCP server.
-- Run as postgres SUPERUSER via scripts/bootstrap_analytics_ro.sh (NOT run_migrations.py).
-- Grants exactly the read surface: SELECT on reporting.* + tracksolid.* + tickets.* +
-- fuel.*, the v_trips matview, and EXECUTE on reporting/tickets/fuel functions. No
-- INSERT/UPDATE/DELETE, not the matview owner, so analytics_ro can never write or
-- REFRESH. Idempotent — safe to re-apply (rotates pw).
\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; -- tables + views
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; -- MATERIALIZED VIEW
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. reporting/tracksolid are owned by the migration role
-- (tracksolid_owner); tickets/fuel are owned by postgres, so their default-privilege
-- grants MUST be keyed to postgres or new objects won't be readable. Matviews are
-- never covered by ALL TABLES — a new matview still needs its own explicit GRANT.
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 `scripts/bootstrap_dashboard_ro.sh` — generates `~/.analytics_ro.pw` (0600) on
first run, applies the SQL via `docker exec ... psql`.
```bash
#!/usr/bin/env bash
# bootstrap_analytics_ro.sh — create/refresh the analytics_ro read-only role.
# Run ON THE HOST. Generates a strong pw into ~/.analytics_ro.pw (0600) on first
# run, then applies scripts/analytics_ro_role.sql as the postgres superuser.
# Deploy:
# scp scripts/analytics_ro_role.sql scripts/bootstrap_analytics_ro.sh kianiadee@twala.rahamafresh.com:~/
# ssh kianiadee@twala.rahamafresh.com 'bash ~/bootstrap_analytics_ro.sh'
set -euo pipefail
PW_FILE="${ANALYTICS_RO_PW_FILE:-$HOME/.analytics_ro.pw}"
SQL_FILE="${1:-$HOME/analytics_ro_role.sql}"
test -f "$SQL_FILE" || { echo "ERROR: role SQL not found at $SQL_FILE"; exit 1; }
if [ ! -s "$PW_FILE" ]; then
( umask 077; openssl rand -hex 24 > "$PW_FILE" ); chmod 600 "$PW_FILE"
echo "Generated new analytics_ro password -> $PW_FILE (0600)"
else
echo "Reusing existing analytics_ro password from $PW_FILE"
fi
PW=$(cat "$PW_FILE")
DB=$(docker ps --filter name=timescale_db --format "{{.Names}}" | head -1)
[ -n "$DB" ] || { echo "ERROR: timescale_db container not found"; exit 1; }
echo "Applying analytics_ro role DDL to $DB as postgres ..."
docker exec -i "$DB" psql -U postgres -d tracksolid_db -v ON_ERROR_STOP=1 -v ro_pw="$PW" < "$SQL_FILE"
echo "analytics_ro ready (password not printed). Now (re)run deploy_analytics_mcp.sh."
```
---
## 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). The canonical source is
[`../analytics_mcp.py`](../analytics_mcp.py); the abridged version below shows the guard, the
tools, and the auth:
```python
"""
analytics_mcp.py — Fireside Communications · Read-only Analytics MCP Server
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Hosted MCP server for the decision & analytics team. Exposes the fleet reporting
data (reporting.* + tracksolid.* + tickets.* + fuel.*) to Claude as READ-ONLY query + introspection
tools. Connects as the analytics_ro role; every query runs in a read-only txn
that is rolled back. Served over streamable HTTP behind Traefik with Bearer auth.
"""
from __future__ import annotations
import os
import re
import time
from contextlib import contextmanager
import psycopg2
import psycopg2.extras
import psycopg2.pool
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
# NOTE: a small local logger is defined here (see analytics_mcp.py); we do NOT import
# ts_shared_rev, so this read-only server carries none of the ingestion secrets.
log = _get_logger("server")
DATABASE_URL = os.environ["DATABASE_URL"] # set to the analytics_ro DSN by deploy
MAX_ROWS_CEIL = int(os.getenv("MCP_MAX_ROWS", "10000"))
# Schemas the introspection helpers (list_tables/describe_table/sample_table) expose.
# Override with MCP_READABLE_SCHEMAS — must stay in sync with the GRANTs in
# analytics_ro_role.sql. The raw query() tool is bounded by the role's GRANTs, not this list.
READABLE_SCHEMAS = tuple(
s.strip() for s in os.getenv(
"MCP_READABLE_SCHEMAS", "reporting,tracksolid,tickets,fuel"
).split(",") if s.strip()
)
# ── read-only connection pool ────────────────────────────────────────────────
# Force read-only + a statement timeout at the connection level (belt + braces;
# the analytics_ro role already sets these, but a self-contained server is safer).
_pool = psycopg2.pool.ThreadedConnectionPool(
1, int(os.getenv("MCP_POOL_MAX", "8")), DATABASE_URL,
options="-c default_transaction_read_only=on -c statement_timeout=30000 -c client_encoding=UTF8",
)
@contextmanager
def _ro_conn():
"""Read-only connection; the transaction 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 _rows(cur):
cols = [d[0] for d in cur.description]
return [dict(zip(cols, r)) for r in cur.fetchall()]
# ── SQL guard for the general query tool ─────────────────────────────────────
_FORBIDDEN = re.compile(
r"\b(insert|update|delete|drop|alter|create|grant|revoke|truncate|copy|call|do|merge|"
r"vacuum|reindex|refresh|comment|lock|set|reset)\b", re.IGNORECASE)
def _strip_comments(sql: str) -> str:
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
sql = re.sub(r"--[^\n]*", " ", sql)
return sql.strip()
def _guard(sql: str) -> str:
stripped = _strip_comments(sql)
if not stripped:
raise ValueError("Empty query.")
# exactly one statement (allow a single trailing ;)
parts = [p for p in stripped.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.IGNORECASE):
raise ValueError("Only SELECT / WITH queries are allowed.")
if _FORBIDDEN.search(stmt):
raise ValueError("Query contains a forbidden (write/DDL) keyword.")
return stmt
mcp = FastMCP("fireside-analytics", stateless_http=True)
@mcp.tool()
def query(sql: str, max_rows: int = 1000) -> dict:
"""Run a read-only SELECT/WITH query against the fleet database.
Readable schemas: reporting.*, tracksolid.*, tickets.*, fuel.*. Returns up to
`max_rows` rows (default 1000, hard cap 10000). Auto-applies LIMIT if absent."""
stmt = _guard(sql)
cap = max(1, min(int(max_rows), MAX_ROWS_CEIL))
if not re.search(r"\blimit\b", stmt, re.IGNORECASE):
stmt = f"{stmt}\nLIMIT {cap + 1}" # +1 to detect truncation
t0 = time.monotonic()
with _ro_conn() as conn, conn.cursor() as cur:
cur.execute(stmt)
rows = _rows(cur)
truncated = len(rows) > cap
rows = rows[:cap]
dur_ms = int((time.monotonic() - t0) * 1000)
log.info("query rows=%d trunc=%s %dms :: %s", len(rows), truncated, dur_ms, sql[:200])
return {"row_count": len(rows), "truncated": truncated, "rows": rows}
@mcp.tool()
def list_schemas() -> list[dict]:
"""List the readable schemas and their table/view counts."""
with _ro_conn() as conn, conn.cursor() as cur:
cur.execute(
"SELECT table_schema AS schema, count(*) AS objects "
"FROM information_schema.tables WHERE table_schema = ANY(%s) "
"GROUP BY 1 ORDER BY 1", (list(READABLE_SCHEMAS),))
return _rows(cur)
@mcp.tool()
def list_tables(schema: str) -> list[dict]:
"""List tables + views in a schema (one of READABLE_SCHEMAS)."""
if schema not in READABLE_SCHEMAS:
raise ValueError(f"schema must be one of {READABLE_SCHEMAS}")
with _ro_conn() as conn, conn.cursor() as cur:
cur.execute(
"SELECT table_name AS name, table_type AS kind "
"FROM information_schema.tables WHERE table_schema = %s "
"ORDER BY 1", (schema,))
return _rows(cur)
@mcp.tool()
def describe_table(schema: str, table: str) -> list[dict]:
"""Describe a table/view: columns, types, nullability, defaults."""
if schema not in READABLE_SCHEMAS:
raise ValueError(f"schema must be one of {READABLE_SCHEMAS}")
with _ro_conn() as conn, conn.cursor() as cur:
cur.execute(
"SELECT column_name AS column, data_type AS type, is_nullable AS nullable, "
"column_default AS default FROM information_schema.columns "
"WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position",
(schema, table))
return _rows(cur)
@mcp.tool()
def list_functions(schema: str = "reporting") -> list[dict]:
"""List callable functions (e.g. reporting.fn_*) with their signatures."""
if schema not in READABLE_SCHEMAS:
raise ValueError(f"schema must be one of {READABLE_SCHEMAS}")
with _ro_conn() as conn, conn.cursor() as cur:
cur.execute(
"SELECT p.proname AS name, pg_get_function_arguments(p.oid) AS args "
"FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE n.nspname = %s ORDER BY 1", (schema,))
return _rows(cur)
@mcp.tool()
def sample_table(schema: str, table: str, n: int = 20) -> dict:
"""Return the first `n` rows of a table/view (convenience over query)."""
if schema not in READABLE_SCHEMAS:
raise ValueError(f"schema must be one of {READABLE_SCHEMAS}")
# quote_ident via format() on validated identifiers
return query(f'SELECT * FROM "{schema}"."{table}"', max_rows=n)
# ── Bearer-token auth middleware ──────────────────────────────────────────────
# MCP_AUTH_TOKENS = "alice:tok1,bob:tok2" (per-analyst → revocable + attributable)
_TOKENS = {
t.split(":", 1)[1]: t.split(":", 1)[0]
for t in os.getenv("MCP_AUTH_TOKENS", "").split(",")
if ":" in t
}
class BearerAuth(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path == "/healthz":
return await call_next(request)
auth = request.headers.get("authorization", "")
token = auth[7:] if auth.lower().startswith("bearer ") else ""
if token not in _TOKENS:
return JSONResponse({"error": "unauthorized"}, status_code=401)
request.state.caller = _TOKENS[token]
return await call_next(request)
async def healthz(_request):
return JSONResponse({"ok": True, "tokens": len(_TOKENS)})
app = mcp.streamable_http_app()
app.add_middleware(BearerAuth)
app.add_route("/healthz", healthz, methods=["GET"]) # Starlette: add_route, not @app.route
```
> **Notes.** `stateless_http=True` suits a behind-proxy / multi-worker deploy. The token map
> is `token → name` so each query is attributable in the logs. The `_FORBIDDEN` guard also
> blocks `SET`/`RESET` so a query can't relax `default_transaction_read_only`. Validate the
> exact FastMCP app-factory method name against the installed `mcp` version
> (`streamable_http_app()` vs `http_app()`); the deploy command's `app` target must match.
### 4.1 Readable data surface
`analytics_ro` (and therefore the helper tools, via `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 it in `analytics_ro_role.sql`), then set `MCP_READABLE_SCHEMAS` to include it and
redeploy. No code change. **Watch the owner**`ALTER DEFAULT PRIVILEGES FOR ROLE …` must
name the role that *owns* the schema's objects (e.g. `postgres` for `tickets`/`fuel`,
`tracksolid_owner` for `reporting`/`tracksolid`) or future objects won't auto-grant.
> **`tickets.inc` shape.** Each row carries 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`
This repo is self-contained: its `pyproject.toml` declares the deps (`mcp[cli]`,
`psycopg2-binary`, `uvicorn[standard]`) and the `Dockerfile` builds a slim image that runs
`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`) — it never tries to build the module as a package.
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 application from this repo
(`repo.rahamafresh.com/kianiadee/fleetanalytics_mcp.git`), Dockerfile build, app port `8892`,
domain `fleetmcp.rahamafresh.com` (prod) / `fleetmcp.fivetitude.com` (staging). Set as
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`** (the tracksolid stack's network) so the hostname resolves. Coolify manages
the Traefik labels + TLS from the domain. Auto-deploys on push via the Forgejo webhook.
**Fallback — manual host deploy (`deploy.sh`).** If not using the Coolify UI, check the repo
out on the host and run `deploy.sh` — it builds the image, resolves the DB network + DSN from
the running stack, swaps in the `analytics_ro` credentials, and runs a standalone
Traefik-labelled bridge (the proven `dashboard_api` pattern). See the script header.
```bash
# 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/allowlist change): omit MCP_AUTH_TOKENS and the
# script reuses the running container's existing 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 scripts/analytics_ro_role.sql scripts/bootstrap_analytics_ro.sh kianiadee@twala.rahamafresh.com:~/` then `ssh ... 'bash ~/bootstrap_analytics_ro.sh'` (writes `~/.analytics_ro.pw`).
2. **App:** either point Coolify at this repo (§6 recommended) or run `deploy.sh` on the host. Record each analyst's token securely (it is shown once when generated).
3. **Network:** ensure the MCP container shares a Docker network with `timescale_db` so the DSN host resolves (Coolify network setting, or `deploy.sh` reuses the stack network automatically).
4. **DNS/Traefik:** ensure `fleetmcp.*` resolves to the host; Coolify/Traefik issues the cert.
---
## 8. Add to Claude (for analysts)
**Claude Code**
```bash
claude mcp add --transport http fireside-analytics https://fleetmcp.fivetitude.com \
--header "Authorization: Bearer <your-token>"
claude mcp list # should show "fireside-analytics: connected"
```
**Claude Desktop / claude.ai** — add a custom connector with the same URL and an
`Authorization: Bearer <your-token>` header.
Example session 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"* (the model writes the SELECT and
calls `query`).
---
## 9. Verification checklist
- [ ] `psql -U analytics_ro -d tracksolid_db -c "SELECT count(*) FROM reporting.v_daily_summary"` **succeeds**.
- [ ] `psql -U analytics_ro ... -c "CREATE TABLE x(i int)"` **fails** (permission denied) — proves read-only.
- [ ] the image builds (`docker build .` or Coolify build succeeds); `analytics_mcp` container is `Up`.
- [ ] `DATABASE_URL` shows `analytics_ro` (pw masked); `curl localhost:8892/healthz` returns `{"ok":true,...}`.
- [ ] the container can resolve/reach `timescale_db` (shares its network).
- [ ] `claude mcp list` shows the server connected; `list_schemas` / `describe_table` / a real `query` return data.
- [ ] `query("UPDATE reporting.refresh_log SET notes='x'")` is **rejected** by the guard.
- [ ] A request with a missing/bad bearer token returns **401**.
- [ ] `docker logs analytics_mcp` shows one audit line per query (caller name, SQL, rows, ms).
---
## 10. Security notes
- **Four read-only layers:** role GRANTs · `default_transaction_read_only=on` (role + connection) · rolled-back txn · SQL keyword guard.
- **Least privilege:** `analytics_ro` only has `USAGE`+`SELECT` on `reporting`/`tracksolid`/`tickets`/`fuel` and `EXECUTE` on those schemas' functions — no other schema, no write of any kind. (`infrastructure` and other schemas remain unreadable by design.)
- **Per-analyst tokens** make access revocable and queries attributable; rotate by editing `MCP_AUTH_TOKENS` and re-running the deploy (a recreate).
- **Resource guards:** `statement_timeout=30s`, idle-txn timeout, row cap (default 1000 / ceiling 10000) protect the DB from runaway analyst queries.
- **Future:** swap static Bearer tokens for OAuth (MCP supports it) if/when the team scales; consider a column-level deny-list if any PII lives in `tracksolid.*`.