Skip to main content
Glama
raiyan979

data-agent

by raiyan979

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.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:

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):

DATA_AGENT_JWT=<token from above> python server.py

Or as a long-lived HTTP service (e.g. under docker-compose):

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.

{
  "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 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:

{
  "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 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:

{
  "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:

# 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.