feat: read-only Fleet Analytics MCP server
Standalone, hosted MCP server that lets the decision & analytics team query
the fleet database (reporting.* / tracksolid.*) from Claude — read-only, for
reporting and decisions, never edit/delete.
- analytics_mcp.py: FastMCP streamable-HTTP server. Tools: query (guarded
single SELECT/WITH, auto-LIMIT, write/DDL blocked), list_schemas,
list_tables, describe_table, list_functions, sample_table. Per-analyst
Bearer auth; /healthz exempt. No ts_shared_rev import (carries no ingestion
secrets).
- Read-only enforced at four layers: analytics_ro GRANTs,
default_transaction_read_only=on, rolled-back txn, SQL keyword guard.
- scripts/: analytics_ro_role.sql + bootstrap_analytics_ro.sh (dedicated
least-privilege role, password in host-only ~/.analytics_ro.pw).
- Dockerfile + pyproject (uv, package=false) for Coolify build; deploy.sh
manual host fallback (standalone Traefik bridge on the tracksolid_db host).
- docs/ANALYTICS_MCP.{md,html} + README: architecture, deploy runbook,
add-to-Claude, verification, security notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
1eda59fe06
11 changed files with 1274 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
*.env
|
||||
.analytics_ro.pw
|
||||
docs
|
||||
scripts
|
||||
deploy.sh
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
README.md
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.env
|
||||
.analytics_ro.pw
|
||||
.DS_Store
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
uv.lock
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# fleetanalytics-mcp — read-only Fleet Analytics MCP server.
|
||||
# Coolify auto-detects this Dockerfile: set the app port to 8892, attach the
|
||||
# domain (e.g. fleetmcp.rahamafresh.com) in the Coolify UI, set DATABASE_URL
|
||||
# (analytics_ro DSN) + MCP_AUTH_TOKENS as secrets, and connect the app to the
|
||||
# network that can reach timescale_db. See README.md / docs/ANALYTICS_MCP.md.
|
||||
FROM python:3.12-slim
|
||||
|
||||
# uv for fast, reproducible dependency installs.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ONLY dependencies (flat module — the project itself is not a package).
|
||||
COPY pyproject.toml ./
|
||||
RUN uv sync --no-dev --no-install-project
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY analytics_mcp.py ./
|
||||
|
||||
EXPOSE 8892
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8892/healthz').status==200 else 1)" || exit 1
|
||||
|
||||
CMD ["uvicorn", "analytics_mcp:app", "--host", "0.0.0.0", "--port", "8892", "--workers", "2"]
|
||||
93
README.md
Normal file
93
README.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Fleet Analytics MCP (read-only)
|
||||
|
||||
A **read-only MCP server** that lets the decision & analytics team query the Fireside
|
||||
fleet database (`tracksolid_db` — PostgreSQL 16 + TimescaleDB + PostGIS) directly from
|
||||
**Claude** — for reporting and decisions, **never edit/delete**.
|
||||
|
||||
It exposes a guarded general `SELECT` tool plus schema-introspection tools over the
|
||||
`reporting.*` (curated analytics layer) and `tracksolid.*` (raw telemetry) schemas,
|
||||
connecting as a dedicated least-privilege **`analytics_ro`** role. It is hosted on the
|
||||
same Coolify host as the database (the DB is internal-only and not reachable from a
|
||||
laptop), and authed with per-analyst Bearer tokens.
|
||||
|
||||
> Sibling of the `tracksolid_timescale_grafana_prod` backend (the DB/ingestion stack)
|
||||
> and the `dashboard_api` read bridge. This repo owns only the analytics MCP server and
|
||||
> its `analytics_ro` role.
|
||||
|
||||
## Read-only is enforced at four layers
|
||||
|
||||
1. **Role GRANTs** — `analytics_ro` has only `USAGE`+`SELECT` on `reporting`/`tracksolid`
|
||||
and `EXECUTE` on `reporting` functions; no INSERT/UPDATE/DELETE, not the matview owner.
|
||||
2. **`default_transaction_read_only = on`** — set on the role and on every connection.
|
||||
3. **Rolled-back transactions** — every query runs in a txn that is rolled back, never committed.
|
||||
4. **SQL guard** — the `query` tool accepts a single `SELECT`/`WITH` statement only and
|
||||
rejects write/DDL keywords (clean errors instead of DB faults).
|
||||
|
||||
It deliberately does **not** import the backend's `ts_shared_rev`, so it carries none of
|
||||
the Tracksolid ingestion secrets — it needs only `DATABASE_URL` + `MCP_AUTH_TOKENS`.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `query(sql, max_rows=1000)` | guarded read-only SELECT/WITH; auto-LIMIT; returns `{row_count, truncated, rows}` |
|
||||
| `list_schemas()` | readable schemas + 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 (wrapper over `query`) |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
analytics_mcp.py # the MCP server (FastMCP streamable-HTTP; uvicorn target analytics_mcp:app)
|
||||
Dockerfile # Coolify-buildable image (port 8892)
|
||||
deploy.sh # manual host deploy (standalone Traefik bridge) — fallback to Coolify
|
||||
scripts/analytics_ro_role.sql # the read-only role DDL
|
||||
scripts/bootstrap_analytics_ro.sh# host bootstrap: generate pw → apply role SQL
|
||||
docs/ANALYTICS_MCP.md / .html # full implementation guide + runbook
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
The DB is internal-only, so the server runs on the **same Coolify host as `timescale_db`**.
|
||||
|
||||
**0. Create the read-only role (once, on the host):**
|
||||
```bash
|
||||
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' # writes ~/.analytics_ro.pw (0600)
|
||||
```
|
||||
|
||||
**1a. Coolify-managed app (recommended):** create a Coolify application from this repo
|
||||
(Forgejo `repo.rahamafresh.com/kianiadee/fleetanalytics_mcp.git`), Dockerfile build, app
|
||||
port `8892`, attach the domain `fleetmcp.rahamafresh.com` (prod) / `fleetmcp.fivetitude.com`
|
||||
(staging). Set as **secrets**:
|
||||
- `DATABASE_URL=postgresql://analytics_ro:<pw>@timescale_db:5432/tracksolid_db`
|
||||
- `MCP_AUTH_TOKENS=alice:<tok>,bob:<tok>` (per-analyst)
|
||||
|
||||
Then **connect the app to the network that can reach `timescale_db`** (the tracksolid
|
||||
stack's network) so the `timescale_db` hostname resolves. Coolify manages the Traefik
|
||||
labels + TLS from the domain you set.
|
||||
|
||||
**1b. Manual host deploy (fallback):** check this repo out on the host and run `deploy.sh`
|
||||
— it builds the image, derives the read-only `DATABASE_URL` from the running stack, and
|
||||
runs a standalone Traefik bridge. See the script header.
|
||||
|
||||
## Add to Claude (for analysts)
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http fireside-analytics https://fleetmcp.rahamafresh.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
|
||||
`Authorization: Bearer <your-token>` header.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
DATABASE_URL=postgresql://... MCP_AUTH_TOKENS=me:dev uv run uvicorn analytics_mcp:app --port 8892
|
||||
```
|
||||
|
||||
Full reference, security notes, and verification checklist: [`docs/ANALYTICS_MCP.md`](docs/ANALYTICS_MCP.md).
|
||||
279
analytics_mcp.py
Normal file
279
analytics_mcp.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""
|
||||
analytics_mcp_rev.py — Fireside Communications · Read-only Analytics MCP Server
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Hosted MCP server for the decision & analytics team. Exposes the fleet reporting
|
||||
data (reporting.* + tracksolid.*) to Claude as READ-ONLY query + introspection
|
||||
tools — for reporting and decisions, never edit/delete.
|
||||
|
||||
It is a STANDALONE Traefik-labelled bridge (not Coolify-managed), the same shape
|
||||
as the dashboard_api staging bridge: it reuses the webhook_receiver image, joins
|
||||
the `coolify` network, and connects to the internal DB over psycopg2 as the
|
||||
dedicated read-only `analytics_ro` role (deploy_analytics_mcp.sh sets DATABASE_URL
|
||||
to that DSN). Served over streamable HTTP with Bearer-token auth.
|
||||
|
||||
READ-ONLY is enforced at FOUR layers:
|
||||
1. the analytics_ro GRANTs (no INSERT/UPDATE/DELETE; not the matview owner)
|
||||
2. role + connection default_transaction_read_only = on
|
||||
3. every query runs in a transaction that is ROLLED BACK (never committed)
|
||||
4. the `query` tool's single-statement / keyword guard (clean errors, not DB faults)
|
||||
|
||||
Env:
|
||||
DATABASE_URL analytics_ro DSN (set by the deploy script)
|
||||
MCP_AUTH_TOKENS "alice:tok1,bob:tok2" — per-analyst Bearer tokens (revocable + audited)
|
||||
MCP_MAX_ROWS hard ceiling on rows returned (default 10000)
|
||||
MCP_POOL_MAX max read-only pool connections (default 8)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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
|
||||
|
||||
|
||||
def _get_logger(name: str) -> logging.Logger:
|
||||
"""Standalone logger mirroring ts_shared_rev's format. Intentionally NOT
|
||||
importing ts_shared_rev: that module eagerly requires the Tracksolid ingestion
|
||||
secrets (APP_KEY/SECRET/PWD), which this read-only analytics server has no
|
||||
business holding."""
|
||||
root = logging.getLogger("analytics_mcp")
|
||||
if not root.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
root.addHandler(handler)
|
||||
root.setLevel(logging.INFO)
|
||||
return root.getChild(name)
|
||||
|
||||
|
||||
log = _get_logger("server")
|
||||
|
||||
DATABASE_URL = os.environ["DATABASE_URL"] # analytics_ro DSN (set by deploy)
|
||||
MAX_ROWS_CEIL = int(os.getenv("MCP_MAX_ROWS", "10000"))
|
||||
READABLE_SCHEMAS = ("reporting", "tracksolid")
|
||||
|
||||
# ── 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
|
||||
# in case it is ever pointed at a less-restricted DSN).
|
||||
_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:
|
||||
try:
|
||||
conn.rollback()
|
||||
finally:
|
||||
_pool.putconn(conn)
|
||||
|
||||
|
||||
def _rows(cur) -> list[dict]:
|
||||
"""Materialise the cursor as a list of JSON-safe dicts."""
|
||||
if cur.description is None:
|
||||
return []
|
||||
cols = [d[0] for d in cur.description]
|
||||
out = []
|
||||
for row in cur.fetchall():
|
||||
out.append({c: _jsonable(v) for c, v in zip(cols, row)})
|
||||
return out
|
||||
|
||||
|
||||
def _jsonable(v):
|
||||
"""Coerce non-JSON-native values (dates, Decimal, etc.) to str."""
|
||||
if v is None or isinstance(v, (bool, int, float, str)):
|
||||
return v
|
||||
return str(v)
|
||||
|
||||
|
||||
# ── SQL guard for the general query tool ─────────────────────────────────────
|
||||
# The analytics_ro role + read-only txn already make writes impossible; this guard
|
||||
# exists to return CLEAN errors (and block multi-statements / SET that could relax
|
||||
# read-only) instead of letting the DB raise.
|
||||
_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) # block comments
|
||||
sql = re.sub(r"--[^\n]*", " ", sql) # line comments
|
||||
return sql.strip()
|
||||
|
||||
|
||||
def _guard(sql: str) -> str:
|
||||
"""Validate a single read-only statement; return the cleaned statement."""
|
||||
stripped = _strip_comments(sql)
|
||||
if not stripped:
|
||||
raise ValueError("Empty query.")
|
||||
parts = [p for p in stripped.split(";") if p.strip()] # allow one trailing ;
|
||||
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 server + tools ───────────────────────────────────────────────────────
|
||||
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.
|
||||
|
||||
Only the reporting.* and tracksolid.* schemas are readable. Single statement
|
||||
only; write/DDL is rejected. Returns up to `max_rows` rows (default 1000, hard
|
||||
cap 10000). A LIMIT is auto-applied when absent. Result: {row_count, truncated, rows}.
|
||||
"""
|
||||
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 row 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 (reporting, tracksolid) with their object 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 (must be reporting or tracksolid)."""
|
||||
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 argument 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)
|
||||
|
||||
|
||||
_IDENT = re.compile(r"^[a-z_][a-z0-9_]*$", re.IGNORECASE)
|
||||
|
||||
|
||||
@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}")
|
||||
if not _IDENT.match(table):
|
||||
raise ValueError("table must be a simple identifier")
|
||||
return query(f'SELECT * FROM "{schema}"."{table}"', max_rows=n)
|
||||
|
||||
|
||||
# ── Bearer-token auth ─────────────────────────────────────────────────────────
|
||||
# MCP_AUTH_TOKENS = "alice:tok1,bob:tok2" → {token: name}. Per-analyst tokens make
|
||||
# access revocable (edit the env + redeploy) and attributable in the logs.
|
||||
_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 ""
|
||||
caller = _TOKENS.get(token)
|
||||
if caller is None:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
request.state.caller = caller
|
||||
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)
|
||||
# Starlette exposes add_route (not a Flask-style @app.route decorator).
|
||||
app.add_route("/healthz", healthz, methods=["GET"])
|
||||
|
||||
|
||||
if not _TOKENS:
|
||||
log.warning("MCP_AUTH_TOKENS is empty — every request will be rejected with 401.")
|
||||
log.info("Analytics MCP starting. Tokens loaded=%d. Readable schemas=%s.", len(_TOKENS), READABLE_SCHEMAS)
|
||||
77
deploy.sh
Executable file
77
deploy.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
# deploy.sh — manual host deploy for the read-only Fleet Analytics MCP server.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Use this if you are NOT letting Coolify build the Dockerfile (see README §Deploy
|
||||
# for the Coolify-managed path, which is the recommended default). This script
|
||||
# builds the image from this repo ON THE HOST and runs it as a standalone
|
||||
# Traefik-labelled bridge — the same proven pattern as the dashboard_api bridges:
|
||||
# it joins the network that can reach timescale_db, derives a READ-ONLY DATABASE_URL
|
||||
# for the analytics_ro role, and exposes the MCP over HTTPS with Bearer auth.
|
||||
#
|
||||
# Prereqs ON THE HOST:
|
||||
# * the analytics_ro role exists -> scripts/bootstrap_analytics_ro.sh (writes ~/.analytics_ro.pw)
|
||||
# * this repo is checked out (e.g. ~/fleetanalytics_mcp) — run this script from inside it
|
||||
#
|
||||
# Run:
|
||||
# cd ~/fleetanalytics_mcp && git pull
|
||||
# MCP_AUTH_TOKENS="alice:$(openssl rand -hex 16)" bash deploy.sh
|
||||
#
|
||||
# A token/env change needs a container RECREATE — this script does that. Record
|
||||
# each analyst's token securely; it is only shown once (when you generate it).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
NAME=analytics_mcp
|
||||
PORT=8892
|
||||
HOST_DOMAIN="${HOST_DOMAIN:-fleetmcp.fivetitude.com}" # prod: fleetmcp.rahamafresh.com
|
||||
IMAGE="fleetanalytics-mcp:latest"
|
||||
ENV_FILE="$(pwd)/.deploy.env"
|
||||
: "${MCP_AUTH_TOKENS:?set MCP_AUTH_TOKENS=name:token[,name:token...] before running (per-analyst Bearer tokens)}"
|
||||
|
||||
# Resolve the network + DB DSN from the running webhook_receiver (it sits on the
|
||||
# same internal network as timescale_db and holds a DATABASE_URL we can reuse the
|
||||
# host:port/dbname from). This avoids hardcoding the internal DB hostname.
|
||||
WH=$(docker ps --filter name=webhook_receiver --format "{{.Names}}" | head -1)
|
||||
[ -n "$WH" ] || { echo "ERROR: webhook_receiver container not found (need the tracksolid stack running)"; exit 1; }
|
||||
APPNET=$(docker inspect "$WH" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
|
||||
SRC_DB_URL=$(docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DATABASE_URL=//p' | head -1)
|
||||
[ -n "$SRC_DB_URL" ] || { echo "ERROR: DATABASE_URL not found in $WH env"; exit 1; }
|
||||
echo "Reusing network $APPNET (from $WH)"
|
||||
|
||||
# Build a READ-ONLY DATABASE_URL: app DB host:port/dbname + analytics_ro creds.
|
||||
RO_PW=$(cat "${ANALYTICS_RO_PW_FILE:-$HOME/.analytics_ro.pw}" 2>/dev/null || true)
|
||||
[ -n "$RO_PW" ] || { echo "ERROR: ~/.analytics_ro.pw missing — run scripts/bootstrap_analytics_ro.sh first"; exit 1; }
|
||||
HOSTPART="${SRC_DB_URL#*@}" # host:port/dbname[?params]
|
||||
RO_DB_URL="postgresql://analytics_ro:${RO_PW}@${HOSTPART}"
|
||||
|
||||
# Build the image from this repo.
|
||||
echo "Building $IMAGE ..."
|
||||
docker build -t "$IMAGE" .
|
||||
|
||||
# Minimal env (read-only DSN + auth only — no Tracksolid ingestion secrets).
|
||||
{ echo "DATABASE_URL=${RO_DB_URL}"; echo "MCP_AUTH_TOKENS=${MCP_AUTH_TOKENS}"; } > "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
docker rm -f "$NAME" 2>/dev/null || true
|
||||
docker run -d --name "$NAME" --restart unless-stopped \
|
||||
--network "$APPNET" \
|
||||
--env-file "$ENV_FILE" \
|
||||
--label 'traefik.enable=true' \
|
||||
--label 'traefik.docker.network=coolify' \
|
||||
--label 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https' \
|
||||
--label "traefik.http.routers.http-0-fleetmcp.entryPoints=http" \
|
||||
--label "traefik.http.routers.http-0-fleetmcp.middlewares=redirect-to-https" \
|
||||
--label "traefik.http.routers.http-0-fleetmcp.rule=Host(\`${HOST_DOMAIN}\`)" \
|
||||
--label "traefik.http.routers.https-0-fleetmcp.entryPoints=https" \
|
||||
--label "traefik.http.routers.https-0-fleetmcp.rule=Host(\`${HOST_DOMAIN}\`)" \
|
||||
--label "traefik.http.routers.https-0-fleetmcp.tls=true" \
|
||||
--label "traefik.http.routers.https-0-fleetmcp.tls.certresolver=letsencrypt" \
|
||||
--label "traefik.http.services.fleetmcp.loadbalancer.server.port=${PORT}" \
|
||||
"$IMAGE"
|
||||
|
||||
docker network connect coolify "$NAME" 2>/dev/null || true
|
||||
rm -f "$ENV_FILE"
|
||||
sleep 5
|
||||
echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}"
|
||||
echo "== DB role (expect analytics_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:<pw>@#"'
|
||||
echo "== health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/healthz" 2>&1 | head
|
||||
223
docs/ANALYTICS_MCP.html
Normal file
223
docs/ANALYTICS_MCP.html
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Read-only Analytics MCP Server — Implementation Guide</title>
|
||||
<style>
|
||||
:root{--bg:#0d1117;--panel:#161b22;--bd:#30363d;--fg:#e6edf3;--mut:#9da7b3;--acc:#3b82f6;--grn:#3fb950;--amb:#d29922;--red:#f85149;--mono:'SF Mono',ui-monospace,Menlo,Consolas,monospace}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,Segoe UI,Roboto,sans-serif}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:34px 22px 90px}
|
||||
h1{font-size:30px;margin:0 0 4px} h2{font-size:22px;margin:42px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--bd)}
|
||||
h3{font-size:17px;margin:26px 0 8px;color:#c9d4e0}
|
||||
.sub{color:var(--mut)} a{color:var(--acc);text-decoration:none} a:hover{text-decoration:underline}
|
||||
code,.mono{font-family:var(--mono);font-size:13px}
|
||||
p code,li code,td code{background:#1c232c;border:1px solid var(--bd);border-radius:5px;padding:.5px 5px;font-size:12.5px}
|
||||
pre{background:#10151c;border:1px solid var(--bd);border-radius:8px;padding:14px 16px;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55}
|
||||
pre .c{color:var(--mut)} pre .k{color:#ff7b72} pre .s{color:#a5d6ff} pre .f{color:#d2a8ff}
|
||||
table{width:100%;border-collapse:collapse;margin:8px 0 18px;background:var(--panel);border:1px solid var(--bd);border-radius:8px;overflow:hidden}
|
||||
th,td{text-align:left;padding:8px 11px;border-bottom:1px solid var(--bd);vertical-align:top;font-size:13.5px}
|
||||
th{background:#1c232c;color:#c9d4e0;font-weight:600}
|
||||
.flow{display:flex;flex-wrap:wrap;gap:10px;align-items:stretch;margin:14px 0}
|
||||
.node{background:var(--panel);border:1px solid var(--bd);border-radius:10px;padding:12px 14px;min-width:150px}
|
||||
.node b{display:block;color:#fff} .node small{color:var(--mut)}
|
||||
.arrow{display:flex;align-items:center;color:var(--acc);font-weight:700;font-size:20px}
|
||||
.note{background:#13202b;border-left:3px solid var(--acc);padding:10px 14px;border-radius:6px;margin:12px 0}
|
||||
.warn{background:#241d10;border-left:3px solid var(--amb);padding:10px 14px;border-radius:6px;margin:12px 0}
|
||||
.pill{display:inline-block;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--bd);color:var(--mut)}
|
||||
.pill.new{color:var(--grn);border-color:#27512f} .pill.edit{color:var(--amb);border-color:#5a4a1f}
|
||||
ul.chk{list-style:none;padding-left:0} ul.chk li{padding:3px 0 3px 26px;position:relative}
|
||||
ul.chk li:before{content:"☐";position:absolute;left:0;color:var(--mut)}
|
||||
.muted{color:var(--mut);font-size:13px}
|
||||
.lh{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
|
||||
</style></head><body><div class="wrap">
|
||||
|
||||
<h1>Read-only Analytics MCP Server</h1>
|
||||
<p class="sub">Implementation guide · standalone repo <code>fleetanalytics_mcp</code>, hosted on the <code>tracksolid_db</code> Coolify host · 2026-06-16 · <span class="pill">built — pending deploy</span></p>
|
||||
|
||||
<h2>1. Purpose & context</h2>
|
||||
<p>The decision & analytics team needs to pull fleet reporting data (fuel, utilisation,
|
||||
driver behaviour, INC tickets, raw telemetry) from <code>tracksolid_db</code> to make
|
||||
decisions — <b>read-only, never edit/delete</b>. The only programmatic surface today is the
|
||||
<code>dashboard_api</code> FastAPI bridge with a fixed set of <code>/analytics/*</code> /
|
||||
<code>/webhook/*</code> endpoints — too rigid for ad-hoc analysis.</p>
|
||||
<p>This adds a <b>hosted, read-only MCP server</b> that lets analysts query the database
|
||||
directly from Claude: a guarded general <code>SELECT</code> tool plus schema-introspection
|
||||
tools, pointed at the existing PostgreSQL 16 + TimescaleDB + PostGIS database through a
|
||||
<b>new least-privilege <code>analytics_ro</code> role</b>.</p>
|
||||
<p>The DB is internal-only (<code>DATABASE_URL</code> → <code>timescale_db:5432</code> on the
|
||||
Docker network, not reachable from a laptop), so the server is <b>hosted on the same Coolify
|
||||
host as the DB</b>. It ships as its own repo with its own <code>Dockerfile</code>
|
||||
(Coolify-buildable) and joins the network that can reach <code>timescale_db</code>; a
|
||||
<code>deploy.sh</code> manual fallback mirrors the proven <code>dashboard_api</code> bridge pattern.</p>
|
||||
<div class="note"><b>Read-only is enforced at four layers:</b> the <code>analytics_ro</code>
|
||||
GRANTs (no INSERT/UPDATE/DELETE) · a session <code>default_transaction_read_only = on</code>
|
||||
· a transaction that is <b>rolled back</b> (never committed) · a single-statement / keyword
|
||||
SQL guard in the <code>query</code> tool.</div>
|
||||
|
||||
<h3>Where this sits</h3>
|
||||
<div class="flow">
|
||||
<div class="node"><b>Analyst's Claude</b><small>Code / Desktop / claude.ai</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><b>Traefik</b><small>fleetmcp.fivetitude.com · HTTPS + Bearer</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><b>analytics_mcp</b><small>uvicorn :8892 · coolify net<br>role = analytics_ro · READ ONLY</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><b>timescale_db:5432</b><small>tracksolid_db<br>reporting.* · tracksolid.*</small></div>
|
||||
</div>
|
||||
<p class="muted">Ports in use: <code>8890</code> prod dashboard_api · <code>8891</code> staging dashboard_api · <b><code>8892</code> analytics_mcp</b>.</p>
|
||||
|
||||
<h2>2. Repo contents</h2>
|
||||
<table>
|
||||
<tr><th>File</th><th>What</th></tr>
|
||||
<tr><td><code>analytics_mcp.py</code></td><td>the MCP server (FastMCP streamable-HTTP; uvicorn target <code>analytics_mcp:app</code>)</td></tr>
|
||||
<tr><td><code>Dockerfile</code></td><td>Coolify-buildable image (port 8892)</td></tr>
|
||||
<tr><td><code>pyproject.toml</code></td><td>deps (<code>mcp[cli]</code>, <code>psycopg2-binary</code>, <code>uvicorn</code>)</td></tr>
|
||||
<tr><td><code>deploy.sh</code></td><td>manual host deploy (standalone Traefik bridge) — fallback to Coolify</td></tr>
|
||||
<tr><td><code>scripts/analytics_ro_role.sql</code></td><td>read-only role DDL (modelled on the backend's <code>dashboard_ro_role.sql</code> + hardening)</td></tr>
|
||||
<tr><td><code>scripts/bootstrap_analytics_ro.sh</code></td><td>host bootstrap: generate pw → apply role SQL</td></tr>
|
||||
<tr><td><code>docs/ANALYTICS_MCP.md</code> / <code>.html</code></td><td>this guide</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>3. Step 1 — the <code>analytics_ro</code> role</h2>
|
||||
<p>Modelled on <code>scripts/dashboard_ro_role.sql</code>. Run as the <b>postgres
|
||||
superuser</b> (it does <code>CREATE ROLE</code>), with the password supplied as psql var
|
||||
<code>:'ro_pw'</code> — <b>no secret in the repo</b>.</p>
|
||||
<h3><code>scripts/analytics_ro_role.sql</code></h3>
|
||||
<pre><span class="c">-- read-only LOGIN role for the analytics MCP server. Apply via bootstrap_analytics_ro.sh.</span>
|
||||
\set ON_ERROR_STOP on
|
||||
<span class="k">DO</span> $role$ <span class="k">BEGIN</span>
|
||||
<span class="k">IF NOT EXISTS</span> (<span class="k">SELECT</span> 1 <span class="k">FROM</span> pg_roles <span class="k">WHERE</span> rolname = <span class="s">'analytics_ro'</span>) <span class="k">THEN</span>
|
||||
<span class="k">CREATE ROLE</span> analytics_ro LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||
<span class="k">END IF</span>; <span class="k">END</span> $role$;
|
||||
<span class="k">ALTER ROLE</span> analytics_ro <span class="k">WITH</span> LOGIN PASSWORD :'ro_pw';
|
||||
|
||||
<span class="k">GRANT</span> CONNECT <span class="k">ON DATABASE</span> tracksolid_db <span class="k">TO</span> analytics_ro;
|
||||
<span class="k">GRANT</span> USAGE <span class="k">ON SCHEMA</span> reporting, tracksolid <span class="k">TO</span> analytics_ro;
|
||||
<span class="k">GRANT</span> SELECT <span class="k">ON ALL TABLES IN SCHEMA</span> reporting <span class="k">TO</span> analytics_ro; <span class="c">-- tables + views</span>
|
||||
<span class="k">GRANT</span> SELECT <span class="k">ON ALL TABLES IN SCHEMA</span> tracksolid <span class="k">TO</span> analytics_ro;
|
||||
<span class="k">GRANT</span> SELECT <span class="k">ON</span> reporting.v_trips <span class="k">TO</span> analytics_ro; <span class="c">-- matview (not in ALL TABLES)</span>
|
||||
<span class="k">GRANT</span> EXECUTE <span class="k">ON ALL FUNCTIONS IN SCHEMA</span> reporting <span class="k">TO</span> analytics_ro;
|
||||
<span class="c">-- future objects auto-grant</span>
|
||||
<span class="k">ALTER DEFAULT PRIVILEGES FOR ROLE</span> tracksolid_owner <span class="k">IN SCHEMA</span> reporting <span class="k">GRANT</span> SELECT <span class="k">ON TABLES TO</span> analytics_ro;
|
||||
<span class="k">ALTER DEFAULT PRIVILEGES FOR ROLE</span> tracksolid_owner <span class="k">IN SCHEMA</span> tracksolid <span class="k">GRANT</span> SELECT <span class="k">ON TABLES TO</span> analytics_ro;
|
||||
<span class="k">ALTER DEFAULT PRIVILEGES FOR ROLE</span> tracksolid_owner <span class="k">IN SCHEMA</span> reporting <span class="k">GRANT</span> EXECUTE <span class="k">ON FUNCTIONS TO</span> analytics_ro;
|
||||
<span class="c">-- extra hardening over dashboard_ro: this role serves ad-hoc HUMAN queries</span>
|
||||
<span class="k">ALTER ROLE</span> analytics_ro <span class="k">SET</span> default_transaction_read_only = on;
|
||||
<span class="k">ALTER ROLE</span> analytics_ro <span class="k">SET</span> statement_timeout = <span class="s">'30s'</span>;
|
||||
<span class="k">ALTER ROLE</span> analytics_ro <span class="k">SET</span> idle_in_transaction_session_timeout = <span class="s">'60s'</span>;</pre>
|
||||
<h3><code>scripts/bootstrap_analytics_ro.sh</code></h3>
|
||||
<p>Clone of <code>bootstrap_dashboard_ro.sh</code> — generates <code>~/.analytics_ro.pw</code>
|
||||
(0600) on first run, applies the SQL via <code>docker exec … psql -v ro_pw=…</code>. The
|
||||
password is never printed and never leaves the host.</p>
|
||||
|
||||
<h2>4. The MCP server (<code>analytics_mcp.py</code>)</h2>
|
||||
<p>FastMCP streamable-HTTP server, served by uvicorn (target <code>analytics_mcp:app</code>).
|
||||
It uses its <b>own</b> read-only psycopg2 pool and a small local logger — it deliberately does
|
||||
<b>not</b> import the backend's <code>ts_shared_rev</code> (that module eagerly requires the
|
||||
Tracksolid ingestion secrets, which this read-only server has no business holding). Tools exposed:</p>
|
||||
<table>
|
||||
<tr><th>Tool</th><th>Purpose</th></tr>
|
||||
<tr><td><code>query(sql, max_rows=1000)</code></td><td>guarded read-only SELECT/WITH; single statement, keyword-blocked, auto-LIMIT; returns rows + <code>truncated</code> flag</td></tr>
|
||||
<tr><td><code>list_schemas()</code></td><td>readable schemas (<code>reporting</code>, <code>tracksolid</code>) + object counts</td></tr>
|
||||
<tr><td><code>list_tables(schema)</code></td><td>tables + views in a schema</td></tr>
|
||||
<tr><td><code>describe_table(schema, table)</code></td><td>columns, types, nullability, defaults</td></tr>
|
||||
<tr><td><code>list_functions(schema='reporting')</code></td><td><code>reporting.fn_*</code> signatures</td></tr>
|
||||
<tr><td><code>sample_table(schema, table, n=20)</code></td><td>first <code>n</code> rows (thin wrapper over <code>query</code>)</td></tr>
|
||||
</table>
|
||||
<p>The core guard + connection logic:</p>
|
||||
<pre><span class="c"># read-only pool: force read-only + statement timeout at connection level (belt + braces)</span>
|
||||
_pool = psycopg2.pool.ThreadedConnectionPool(1, 8, DATABASE_URL,
|
||||
options=<span class="s">"-c default_transaction_read_only=on -c statement_timeout=30000"</span>)
|
||||
|
||||
<span class="f">@contextmanager</span>
|
||||
<span class="k">def</span> _ro_conn(): <span class="c"># txn is ALWAYS rolled back — never commits</span>
|
||||
conn = _pool.getconn()
|
||||
<span class="k">try</span>:
|
||||
conn.set_session(readonly=<span class="k">True</span>, autocommit=<span class="k">False</span>)
|
||||
<span class="k">yield</span> conn
|
||||
<span class="k">finally</span>:
|
||||
conn.rollback(); _pool.putconn(conn)
|
||||
|
||||
<span class="k">def</span> _guard(sql): <span class="c"># single SELECT/WITH, no write/DDL keywords</span>
|
||||
stmt = _strip_comments(sql)
|
||||
parts = [p <span class="k">for</span> p <span class="k">in</span> stmt.split(<span class="s">";"</span>) <span class="k">if</span> p.strip()]
|
||||
<span class="k">if</span> len(parts) != 1: <span class="k">raise</span> ValueError(<span class="s">"Only a single statement is allowed."</span>)
|
||||
stmt = parts[0].strip()
|
||||
<span class="k">if not</span> re.match(<span class="s">r"^(select|with)\b"</span>, stmt, re.I): <span class="k">raise</span> ValueError(<span class="s">"Only SELECT/WITH allowed."</span>)
|
||||
<span class="k">if</span> _FORBIDDEN.search(stmt): <span class="k">raise</span> ValueError(<span class="s">"Forbidden (write/DDL) keyword."</span>)
|
||||
<span class="k">return</span> stmt</pre>
|
||||
<p>Auth is a Starlette <code>BaseHTTPMiddleware</code> that requires
|
||||
<code>Authorization: Bearer <token></code>. Tokens come from env
|
||||
<code>MCP_AUTH_TOKENS="alice:tok1,bob:tok2"</code> (per-analyst → revocable + attributable in
|
||||
logs); <code>/healthz</code> is exempt. The app is mounted via
|
||||
<code>app = mcp.streamable_http_app()</code>, then <code>app.add_middleware(BearerAuth)</code>
|
||||
and <code>app.add_route("/healthz", …)</code> (Starlette exposes <code>add_route</code>, not a
|
||||
Flask-style <code>@app.route</code> decorator — verified against the installed <code>mcp</code>).</p>
|
||||
<div class="note">Full, current source is the repo's <code>analytics_mcp.py</code>; the excerpt
|
||||
above is abridged.</div>
|
||||
|
||||
<h2>5. Packaging — <code>Dockerfile</code> + <code>pyproject.toml</code></h2>
|
||||
<p>Self-contained: <code>pyproject.toml</code> declares the deps (<code>mcp[cli]</code>,
|
||||
<code>psycopg2-binary</code>, <code>uvicorn[standard]</code>) and the <code>Dockerfile</code>
|
||||
builds a slim image running <code>uvicorn analytics_mcp:app</code> on port 8892. The project is
|
||||
a flat single module, so <code>[tool.uv] package = false</code> and the Dockerfile installs
|
||||
<b>dependencies only</b> (<code>uv sync --no-dev --no-install-project</code>) — no dependency on
|
||||
the backend image.</p>
|
||||
|
||||
<h2>6. Deploy</h2>
|
||||
<p>The DB is internal-only, so the server runs on the <b>same Coolify host as
|
||||
<code>timescale_db</code></b>.</p>
|
||||
<p><b>Recommended — Coolify-managed app.</b> Create a Coolify app from this repo, Dockerfile
|
||||
build, app port <code>8892</code>, domain <code>fleetmcp.rahamafresh.com</code> (prod) /
|
||||
<code>fleetmcp.fivetitude.com</code> (staging). Set secrets
|
||||
<code>DATABASE_URL=postgresql://analytics_ro:<pw>@timescale_db:5432/tracksolid_db</code> and
|
||||
<code>MCP_AUTH_TOKENS=alice:<tok>,bob:<tok></code>, then <b>connect the app to the network
|
||||
that can reach <code>timescale_db</code></b> so the hostname resolves. Coolify manages Traefik +
|
||||
TLS from the domain; auto-deploys on push via the Forgejo webhook.</p>
|
||||
<p><b>Fallback — <code>deploy.sh</code>.</b> 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
|
||||
<code>analytics_ro</code> credentials, and runs a standalone Traefik bridge.</p>
|
||||
<pre>cd ~/fleetanalytics_mcp && git pull
|
||||
MCP_AUTH_TOKENS=<span class="s">"alice:$(openssl rand -hex 16)"</span> bash deploy.sh</pre>
|
||||
|
||||
<h2>7. Deploy runbook (ordered)</h2>
|
||||
<ol>
|
||||
<li><b>Role (once):</b> <code>scp</code> the role SQL + bootstrap to <code>twala.rahamafresh.com</code>, run <code>bootstrap_analytics_ro.sh</code> (writes <code>~/.analytics_ro.pw</code>).</li>
|
||||
<li><b>App:</b> point Coolify at this repo (§6) or run <code>deploy.sh</code> on the host. Record each analyst's token (shown once).</li>
|
||||
<li><b>Network:</b> ensure the MCP container shares a Docker network with <code>timescale_db</code> so the DSN host resolves.</li>
|
||||
<li><b>DNS/Traefik:</b> ensure <code>fleetmcp.*</code> resolves to the host; Coolify/Traefik issues the cert.</li>
|
||||
</ol>
|
||||
|
||||
<h2>8. Add to Claude (for analysts)</h2>
|
||||
<pre><span class="c"># Claude Code</span>
|
||||
claude mcp add --transport http fireside-analytics https://fleetmcp.fivetitude.com \
|
||||
--header <span class="s">"Authorization: Bearer <your-token>"</span>
|
||||
claude mcp list <span class="c"># → "fireside-analytics: connected"</span></pre>
|
||||
<p><b>Claude Desktop / claude.ai:</b> add a custom connector with the same URL and an
|
||||
<code>Authorization: Bearer <your-token></code> header. Example prompts: <i>"list the
|
||||
schemas"</i>, <i>"describe reporting.v_daily_summary"</i>, <i>"top 10 cost centres by distance
|
||||
in the last 30 days"</i>.</p>
|
||||
|
||||
<h2>9. Verification checklist</h2>
|
||||
<ul class="chk">
|
||||
<li><code>psql -U analytics_ro … "SELECT count(*) FROM reporting.v_daily_summary"</code> <b>succeeds</b>.</li>
|
||||
<li><code>psql -U analytics_ro … "CREATE TABLE x(i int)"</code> <b>fails</b> (permission denied) — proves read-only.</li>
|
||||
<li>the image builds (<code>docker build .</code> or Coolify build); <code>analytics_mcp</code> is <code>Up</code>; the container can reach <code>timescale_db</code>.</li>
|
||||
<li><code>DATABASE_URL</code> shows <code>analytics_ro</code> (pw masked); <code>curl localhost:8892/healthz</code> returns <code>{"ok":true,…}</code>.</li>
|
||||
<li><code>claude mcp list</code> shows connected; <code>list_schemas</code> / <code>describe_table</code> / a real <code>query</code> return data.</li>
|
||||
<li><code>query("UPDATE reporting.refresh_log …")</code> is <b>rejected</b> by the guard.</li>
|
||||
<li>A request with a missing/bad bearer token returns <b>401</b>.</li>
|
||||
<li><code>docker logs analytics_mcp</code> shows one audit line per query (caller, SQL, rows, ms).</li>
|
||||
</ul>
|
||||
|
||||
<h2>10. Security notes</h2>
|
||||
<ul>
|
||||
<li><b>Four read-only layers:</b> role GRANTs · <code>default_transaction_read_only=on</code> (role + connection) · rolled-back txn · SQL keyword guard.</li>
|
||||
<li><b>Least privilege:</b> <code>analytics_ro</code> only has <code>USAGE</code>+<code>SELECT</code> on <code>reporting</code>/<code>tracksolid</code> and <code>EXECUTE</code> on <code>reporting</code> functions.</li>
|
||||
<li><b>Per-analyst tokens</b> make access revocable and queries attributable; rotate via <code>MCP_AUTH_TOKENS</code> + redeploy (recreate).</li>
|
||||
<li><b>Resource guards:</b> <code>statement_timeout=30s</code>, idle-txn timeout, row cap (1000 default / 10000 ceiling).</li>
|
||||
<li><b>Future:</b> swap static Bearer for OAuth if the team scales; add a column deny-list if PII lives in <code>tracksolid.*</code>.</li>
|
||||
</ul>
|
||||
|
||||
<p class="muted" style="margin-top:40px">Companion file: <code>docs/ANALYTICS_MCP.md</code> (full source for all four new files).</p>
|
||||
</div></body></html>
|
||||
425
docs/ANALYTICS_MCP.md
Normal file
425
docs/ANALYTICS_MCP.md
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
# Read-only Analytics MCP Server — Implementation Guide
|
||||
|
||||
> **Audience:** engineer deploying/maintaining the server. **Status:** built — pending deploy.
|
||||
> **Repo:** `fleetanalytics_mcp` (standalone; `repo.rahamafresh.com/kianiadee/fleetanalytics_mcp.git`).
|
||||
> Hosted on the same Coolify host as `tracksolid_db`. **Date:** 2026-06-16.
|
||||
|
||||
## 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_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.*
|
||||
```
|
||||
|
||||
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.*, the v_trips
|
||||
-- matview, and EXECUTE on reporting.fn_*. 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 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 reporting.v_trips TO analytics_ro; -- MATERIALIZED VIEW
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO analytics_ro;
|
||||
|
||||
-- Future objects created by the migration role auto-grant (matviews still need 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;
|
||||
|
||||
-- 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.*) 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"))
|
||||
READABLE_SCHEMAS = ("reporting", "tracksolid")
|
||||
|
||||
# ── 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.
|
||||
Only the reporting.* and tracksolid.* schemas are readable. 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 (reporting or tracksolid)."""
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
cd ~/fleetanalytics_mcp && git pull
|
||||
MCP_AUTH_TOKENS="alice:$(openssl rand -hex 16)" bash deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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"* (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` and `EXECUTE` on `reporting` functions — no other schema, no write of any kind.
|
||||
- **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.*`.
|
||||
35
pyproject.toml
Normal file
35
pyproject.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
[project]
|
||||
name = "fleetanalytics-mcp"
|
||||
version = "1.0.0"
|
||||
description = "Fireside Communications — read-only Fleet Analytics MCP server (decision & analytics team)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [
|
||||
{ name = "Fireside DevOps", email = "devops@firesideafrica.cloud" }
|
||||
]
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.2", # MCP server SDK (FastMCP, streamable HTTP)
|
||||
"psycopg2-binary>=2.9.9", # Postgres driver (binary wheels — easy in Docker)
|
||||
"uvicorn[standard]>=0.30.0", # ASGI server
|
||||
"starlette>=0.37", # Bearer-auth middleware + /healthz route (pulled in by mcp, pinned for clarity)
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.4",
|
||||
"mypy>=1.10",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
# Flat single-module project (analytics_mcp.py) — don't try to build/install it as
|
||||
# a package; just manage the dependency venv.
|
||||
package = false
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
select = ["E", "W", "F", "B", "UP", "SIM"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
ignore_missing_imports = true
|
||||
55
scripts/analytics_ro_role.sql
Normal file
55
scripts/analytics_ro_role.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
-- analytics_ro_role.sql — dedicated read-only LOGIN role for the analytics MCP server.
|
||||
--
|
||||
-- Sibling of dashboard_ro_role.sql, but for the decision & analytics team's MCP
|
||||
-- server (analytics_mcp_rev.py) rather than the dashboard bridge. A separate role
|
||||
-- keeps the two access paths independently revocable and lets us apply tighter,
|
||||
-- human-ad-hoc-query guards (statement_timeout, idle-txn timeout) without touching
|
||||
-- the dashboard bridge's credential.
|
||||
--
|
||||
-- Run as the postgres SUPERUSER (CREATE ROLE), NOT via run_migrations.py (which
|
||||
-- connects as the app role and may lack CREATEROLE). Apply with
|
||||
-- scripts/bootstrap_analytics_ro.sh, which supplies the password as the psql
|
||||
-- variable :ro_pw from a host-only 0600 file — so no secret lives in this repo.
|
||||
--
|
||||
-- It grants exactly the read surface the MCP server needs:
|
||||
-- * SELECT on reporting.* and tracksolid.* (tables + views)
|
||||
-- * SELECT on the reporting.v_trips MATERIALIZED VIEW — matviews are NOT
|
||||
-- covered by GRANT ... ON ALL TABLES, so it must be named explicitly
|
||||
-- * EXECUTE on the reporting.fn_* functions (so analysts can SELECT reporting.fn_...)
|
||||
-- * DEFAULT PRIVILEGES so future objects created by the migration role are
|
||||
-- auto-readable (no re-grant when we add views)
|
||||
-- Read-only: no INSERT/UPDATE/DELETE and not the matview owner, so analytics_ro
|
||||
-- can never write or REFRESH. Idempotent -> safe to re-apply (also 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 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 reporting.v_trips TO analytics_ro; -- MATERIALIZED VIEW (not in ALL TABLES)
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO analytics_ro;
|
||||
|
||||
-- "dynamic": future objects created by the migration role (tracksolid_owner)
|
||||
-- are auto-granted. NOTE: matviews are still never covered — a new matview needs
|
||||
-- its own explicit GRANT SELECT (as above for v_trips).
|
||||
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;
|
||||
|
||||
-- Extra hardening over dashboard_ro: this role serves ad-hoc HUMAN queries via the
|
||||
-- MCP server, so pin read-only at the role level and cap runaway work. These are
|
||||
-- belt-and-braces alongside the read-only txn the server itself uses.
|
||||
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';
|
||||
39
scripts/bootstrap_analytics_ro.sh
Executable file
39
scripts/bootstrap_analytics_ro.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
# bootstrap_analytics_ro.sh — create/refresh the analytics_ro read-only role.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Run ON THE HOST. Generates a strong password into ~/.analytics_ro.pw (0600) on
|
||||
# first run (reused thereafter), then applies scripts/analytics_ro_role.sql to the
|
||||
# prod DB as the postgres superuser. The password is NEVER printed and never
|
||||
# leaves the host — the MCP deploy script (deploy_analytics_mcp.sh) reads the same
|
||||
# ~/.analytics_ro.pw.
|
||||
#
|
||||
# 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'
|
||||
#
|
||||
# Idempotent: re-running rotates nothing unless ~/.analytics_ro.pw is deleted
|
||||
# first (then it generates + sets a fresh password and you must redeploy the MCP).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
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 (scp scripts/analytics_ro_role.sql to ~ first)"; 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."
|
||||
Loading…
Reference in a new issue