data-agent
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@data-agentShow me daily active users for the past week."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Related MCP server: Loom MCP Server
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.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
The grounded data layer for any LLM: governed SQL, metrics, lineage and catalog over your data.
Gateway between LLM agents and world data through eight tools and a bundled endpoint catalog.
A read-only verified record of agent-operable GTM tools: search, fetch, compare, track changes.
Discover Frontier inference capabilities and read sanitized usage through read-only tools.
Related MCP Servers
- AlicenseBqualityCmaintenanceEnables agents to interact with a governed semantic layer for querying and authoring metrics, providing tools for discovery, planning, validation, and execution of analytics queries.16Apache 2.0
- AlicenseNot gradedqualityBmaintenanceExposes Iceberg-backed ontology objects, links, and actions as typed MCP tools for LLM agents, enabling governed data access and operations without raw SQL.MIT
- FlicenseNot gradedqualityBmaintenanceEnables agents to perform controlled enterprise data queries through semantic intent, with runtime validation of statistics, filters, granularity, permissions, and physical bindings. Exposes tools like semantic_query for safe, fail-closed access to data horizons and capabilities.-
- FlicenseNot gradedqualityBmaintenanceExposes schema, lineage, and data-quality trust signals from a SQLite-backed catalog as MCP tools, enabling AI agents to answer grounded questions about datasets without hallucinating.-