Content
# data-briefing
> Dashboards were pull (you visit them). Scheduled paginated reports were push
> (compensating for the friction of visiting a dashboard). Agents return us to
> pull — but without the dashboard. The scheduler, the SMTP server, the
> recipient list were always accidental complexity from the assumption that
> data had to come *to* the user.
A Databricks-backed MCP server that turns config-driven SQL into typed tools
any MCP-capable agent can call. Pair it with the bundled `data-briefing` skill
and the agent produces an exec-grade HTML + PDF briefing on demand. The MCP
is BYO-agent; the skill is the opinionated client.
## How it works
```
[Agent: Claude Desktop / Claude Code / Cursor / ...]
│
│ user types /data-briefing
▼
[data-briefing skill]
│ reads SKILL.md, calls each MCP tool, gets JSON envelopes
│ { rows, columns, hints, revenue_chart, ... } ← named SVGs pre-rendered server-side
│
│ assembles ~/Briefings/YYYY-MM-DD-briefing.json
│ { executive_summary, kpi_snapshot, reports[], watch_outs, actions }
│
│ calls MCP render_briefing tool (server-side Mustache → HTML)
│ writes ~/Briefings/YYYY-MM-DD-briefing.html
│
│ shells out to system Chrome / Edge in headless mode
│ chrome --headless --print-to-pdf=... file://...
▼
~/Briefings/YYYY-MM-DD-briefing.{json, html, pdf}
```
The MCP server side:
```
[FastAPI / FastMCP app, deployed as a Databricks App]
/health (REST)
/api/v1/reports (REST listing for human discovery)
/api/v1/render (REST: briefing JSON → HTML document)
/mcp (Streamable HTTP)
├── <report_name> one tool per configs/*.yaml,
├── ... registered dynamically at startup
└── render_briefing static: run_id → HTML
OBO via X-Forwarded-Access-Token (per-request, cached 60s)
ConnectorCache amortises warehouse session warm-up
Altair + vl-convert renders each report's named charts to inline SVG
Per-report html_template fragments rendered server-side via chevron
```
## Design principles
- **Pull, not push.** No scheduler, no email, no recipient list. Users
trigger when they want it; ad-hoc parameter overrides are free.
- **MCP returns structured data + SVG.** Prose is the agent's job;
structure is the skill's job; data is the server's job.
- **Death of the dashboard.** No web UI. The artifact is a portable
HTML/PDF that lives in `~/Briefings/`.
- **Config-driven.** A new report is a YAML file. New charts are Vega-Lite
specs in that YAML. No backend code change.
- **OBO end-to-end.** Each briefing runs on the calling user's identity;
they only see data they could read in SQL.
- **BYO agent for the MCP, opinionated skill for the artifact.** Any
MCP-capable agent can drive the server; the skill enforces a fixed
JSON shape and Mustache template for visual consistency.
## Repo layout
```
app/ FastAPI + FastMCP + chart rendering + HTML rendering
├─ main.py lifespan, mounts MCP at /mcp
├─ mcp_server.py dynamic tool registration (one per YAML + render_briefing)
├─ registry.py YAML loading + QuerySpec + ChartSpec + HintsSpec + Pydantic param models
├─ runner.py multi-query execution, named chart SVGs, row cap, html_template render
├─ viz.py Vega-Lite → SVG via Altair + vl-convert
├─ hints.py threshold breaches, top/bottom
├─ cache.py TTL+LRU connector cache (per-request OBO bearer)
├─ sql.py databricks-sql-connector wrapper, :name params
├─ auth.py X-Forwarded-Email / X-Forwarded-Access-Token
├─ render.py chevron(template.html, briefing) → HTML
├─ templates/
│ └─ template.html Mustache exec-grade page (the rendering contract)
└─ rest_routes.py /health, /api/v1/reports, /api/v1/render
configs/ YAML report definitions (one tool per file)
├─ _schema.md config schema reference (ignored by the loader)
└─ *.yaml each file becomes a typed MCP tool at startup
skill/ Bundled opinionated client (one Claude skill)
├─ SKILL.md workflow + JSON schema; shells out to Chrome for PDF
├─ SKILL-data-only.md data-first variant focused on structured JSON output
└─ mcp-setup.md per-agent install snippets
scripts/install_skill.sh symlink skill/ -> ~/.claude/skills/data-briefing/
databricks.yml DAB: app + warehouse + OBO scopes
```
## Quick start (local)
```bash
uv sync --extra dev
cp .env.example .env
# Preferred: set DATABRICKS_CONFIG_PROFILE (after `databricks auth login`)
# and DATABRICKS_WAREHOUSE_ID. The profile resolves the host + creds.
# Or legacy: set DATABRICKS_HOST + DATABRICKS_TOKEN + DATABRICKS_WAREHOUSE_ID.
uv run uvicorn app.main:app --reload --port 8000
# In another shell:
curl localhost:8000/api/v1/reports
```
Local auth precedence (resolved by `app/sql.py` via `databricks-sdk`):
1. **OBO bearer** — `X-Forwarded-Access-Token` (Databricks Apps only).
2. **`DATABRICKS_CONFIG_PROFILE`** — profile from `~/.databrickscfg`,
populated by `databricks auth login -p <profile>`. Works for PAT,
OAuth U2M, or service-principal profiles.
3. **`DATABRICKS_TOKEN` + `DATABRICKS_HOST`** — PAT fallback.
Point an MCP client at `http://localhost:8000/mcp` — see `skill/mcp-setup.md`.
## Add a report
Drop a YAML in `configs/` (see `configs/_schema.md`). Declare one or more
`queries:` (the query named `rows` is primary), optional `charts:` (named
Vega-Lite specs — no `data` block), and an optional `html_template:` Mustache
fragment. Named chart SVGs inject via `{{{chart_name}}}`. On next restart the
report appears as a typed MCP tool.
The old `sql:` and `chart:` shorthands are still accepted and normalised
automatically — existing configs need no changes.
## Deploy as a Databricks App
```bash
databricks bundle validate -t dev
databricks bundle deploy -t dev
```
The DAB declares `user_api_scopes: [sql, catalog.*]` so OBO works without
extra setup. The app extracts the per-request `X-Forwarded-Access-Token`
and runs SQL on the calling user's identity.
## Install the skill
```bash
bash scripts/install_skill.sh
```
Then in a fresh Claude Code (or other agent) session, type `/data-briefing` —
or "give me the exec data briefing".
## Sample reports
Two reports ship in `configs/` and are exposed as MCP tools at startup:
- **`bakehouse_briefing`** — 4-page exec layout over `samples.bakehouse`
(customers + franchises + suppliers). KPI tiles, daily revenue trend,
top franchises, country / product mix, supplier leaderboard.
- **`spend_briefing`** — IT-leader cost report joining `system.billing.usage`
to `system.billing.list_prices` for USD. Tiles for SQL, AI/Agentic,
Jobs/ETL, Interactive, Apps, Lakebase; stacked-area daily trend;
warehouse / job / workspace leaderboards.
The skill produces these end-to-end by calling the report tool, handing
the `run_id` to `render_briefing` for HTML, writing it under
`~/Briefings/`, then driving headless Chrome for a sibling PDF:
```bash
chrome --headless --no-pdf-header-footer \
--print-to-pdf=~/Briefings/YYYY-MM-DD-<report>.pdf \
file://~/Briefings/YYYY-MM-DD-<report>.html
```
No Python or Playwright on the client side — just Chrome (or Edge).
## Limitations (v1)
- **App permissions are wide-open by default** in `databricks.yml`
(`user_name: "*"`). OBO scopes data per-user, but any workspace user can
invoke the tools and consume warehouse compute. Restrict via `group_name`
for prod.
- **Connector cache is per-process, in-memory.** Short-TTL LRU pool that
amortises SQL warehouse session warm-up across the report + render
tool calls in one flow. With multiple replicas, each warms its own pool.
- **Reports return at most `BRIEFING_MAX_ROWS=1000` rows by default.**
Larger result sets are truncated and the envelope sets `truncated: true`
and `total_rows: <n>`.
- **MissingAuthError surfaces as a structured envelope**, not HTTP 500.
If the Databricks Apps proxy doesn't forward `X-Forwarded-Access-Token`
(and no local `DATABRICKS_TOKEN` is set), tools return
`{report, error: "auth_missing", message: ...}`.
- **PDF rendering is client-side via system Chrome / Edge.** The server
renders HTML; the skill shells out to a Chromium-family browser in
headless mode (`--print-to-pdf`). If no browser is found, the HTML is
still written and the user can open it and File → Print → Save as PDF.
## Verification
```bash
uv run pytest tests/ # 47 tests
uv run ruff check .
```