data-agent
by raiyan979
README.md
# mcp-server (Agent 5 — AI/Agent Engineer)
The MCP server: the only interface an LLM ever gets into this platform's
data. It exposes **exactly three read-only tools** — no `run_sql`, no
free-form SQL, ever. That's a hard security requirement of the product, not
a style choice: the agent never gets raw DB access.
```
list_available_metrics()
get_metric(metric_name, dimensions[], filters{}, date_range{start,end})
get_schema_description(model_name)
```
Every call is authenticated (tenant_id/role from a connection-time JWT, never
from tool arguments), validated (unknown metrics/dimensions/filters and
out-of-range dates are rejected before any backend call), bounded (row cap +
truncation flag, request timeout, denylisted models refused outright), and
audited (one JSON line per call to `logs/audit.jsonl`).
Calls Agent 4's backend (`GET /api/v1/metrics`, `POST /api/v1/metrics/query`)
over HTTP, with a local fixture fallback (`fixtures.py`) so this runs
end-to-end even when the backend is down. **Verified against a real, running
instance of Agent 4's backend** — see "Demonstrated end-to-end trace" below.
## Layout
```
mcp-server/
server.py entry point — FastMCP app, the 3 tool definitions
config.py env-driven settings (single source of truth for every knob below)
auth.py connection-time JWT -> AuthContext (tenant_id/role enforcement)
guardrails.py input validation + guardrails, used by every tool
backend_client.py HTTP client for Agent 4's backend, with fixture fallback
fixtures.py local mock data (metric catalog, schema descriptions, mock rows)
audit.py JSONL audit logger
requirements.txt
.env.example
mcp.json.example Claude Desktop/Code connection config snippet
Dockerfile (bootstrap stub from Agent 11 — see note in the file; still accurate)
scripts/
make_dev_token.py mint a dev JWT (HS256, shared secret)
demo_trace.py drives the real MCP protocol end-to-end (see below)
tests/
test_guardrails.py 23 unit tests for guardrails.py
logs/
audit.jsonl audit log (gitignored; created on first tool call)
```
Flat files, no package directory: `server.py` and friends live directly in
`mcp-server/` and import each other with plain `import config` etc. — this
matches the bootstrap `Dockerfile` Agent 11 already placed here (it looks for
`server.py` as the entrypoint) and keeps `python server.py` working with zero
path setup.
## Setup
```bash
cd mcp-server
python3 -m venv .venv # optional but recommended
.venv/bin/pip install -r requirements.txt
```
Requires Python 3.10+ (uses PEP 604 `X | None` unions in a couple of spots).
Copy `.env.example` to `.env` (or set the same variables directly in your MCP
client's config, see below) and mint a dev token:
```bash
python scripts/make_dev_token.py --tenant-id tenant-demo --role analyst
```
This is signed with `MCP_JWT_SECRET`, which defaults to the exact same
well-known dev value Agent 4's backend uses for `JWT_DEV_SECRET`
(`dev-shared-secret-change-me`) — so a token minted here works against a
locally-running backend, and `backend/scripts/mint_token.py` works
interchangeably with this server. Only override `MCP_JWT_SECRET` if you've
changed the backend's secret too.
Run it directly (stdio — what Claude Desktop/Code use):
```bash
DATA_AGENT_JWT=<token from above> python server.py
```
Or as a long-lived HTTP service (e.g. under docker-compose):
```bash
DATA_AGENT_JWT=<token> python server.py --transport http # serves on :8001, GET /health
```
If `DATA_AGENT_JWT` is missing, expired, unsigned, or has an unknown role,
the server logs a clear error to stderr and **refuses to start** — it never
runs without a validated identity (see `auth.py`).
## Connecting to Claude Desktop / Claude Code
See `mcp.json.example`. Claude Desktop: merge the `data-agent` entry into
`claude_desktop_config.json`'s `mcpServers`. Claude Code: save the file as
`.mcp.json` at the repo root.
```json
{
"mcpServers": {
"data-agent": {
"command": "python",
"args": ["/home/claude/data-agent-in-a-box/mcp-server/server.py"],
"env": {
"DATA_AGENT_JWT": "<paste a minted token>",
"MCP_JWT_SECRET": "dev-shared-secret-change-me",
"BACKEND_BASE_URL": "http://127.0.0.1:8000/api/v1",
"MOCK_BACKEND_MODE": "auto"
}
}
}
}
```
This `env` block **is** the "auth context passed at connection time"
mechanism described in the design below — the client launches this process
with a specific tenant's JWT baked in, and that's the only place tenant_id/
role ever come from for the life of the connection.
## Tool contracts
All three match `SHARED_CONTRACTS.md`'s fixed MCP tool set exactly (verified
by introspecting the live `inputSchema` the server actually registers — see
"Demonstrated end-to-end trace"). Every tool returns a JSON string.
### `list_available_metrics()`
No arguments. Returns the metric catalog for the caller's tenant:
```json
[
{"name": "revenue", "description": "...", "dimensions": ["date", "tenant_id", "product_category"]},
{"name": "order_count", "description": "...", "dimensions": [...]},
{"name": "avg_order_value", "description": "...", "dimensions": [...]}
]
```
### `get_metric(metric_name, date_range, dimensions=[], filters={})`
> Note on argument order: Python requires required parameters before
> defaulted ones in a function signature, so `date_range` (required) is
> declared before `dimensions`/`filters` (optional) in `server.py`. This has
> no effect on the tool as MCP callers see it — tool calls are JSON objects
> keyed by name, not positional, so `{"metric_name": ..., "dimensions": ...,
> "filters": ..., "date_range": ...}` in any key order works identically. The
> generated `inputSchema` only marks `metric_name`/`date_range` as
> `required`.
- `metric_name` (str, required) — must be one returned by
`list_available_metrics()`.
- `date_range` (object, required) — `{"start": "YYYY-MM-DD", "end":
"YYYY-MM-DD"}`, inclusive.
- `dimensions` (array of str, default `[]`) — subset of the metric's allowed
dimensions.
- `filters` (object of str->str, default `{}`) — equality filters keyed by
dimension name.
Success:
```json
{
"rows": [ {"date": "2026-08-01", "revenue": 1908.68}, "..." ],
"metric": "revenue",
"generated_at": "2026-09-09T18:00:11.94Z",
"truncated": false,
"row_count": 31,
"total_row_count": 31,
"row_limit": 1000
}
```
`rows`/`metric`/`generated_at` are exactly `SHARED_CONTRACTS.md`'s contract
shape; `truncated`/`row_count`/`total_row_count`/`row_limit` are additive
fields required by the Agent 5 task's guardrail spec ("output truncation with
a clear truncated flag").
Rejection/error (both tools use this same shape):
```json
{"error": "human-readable reason", "guardrail_code": "date_range_too_large"}
```
This comes back as a normal (non-protocol-error) tool result so the LLM can
read and explain it, rather than a raw exception.
### `get_schema_description(model_name)`
- `model_name` (str, required) — e.g. `"mart_sales"`.
Success: `{"description": str, "columns": [{"name", "type", "description"}, ...]}`.
Rejection (denylisted, unknown model, or role not permitted): same
`{"error", "guardrail_code"}` shape as above.
## Guardrails
All implemented in `guardrails.py`, applied before any backend call, and all
tunable via env vars (see `.env.example`) without touching code:
| Guardrail | Default | Rejection code |
|---|---|---|
| Unknown metric name | rejected against `list_available_metrics()`'s live catalog | `unknown_metric` |
| Unknown dimension | must be in the metric's declared dimensions (or the contract's `date`/`tenant_id`/`product_category` if the catalog doesn't specify) | `unknown_dimension` |
| Too many dimensions | `MCP_MAX_DIMENSIONS=3` | `too_many_dimensions` |
| Unknown filter key | same allowed set as dimensions | `unknown_filter` |
| Too many filters | `MCP_MAX_FILTERS=5` | `too_many_filters` |
| Filter value too long | `MCP_MAX_FILTER_VALUE_LEN=200` chars | `filter_value_too_long` |
| **Cross-tenant filter** | a `filters.tenant_id` that doesn't equal the authenticated tenant is always rejected | `cross_tenant_denied` |
| Nonsensical date range (end < start) | — | `invalid_date_range` |
| Huge date range | `MCP_MAX_DATE_RANGE_DAYS=366` | `date_range_too_large` |
| Date too far in the past | before `2000-01-01` | `date_range_too_old` |
| Date in the future | `end` beyond today + `MCP_MAX_FUTURE_SLACK_DAYS=1` | `date_range_in_future` |
| Row limit / output truncation | `MCP_MAX_ROWS=1000`; response carries `truncated`, `row_count`, `total_row_count`, `row_limit` | — |
| Denylisted model | glob patterns, default `raw*,raw.*,*__pii*,*_pii,*_pii__*,*_internal,*_confidential,mart_finance_confidential` (`MCP_DENYLISTED_MODELS`) | `denylisted_model` |
| Role gate on schema introspection | `owner`/`analyst` only by default (`SCHEMA_DESCRIPTION_ALLOWED_ROLES` in `config.py`) — defense-in-depth; the authoritative RBAC policy is Agent 7's `access-control/` | `role_denied` |
| Request timeout | `MCP_REQUEST_TIMEOUT_SECONDS=10` on every backend HTTP call (`httpx.Timeout`) | surfaces as a backend error, not a hang |
| No `run_sql` / free-form SQL, ever | structural — there is no such tool, no SQL string is ever built or accepted anywhere in this folder | — |
## tenant_id / role enforcement (never trust the LLM)
`get_metric`'s signature has **no `tenant_id` or `role` parameter at all** —
by design, so there's nothing for the LLM's reasoning to set or override.
Instead:
1. The MCP client (Claude Desktop/Code) launches this process with
`DATA_AGENT_JWT` in its environment — the same claim shape as the backend
contract (`sub`, `tenant_id`, `role`, `exp`).
2. `auth.py`'s `load_auth_context()` decodes and verifies it (HS256, shared
dev secret — see the `TODO(agent6)` in `config.py`) **once, at process
start**. Missing/expired/invalid/unknown-role tokens make the server
refuse to start (verified above: "Setup").
3. The resulting `AuthContext` is immutable for the life of the connection.
Every tool call, every backend HTTP request (as the `Authorization:
Bearer` header — never in the request body, matching
`SHARED_CONTRACTS.md`'s "never trusted from request body"), and every
audit log line uses this same object.
4. `tenant_id` is still a legal *dimension* per the contract (e.g. to group
by it), so a caller could try `filters={"tenant_id": "someone-else"}`.
`guardrails.validate_filters` explicitly checks this and rejects it with
`cross_tenant_denied` if it doesn't equal the authenticated tenant —
verified in the trace below (Step 5).
## Audit logging
Every tool call — success, guardrail rejection, or backend error — appends
one JSON line to `logs/audit.jsonl` (path configurable via
`MCP_AUDIT_LOG_PATH`), via `audit.py`:
```json
{
"timestamp": "2026-09-09T18:00:11.94+00:00",
"tool": "get_metric",
"user": "test-user",
"tenant_id": "tenant-demo",
"role": "analyst",
"params": {"metric_name": "revenue", "dimensions": ["date"], "filters": {}, "date_range": {"start": "2026-08-01", "end": "2026-08-31"}},
"status": "success",
"row_count": 31,
"latency_ms": 130.5,
"error": null,
"guardrail_code": null
}
```
`user`/`tenant_id`/`role` always come from the authenticated `AuthContext`,
never from `params` — so the audit trail can't be spoofed by a call's
arguments either. Writes are serialized with an `asyncio.Lock` (safe even
under concurrent calls on the HTTP transport) and mirrored to stderr for
local dev visibility; a failure to write the file is itself logged to stderr
rather than crashing the tool call.
This file (JSONL — one record per line, trivially tailable/greppable) is what
Agent 12 (monitoring) and Agent 8 (security audit trail) are expected to
consume next. There's a `TODO(agent12/agent8)` at the top of `audit.py`
marking where to point a real sink (log shipper, Postgres audit table, etc.)
in instead — the record shape shown above is the contract.
## Backend integration
`backend_client.py` calls Agent 4's real backend
(`BACKEND_BASE_URL=http://127.0.0.1:8000/api/v1` by default) for
`list_available_metrics`/`get_metric`, forwarding the connection's JWT as a
Bearer token. **This was verified against a real, running instance of
`backend/`** (not just its fixture shape) — see the next section.
`MOCK_BACKEND_MODE` controls fallback (`config.py`):
- `auto` (default) — try the real backend, silently fall back to
`fixtures.py` on a connection error. This is why the server works
out-of-the-box even before/without `backend/` running.
- `always` — never touch the network; local fixtures only (fast tests).
- `never` — always call the real backend; raise a clear error instead of
masking an outage with fixture data (use this once `backend/` is a
permanent, always-up dependency).
`get_schema_description` has no backend endpoint to call at all yet —
Agent 4's API only exposes `/metrics` and `/metrics/query` (see
`backend/README.md`'s endpoint table) — so it's fixture-only today, with a
`TODO(agent4)` in both `backend_client.py` and `fixtures.py` marking where a
real `GET /api/v1/schema/{model_name}` would plug in once it exists.
## Demonstrated end-to-end trace
`scripts/demo_trace.py` answers **"What was revenue last month for tenant
tenant-demo?"** by launching `server.py` as a real MCP subprocess (stdio
transport — exactly how Claude Desktop/Code would) and driving it through the
actual `mcp` Python client (`mcp.client.stdio` + `ClientSession`), not by
calling the tool functions directly. This was run against a live, separately
started instance of `backend/` (`uvicorn app.main:app --port 8000`,
`MOCK_BACKEND_MODE=never`) to prove the integration is real, not just
fixture-shaped:
```bash
# terminal 1
cd backend && .venv/bin/uvicorn app.main:app --port 8000
# terminal 2
cd mcp-server
TOKEN=$(python scripts/make_dev_token.py --tenant-id tenant-demo --role analyst)
DATA_AGENT_JWT="$TOKEN" BACKEND_BASE_URL="http://127.0.0.1:8000/api/v1" \
MOCK_BACKEND_MODE=never python scripts/demo_trace.py
```
Actual trace captured from this run:
1. **`list_tools()`** confirms the server exposes exactly
`["get_metric", "get_schema_description", "list_available_metrics"]` — no
`run_sql`, nothing extra.
2. **`list_available_metrics()`** → returns `revenue`/`order_count`/
`avg_order_value` **from the live backend** (its description text —
`"Total order revenue."` — differs from this server's own fixture text,
which is how you can tell the real backend answered, not the fallback).
3. **`get_metric(metric_name="revenue", dimensions=["date"], date_range={"start":"2026-08-01","end":"2026-08-31"})`**
(last full calendar month at demo time) → 31 daily rows,
`"truncated": false`, `"row_count": 31` — this is the answer to the NL
question: sum the rows, or read the total from a smaller/aggregated call.
4. **Guardrail: date range too large** (`1990-01-01`..`2030-01-01`, ~14,611
days) → rejected, `date_range_too_large`, backend never called.
5. **Guardrail: unknown dimension** (`customer_email`) → rejected,
`unknown_dimension`, backend never called.
6. **Guardrail: cross-tenant filter** (`filters.tenant_id =
"someone-elses-tenant"` on a `tenant-demo`-authenticated connection) →
rejected, `cross_tenant_denied`, backend never called.
7. **Guardrail: denylisted model** (`get_schema_description("raw.shopify__customers_pii")`)
→ rejected, `denylisted_model`.
8. **`get_schema_description("mart_sales")`** → succeeds, returns the mart's
column descriptions.
Every one of these 7 calls produced a matching line in `logs/audit.jsonl`
(`tool`, `user`, `tenant_id`, `role`, `params`, `status`, `row_count`,
`latency_ms` — confirmed by inspecting the file after the run).
Also verified separately in the same session:
- Server refuses to start with no token, an expired token, and a token with
an unrecognized role (`auth.py`'s fail-closed behavior).
- A `viewer`-role token gets `role_denied` on `get_schema_description`
(an `analyst`/`owner` token succeeds).
- `MOCK_BACKEND_MODE=auto` against an unreachable backend
(`http://127.0.0.1:9999`) transparently falls back to `fixtures.py`
(confirmed via the fixture-specific description text in the response).
- `python server.py --transport http` serves `GET /health` → `{"status":
"ok", "tenant_id": "tenant-demo"}` on `:8001`.
- `python -m unittest tests.test_guardrails -v` — 23/23 tests pass, covering
every guardrail rejection code above.
## Environment variables
See `.env.example` for the full list with defaults; the important ones:
| Var | Default | Purpose |
|---|---|---|
| `DATA_AGENT_JWT` | *(required)* | Connection-time auth token (sub/tenant_id/role/exp). |
| `MCP_JWT_SECRET` | `dev-shared-secret-change-me` | Must match backend's `JWT_DEV_SECRET`. |
| `BACKEND_BASE_URL` | `http://backend:8000/api/v1` | Agent 4's backend. `docker-compose`'s service-name default; use `http://127.0.0.1:8000/api/v1` running outside compose. |
| `MOCK_BACKEND_MODE` | `auto` | `auto` \| `always` \| `never` |
| `MCP_MAX_ROWS` | `1000` | Row cap / truncation threshold. |
| `MCP_MAX_DATE_RANGE_DAYS` | `366` | Max span for `date_range`. |
| `MCP_DENYLISTED_MODELS` | see table above | Comma-separated glob patterns for `get_schema_description`. |
| `MCP_AUDIT_LOG_PATH` | `./logs/audit.jsonl` | Audit log destination. |
## Known gaps / TODOs (left deliberately, not accidentally)
- `config.py` / `auth.py`: JWT verification is a local HS256 shared-secret
stand-in. Swap for Agent 6's real signing key/JWKS once `auth/` exists —
the claim shape already matches, nothing else here should need to change.
- `backend_client.py` / `fixtures.py`: `get_schema_description` has no real
backend endpoint to call — swap once Agent 4 adds one.
- `SCHEMA_DESCRIPTION_ALLOWED_ROLES` in `config.py` is a minimal
defense-in-depth check, not the real RBAC policy — Agent 7's
`access-control/` owns that; this can be relaxed once it's wired in
upstream of this server.
- The `docker-compose`/HTTP-transport path (`--transport http`, `/health`)
is tested standalone (see above) but not yet wired into the root
`docker-compose.yml` — that's Agent 11's file, outside this folder.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues