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.

Related MCP server: Loom MCP Server

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues

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

Related MCP Servers

  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables 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.
    -