data-agent
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
cd mcp-server
python3 -m venv .venv # optional but recommended
.venv/bin/pip install -r requirements.txtRequires 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:
python scripts/make_dev_token.py --tenant-id tenant-demo --role analystThis 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):
DATA_AGENT_JWT=<token from above> python server.pyOr as a long-lived HTTP service (e.g. under docker-compose):
DATA_AGENT_JWT=<token> python server.py --transport http # serves on :8001, GET /healthIf 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.
{
"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:
[
{"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 beforedimensions/filters(optional) inserver.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 generatedinputSchemaonly marksmetric_name/date_rangeasrequired.
metric_name(str, required) — must be one returned bylist_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:
{
"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):
{"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 |
|
Unknown dimension | must be in the metric's declared dimensions (or the contract's |
|
Too many dimensions |
|
|
Unknown filter key | same allowed set as dimensions |
|
Too many filters |
|
|
Filter value too long |
|
|
Cross-tenant filter | a |
|
Nonsensical date range (end < start) | — |
|
Huge date range |
|
|
Date too far in the past | before |
|
Date in the future |
|
|
Row limit / output truncation |
| — |
Denylisted model | glob patterns, default |
|
Role gate on schema introspection |
|
|
Request timeout |
| surfaces as a backend error, not a hang |
No | 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:
The MCP client (Claude Desktop/Code) launches this process with
DATA_AGENT_JWTin its environment — the same claim shape as the backend contract (sub,tenant_id,role,exp).auth.py'sload_auth_context()decodes and verifies it (HS256, shared dev secret — see theTODO(agent6)inconfig.py) once, at process start. Missing/expired/invalid/unknown-role tokens make the server refuse to start (verified above: "Setup").The resulting
AuthContextis immutable for the life of the connection. Every tool call, every backend HTTP request (as theAuthorization: Bearerheader — never in the request body, matchingSHARED_CONTRACTS.md's "never trusted from request body"), and every audit log line uses this same object.tenant_idis still a legal dimension per the contract (e.g. to group by it), so a caller could tryfilters={"tenant_id": "someone-else"}.guardrails.validate_filtersexplicitly checks this and rejects it withcross_tenant_deniedif 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:
{
"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 tofixtures.pyon a connection error. This is why the server works out-of-the-box even before/withoutbackend/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 oncebackend/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:
# 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.pyActual trace captured from this run:
list_tools()confirms the server exposes exactly["get_metric", "get_schema_description", "list_available_metrics"]— norun_sql, nothing extra.list_available_metrics()→ returnsrevenue/order_count/avg_order_valuefrom 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).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.Guardrail: date range too large (
1990-01-01..2030-01-01, ~14,611 days) → rejected,date_range_too_large, backend never called.Guardrail: unknown dimension (
customer_email) → rejected,unknown_dimension, backend never called.Guardrail: cross-tenant filter (
filters.tenant_id = "someone-elses-tenant"on atenant-demo-authenticated connection) → rejected,cross_tenant_denied, backend never called.Guardrail: denylisted model (
get_schema_description("raw.shopify__customers_pii")) → rejected,denylisted_model.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 getsrole_deniedonget_schema_description(ananalyst/ownertoken succeeds).MOCK_BACKEND_MODE=autoagainst an unreachable backend (http://127.0.0.1:9999) transparently falls back tofixtures.py(confirmed via the fixture-specific description text in the response).python server.py --transport httpservesGET /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 |
| (required) | Connection-time auth token (sub/tenant_id/role/exp). |
|
| Must match backend's |
|
| Agent 4's backend. |
|
|
|
|
| Row cap / truncation threshold. |
|
| Max span for |
| see table above | Comma-separated glob patterns for |
|
| 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 onceauth/exists — the claim shape already matches, nothing else here should need to change.backend_client.py/fixtures.py:get_schema_descriptionhas no real backend endpoint to call — swap once Agent 4 adds one.SCHEMA_DESCRIPTION_ALLOWED_ROLESinconfig.pyis a minimal defense-in-depth check, not the real RBAC policy — Agent 7'saccess-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 rootdocker-compose.yml— that's Agent 11's file, outside this folder.