Compare commits

...

2 commits

Author SHA1 Message Date
kiania
0355047fdd 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:37:25 +03:00
kiania
f83f67e73f feat(access): expose tickets + fuel schemas to analytics_ro (read-only)
The analytics_ro role only had USAGE/SELECT on reporting + tracksolid, so
the tickets schema (INC/CRQ, 8 tables + 1 view + 7 fns) and fuel schema
were invisible to the MCP server — queries failed with permission denied.

- analytics_ro_role.sql: GRANT USAGE/SELECT/EXECUTE on tickets + fuel.
  Default privileges for these are keyed to postgres (their owner), not
  tracksolid_owner, so future objects auto-grant correctly.
- analytics_mcp.py: READABLE_SCHEMAS now includes tickets + fuel and is
  overridable via MCP_READABLE_SCHEMAS, so the introspection helpers
  (list_tables/describe_table/sample_table) work for them too.
- deploy.sh: reuse existing analyst tokens from the running container when
  MCP_AUTH_TOKENS is unset, so a code-only redeploy needs no secret.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:37:25 +03:00
5 changed files with 148 additions and 29 deletions

View file

@ -64,7 +64,15 @@ log = _get_logger("server")
DATABASE_URL = os.environ["DATABASE_URL"] # analytics_ro DSN (set by deploy) DATABASE_URL = os.environ["DATABASE_URL"] # analytics_ro DSN (set by deploy)
MAX_ROWS_CEIL = int(os.getenv("MCP_MAX_ROWS", "10000")) MAX_ROWS_CEIL = int(os.getenv("MCP_MAX_ROWS", "10000"))
READABLE_SCHEMAS = ("reporting", "tracksolid") # Schemas the introspection helpers (list_tables/describe_table/sample_table) expose.
# Override with MCP_READABLE_SCHEMAS="reporting,tracksolid,tickets,fuel" — these must
# stay in sync with the GRANTs in scripts/analytics_ro_role.sql. The raw query() tool
# is bounded by the analytics_ro role's GRANTs, not by 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 ──────────────────────────────────────────────── # ── Read-only connection pool ────────────────────────────────────────────────
# Force read-only + a statement timeout at the connection level (belt + braces; # Force read-only + a statement timeout at the connection level (belt + braces;

View file

@ -26,6 +26,15 @@ PORT=8892
HOST_DOMAIN="${HOST_DOMAIN:-fleetmcp.fivetitude.com}" # prod: fleetmcp.rahamafresh.com HOST_DOMAIN="${HOST_DOMAIN:-fleetmcp.fivetitude.com}" # prod: fleetmcp.rahamafresh.com
IMAGE="fleetanalytics-mcp:latest" IMAGE="fleetanalytics-mcp:latest"
ENV_FILE="$(pwd)/.deploy.env" ENV_FILE="$(pwd)/.deploy.env"
# Per-analyst Bearer tokens. For a CODE-ONLY redeploy you can omit MCP_AUTH_TOKENS:
# we reuse the tokens from the currently running container so existing analysts keep
# working and no secret has to be re-typed or printed. Only set MCP_AUTH_TOKENS when
# you are adding/rotating/revoking a token.
if [ -z "${MCP_AUTH_TOKENS:-}" ]; then
MCP_AUTH_TOKENS="$(docker inspect "$NAME" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | sed -n 's/^MCP_AUTH_TOKENS=//p' | head -1)"
[ -n "$MCP_AUTH_TOKENS" ] && echo "Reusing existing analyst tokens from running $NAME container."
fi
: "${MCP_AUTH_TOKENS:?set MCP_AUTH_TOKENS=name:token[,name:token...] before running (per-analyst Bearer tokens)}" : "${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 # Resolve the network + DB DSN from the running webhook_receiver (it sits on the

View file

@ -32,7 +32,7 @@ ul.chk li:before{content:"☐";position:absolute;left:0;color:var(--mut)}
</style></head><body><div class="wrap"> </style></head><body><div class="wrap">
<h1>Read-only Analytics MCP Server</h1> <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> <p class="sub">Implementation guide · standalone repo <code>fleetanalytics_mcp</code>, hosted on the <code>tracksolid_db</code> Coolify host · updated 2026-06-17 · <span class="pill new">deployed &amp; live</span></p>
<h2>1. Purpose &amp; context</h2> <h2>1. Purpose &amp; context</h2>
<p>The decision &amp; analytics team needs to pull fleet reporting data (fuel, utilisation, <p>The decision &amp; analytics team needs to pull fleet reporting data (fuel, utilisation,
@ -62,7 +62,7 @@ SQL guard in the <code>query</code> tool.</div>
<div class="arrow"></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="node"><b>analytics_mcp</b><small>uvicorn :8892 · coolify net<br>role = analytics_ro · READ ONLY</small></div>
<div class="arrow"></div> <div class="arrow"></div>
<div class="node"><b>timescale_db:5432</b><small>tracksolid_db<br>reporting.* · tracksolid.*</small></div> <div class="node"><b>timescale_db:5432</b><small>tracksolid_db<br>reporting.* · tracksolid.* · tickets.* · fuel.*</small></div>
</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> <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>
@ -92,15 +92,23 @@ superuser</b> (it does <code>CREATE ROLE</code>), with the password supplied as
<span class="k">ALTER ROLE</span> analytics_ro <span class="k">WITH</span> LOGIN PASSWORD :'ro_pw'; <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> 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> USAGE <span class="k">ON SCHEMA</span> reporting, tracksolid, tickets, fuel <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> 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 ALL TABLES IN SCHEMA</span> tracksolid <span class="k">TO</span> analytics_ro;
<span class="k">GRANT</span> SELECT <span class="k">ON ALL TABLES IN SCHEMA</span> tickets <span class="k">TO</span> analytics_ro; <span class="c">-- INC/CRQ tickets</span>
<span class="k">GRANT</span> SELECT <span class="k">ON ALL TABLES IN SCHEMA</span> fuel <span class="k">TO</span> analytics_ro; <span class="c">-- fuel</span>
<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> 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="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">GRANT</span> EXECUTE <span class="k">ON ALL FUNCTIONS IN SCHEMA</span> tickets <span class="k">TO</span> analytics_ro;
<span class="k">GRANT</span> EXECUTE <span class="k">ON ALL FUNCTIONS IN SCHEMA</span> fuel <span class="k">TO</span> analytics_ro;
<span class="c">-- future objects auto-grant; key to the OWNER role (postgres for tickets/fuel)</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> 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> 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="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="k">ALTER DEFAULT PRIVILEGES FOR ROLE</span> postgres <span class="k">IN SCHEMA</span> tickets <span class="k">GRANT</span> SELECT <span class="k">ON TABLES TO</span> analytics_ro;
<span class="k">ALTER DEFAULT PRIVILEGES FOR ROLE</span> postgres <span class="k">IN SCHEMA</span> fuel <span class="k">GRANT</span> SELECT <span class="k">ON TABLES TO</span> analytics_ro;
<span class="k">ALTER DEFAULT PRIVILEGES FOR ROLE</span> postgres <span class="k">IN SCHEMA</span> tickets <span class="k">GRANT</span> EXECUTE <span class="k">ON FUNCTIONS TO</span> analytics_ro;
<span class="k">ALTER DEFAULT PRIVILEGES FOR ROLE</span> postgres <span class="k">IN SCHEMA</span> fuel <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="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> 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> statement_timeout = <span class="s">'30s'</span>;
@ -118,7 +126,7 @@ Tracksolid ingestion secrets, which this read-only server has no business holdin
<table> <table>
<tr><th>Tool</th><th>Purpose</th></tr> <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>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_schemas()</code></td><td>readable schemas (<code>reporting</code>, <code>tracksolid</code>, <code>tickets</code>, <code>fuel</code>) + object counts</td></tr>
<tr><td><code>list_tables(schema)</code></td><td>tables + views in a schema</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>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>list_functions(schema='reporting')</code></td><td><code>reporting.fn_*</code> signatures</td></tr>
@ -156,6 +164,30 @@ Flask-style <code>@app.route</code> decorator — verified against the installed
<div class="note">Full, current source is the repo's <code>analytics_mcp.py</code>; the excerpt <div class="note">Full, current source is the repo's <code>analytics_mcp.py</code>; the excerpt
above is abridged.</div> above is abridged.</div>
<h3>4.1 Readable data surface</h3>
<p><code>analytics_ro</code> (and the helper tools, via <code>READABLE_SCHEMAS</code>, default
<code>reporting,tracksolid,tickets,fuel</code>, override with env <code>MCP_READABLE_SCHEMAS</code>)
can read:</p>
<table>
<tr><th>Schema</th><th>Owner</th><th>What's there</th></tr>
<tr><td><code>reporting</code></td><td><code>tracksolid_owner</code></td><td>curated views/matviews (<code>v_daily_summary</code>, <code>v_trips</code>, <code>v_monthly_cost_centre</code>, …) + <code>fn_*</code> functions</td></tr>
<tr><td><code>tracksolid</code></td><td><code>tracksolid_owner</code></td><td>raw ingestion tables (devices, positions, events, …)</td></tr>
<tr><td><code>tickets</code></td><td><code>postgres</code></td><td>INC/CRQ tickets: <code>inc</code>, <code>crq</code>, <code>closure_events</code>, <code>inc_daily_snapshot</code>, <code>geo_clusters</code>, <code>geo_locations</code>, <code>inc_open_sla</code> (view) + 7 functions</td></tr>
<tr><td><code>fuel</code></td><td><code>postgres</code></td><td><code>records</code>, <code>ingest_state</code> + 7 functions</td></tr>
</table>
<p><b>Adding a schema later</b> is config-only: <code>GRANT USAGE/SELECT/EXECUTE …</code> to
<code>analytics_ro</code> (persist in <code>analytics_ro_role.sql</code>), then add it to
<code>MCP_READABLE_SCHEMAS</code> and redeploy — no code change.</p>
<div class="warn"><b>Owner gotcha.</b> <code>ALTER DEFAULT PRIVILEGES FOR ROLE …</code> must name
the role that <i>owns</i> the schema's objects — <code>postgres</code> for
<code>tickets</code>/<code>fuel</code>, <code>tracksolid_owner</code> for
<code>reporting</code>/<code>tracksolid</code> — or future objects won't auto-grant.</div>
<div class="note"><b><code>tickets.inc</code> shape.</b> Each row has both typed columns
(<code>bucket</code>, <code>raw_status</code>, <code>normalized_status</code>, <code>sla_status</code>,
<code>region</code>, <code>cluster</code>, <code>owner</code>, <code>mttr</code>, <code>closed_at</code>,
<code>latitude</code>/<code>longitude</code>, <code>geog</code>/<code>geom</code>, …) <b>and</b> a
<code>raw</code> text blob with the original source fields. Query the <b>typed columns</b>, not <code>raw</code>.</div>
<h2>5. Packaging — <code>Dockerfile</code> + <code>pyproject.toml</code></h2> <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>, <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> <code>psycopg2-binary</code>, <code>uvicorn[standard]</code>) and the <code>Dockerfile</code>
@ -177,8 +209,16 @@ 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 <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 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> <code>analytics_ro</code> credentials, and runs a standalone Traefik bridge.</p>
<pre>cd ~/fleetanalytics_mcp &amp;&amp; git pull <pre><span class="c"># add/rotate a token (sets the full token list):</span>
MCP_AUTH_TOKENS=<span class="s">"alice:$(openssl rand -hex 16)"</span> bash deploy.sh</pre> cd ~/fleetanalytics_mcp &amp;&amp; git pull
MCP_AUTH_TOKENS=<span class="s">"alice:$(openssl rand -hex 16)"</span> bash deploy.sh
<span class="c"># code-only redeploy (e.g. a schema change): omit MCP_AUTH_TOKENS —</span>
<span class="c"># deploy.sh reuses the running container's tokens, no secret to re-type:</span>
cd ~/fleetanalytics_mcp &amp;&amp; git pull &amp;&amp; bash deploy.sh</pre>
<p class="muted">Optional env: <code>MCP_READABLE_SCHEMAS</code> (default
<code>reporting,tracksolid,tickets,fuel</code>) controls which schemas the introspection helpers
expose; <code>MCP_MAX_ROWS</code> (default 10000) the row ceiling.</p>
<h2>7. Deploy runbook (ordered)</h2> <h2>7. Deploy runbook (ordered)</h2>
<ol> <ol>
@ -196,7 +236,8 @@ claude mcp list <span class="c"># → "fireside-analytics: connected"</span
<p><b>Claude Desktop / claude.ai:</b> add a custom connector with the same URL and an <p><b>Claude Desktop / claude.ai:</b> add a custom connector with the same URL and an
<code>Authorization: Bearer &lt;your-token&gt;</code> header. Example prompts: <i>"list the <code>Authorization: Bearer &lt;your-token&gt;</code> header. Example prompts: <i>"list the
schemas"</i>, <i>"describe reporting.v_daily_summary"</i>, <i>"top 10 cost centres by distance schemas"</i>, <i>"describe reporting.v_daily_summary"</i>, <i>"top 10 cost centres by distance
in the last 30 days"</i>.</p> in the last 30 days"</i>, <i>"open INC tickets by region and SLA status from tickets.inc"</i>,
<i>"MTTR by cluster this month"</i>.</p>
<h2>9. Verification checklist</h2> <h2>9. Verification checklist</h2>
<ul class="chk"> <ul class="chk">
@ -213,7 +254,7 @@ in the last 30 days"</i>.</p>
<h2>10. Security notes</h2> <h2>10. Security notes</h2>
<ul> <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>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>Least privilege:</b> <code>analytics_ro</code> only has <code>USAGE</code>+<code>SELECT</code> on <code>reporting</code>/<code>tracksolid</code>/<code>tickets</code>/<code>fuel</code> and <code>EXECUTE</code> on those schemas' functions — no other schema (e.g. <code>infrastructure</code> stays unreadable), no write of any kind.</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>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>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> <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>

View file

@ -1,13 +1,15 @@
# Read-only Analytics MCP Server — Implementation Guide # Read-only Analytics MCP Server — Implementation Guide
> **Audience:** engineer deploying/maintaining the server. **Status:** built — pending deploy. > **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`). > **Repo:** `fleetanalytics_mcp` (standalone; `repo.rahamafresh.com/kianiadee/fleetanalytics_mcp.git`).
> Hosted on the same Coolify host as `tracksolid_db`. **Date:** 2026-06-16. > Hosted on the same Coolify host as `tracksolid_db`. **Last updated:** 2026-06-17
> (added `tickets` + `fuel` schemas).
## 1. Purpose & context ## 1. Purpose & context
The decision & analytics team needs to pull fleet reporting data (fuel, utilisation, driver 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, 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 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 bridge with a fixed set of `/analytics/*` / `/webhook/*` endpoints — too rigid for ad-hoc
analysis. analysis.
@ -37,7 +39,7 @@ analyst's Claude ──HTTPS (Bearer)──► fleetmcp.fivetitude.com (Traefi
│ psycopg2, role = analytics_ro, READ ONLY │ psycopg2, role = analytics_ro, READ ONLY
timescale_db:5432 (tracksolid_db) timescale_db:5432 (tracksolid_db)
reporting.* · tracksolid.* reporting.* · tracksolid.* · tickets.* · fuel.*
``` ```
Ports in use: `8890` prod dashboard_api · `8891` staging dashboard_api · **`8892` analytics_mcp**. Ports in use: `8890` prod dashboard_api · `8891` staging dashboard_api · **`8892` analytics_mcp**.
@ -71,9 +73,10 @@ Modelled on `scripts/dashboard_ro_role.sql`. Run as the **postgres superuser** (
```sql ```sql
-- analytics_ro_role.sql — dedicated read-only LOGIN role for the analytics MCP server. -- 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). -- 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 -- Grants exactly the read surface: SELECT on reporting.* + tracksolid.* + tickets.* +
-- matview, and EXECUTE on reporting.fn_*. No INSERT/UPDATE/DELETE, not the matview owner, -- fuel.*, the v_trips matview, and EXECUTE on reporting/tickets/fuel functions. No
-- so analytics_ro can never write or REFRESH. Idempotent — safe to re-apply (rotates pw). -- 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 \set ON_ERROR_STOP on
DO $role$ DO $role$
@ -86,17 +89,28 @@ END $role$;
ALTER ROLE analytics_ro WITH LOGIN PASSWORD :'ro_pw'; ALTER ROLE analytics_ro WITH LOGIN PASSWORD :'ro_pw';
GRANT CONNECT ON DATABASE tracksolid_db TO analytics_ro; GRANT CONNECT ON DATABASE tracksolid_db TO analytics_ro;
GRANT USAGE ON SCHEMA reporting, tracksolid 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 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 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 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 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 created by the migration role auto-grant (matviews still need explicit GRANT). -- 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 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 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 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. -- 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 default_transaction_read_only = on;
@ -155,7 +169,7 @@ tools, and the auth:
analytics_mcp.py — Fireside Communications · Read-only Analytics MCP Server analytics_mcp.py — Fireside Communications · Read-only Analytics MCP Server
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Hosted MCP server for the decision & analytics team. Exposes the fleet reporting Hosted MCP server for the decision & analytics team. Exposes the fleet reporting
data (reporting.* + tracksolid.*) to Claude as READ-ONLY query + introspection 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 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. that is rolled back. Served over streamable HTTP behind Traefik with Bearer auth.
""" """
@ -179,7 +193,14 @@ log = _get_logger("server")
DATABASE_URL = os.environ["DATABASE_URL"] # set to the analytics_ro DSN by deploy DATABASE_URL = os.environ["DATABASE_URL"] # set to the analytics_ro DSN by deploy
MAX_ROWS_CEIL = int(os.getenv("MCP_MAX_ROWS", "10000")) MAX_ROWS_CEIL = int(os.getenv("MCP_MAX_ROWS", "10000"))
READABLE_SCHEMAS = ("reporting", "tracksolid") # 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 ──────────────────────────────────────────────── # ── read-only connection pool ────────────────────────────────────────────────
# Force read-only + a statement timeout at the connection level (belt + braces; # Force read-only + a statement timeout at the connection level (belt + braces;
@ -234,7 +255,7 @@ mcp = FastMCP("fireside-analytics", stateless_http=True)
@mcp.tool() @mcp.tool()
def query(sql: str, max_rows: int = 1000) -> dict: def query(sql: str, max_rows: int = 1000) -> dict:
"""Run a read-only SELECT/WITH query against the fleet database. """Run a read-only SELECT/WITH query against the fleet database.
Only the reporting.* and tracksolid.* schemas are readable. Returns up to Readable schemas: reporting.*, tracksolid.*, tickets.*, fuel.*. Returns up to
`max_rows` rows (default 1000, hard cap 10000). Auto-applies LIMIT if absent.""" `max_rows` rows (default 1000, hard cap 10000). Auto-applies LIMIT if absent."""
stmt = _guard(sql) stmt = _guard(sql)
cap = max(1, min(int(max_rows), MAX_ROWS_CEIL)) cap = max(1, min(int(max_rows), MAX_ROWS_CEIL))
@ -262,7 +283,7 @@ def list_schemas() -> list[dict]:
@mcp.tool() @mcp.tool()
def list_tables(schema: str) -> list[dict]: def list_tables(schema: str) -> list[dict]:
"""List tables + views in a schema (reporting or tracksolid).""" """List tables + views in a schema (one of READABLE_SCHEMAS)."""
if schema not in READABLE_SCHEMAS: if schema not in READABLE_SCHEMAS:
raise ValueError(f"schema must be one of {READABLE_SCHEMAS}") raise ValueError(f"schema must be one of {READABLE_SCHEMAS}")
with _ro_conn() as conn, conn.cursor() as cur: with _ro_conn() as conn, conn.cursor() as cur:
@ -338,6 +359,28 @@ app.add_route("/healthz", healthz, methods=["GET"]) # Starlette: add_route, no
> exact FastMCP app-factory method name against the installed `mcp` version > 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. > (`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` ## 5. Packaging — `Dockerfile` + `pyproject.toml`
@ -369,10 +412,18 @@ the running stack, swaps in the `analytics_ro` credentials, and runs a standalon
Traefik-labelled bridge (the proven `dashboard_api` pattern). See the script header. Traefik-labelled bridge (the proven `dashboard_api` pattern). See the script header.
```bash ```bash
# add/rotate a token (sets the full token list):
cd ~/fleetanalytics_mcp && git pull cd ~/fleetanalytics_mcp && git pull
MCP_AUTH_TOKENS="alice:$(openssl rand -hex 16)" bash deploy.sh 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) ## 7. Deploy runbook (ordered)
@ -397,7 +448,8 @@ claude mcp list # should show "fireside-analytics: connected"
`Authorization: Bearer <your-token>` header. `Authorization: Bearer <your-token>` header.
Example session prompts: *"list the schemas"*, *"describe reporting.v_daily_summary"*, 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 *"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`). calls `query`).
--- ---
@ -419,7 +471,7 @@ calls `query`).
## 10. Security notes ## 10. Security notes
- **Four read-only layers:** role GRANTs · `default_transaction_read_only=on` (role + connection) · rolled-back txn · SQL keyword guard. - **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. - **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). - **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. - **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.*`. - **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.*`.

View file

@ -33,19 +33,28 @@ END $role$;
ALTER ROLE analytics_ro WITH LOGIN PASSWORD :'ro_pw'; ALTER ROLE analytics_ro WITH LOGIN PASSWORD :'ro_pw';
GRANT CONNECT ON DATABASE tracksolid_db TO analytics_ro; GRANT CONNECT ON DATABASE tracksolid_db TO analytics_ro;
GRANT USAGE ON SCHEMA reporting, tracksolid 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 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 tracksolid TO analytics_ro; -- tables + views
GRANT SELECT ON ALL TABLES IN SCHEMA tickets TO analytics_ro; -- INC/CRQ tickets: tables + views
GRANT SELECT ON ALL TABLES IN SCHEMA fuel TO analytics_ro; -- fuel: tables + views
GRANT SELECT ON reporting.v_trips TO analytics_ro; -- MATERIALIZED VIEW (not in ALL TABLES) 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; 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;
-- "dynamic": future objects created by the migration role (tracksolid_owner) -- "dynamic": future objects are auto-granted. reporting/tracksolid are created by the
-- are auto-granted. NOTE: matviews are still never covered — a new matview needs -- migration role (tracksolid_owner); tickets/fuel are owned by postgres, so their
-- its own explicit GRANT SELECT (as above for v_trips). -- default-privilege grants must be keyed to postgres or new objects won't be readable.
-- NOTE: matviews are still never covered — a new matview 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 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 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 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 via the -- 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 -- MCP server, so pin read-only at the role level and cap runaway work. These are