A local model answers questions about retail sales by calling tools on an MCP server. Those tools reach the data through a semantic layer and guarded SQL. The user's identity travels with every call, and each layer checks it again. This page follows real requests through the system, one hop at a time.
Pick a scenario and step through it with Next or the arrow keys. The top diagram shows which services a step touches; the bottom one shows where the LangGraph state machine is. Click any box to read what that component does.
src/agent/graph.py). The agent ⇄ tools cycle repeats until the model answers or hits the step limit (8). Calls to run_sql pause in approve_sql until a person approves, edits or rejects them.Two independent questions decide every answer. Which rows? All regions, or only the user's own (row-level security). What shape? Raw detail, masked detail, or only aggregates (scope). Both come from one file, config/policies.yaml. The agent, the MCP server and Cube all read it, and Postgres mirrors it with policies.
No single check is trusted alone. Pick something that could go wrong; the list shows the layers in the order a request crosses them, which one stops it, and which would catch it if that one failed. Every one of these is covered by a test in tests/.
An agent fails quietly. It picks the wrong tool, sums a column badly, or returns a number nobody can trace. Three instruments make that visible.
The agent and the MCP server both export OpenTelemetry spans. The trace id rides along on every HTTP call, so one chat turn is one trace: graph nodes, each model call with its prompt and tool choice, and each tool span tagged with user and role. Use it to answer "why was that slow?" and "what exactly did the model see?".
audit.tool_calls)One row per tool call, written by a role that can only INSERT: who, which tool, the exact query, row count, latency and ok / denied / error. Traces are for debugging and get discarded after a while; the audit log is the compliance record.
evals/run_evals.py)23 golden questions with reference answers computed straight from the database. Each run scores tool choice, answer accuracy, latency and the count that must stay at zero: data leaks. The script exits non-zero on any leak, so it can gate CI.
All four failures had one cause. A date range with no granularity was split into 12 monthly rows, and the 2B model couldn't add them up. After the fix, a range returns one total and all six affected cases pass in 13–18 s. The evals found this; reading the code had not.
| Port | Service | What to do there |
|---|---|---|
| 8000 | agent | /ui chat, /chat API, /healthz |
| 8001 | mcp-server | MCP endpoint /mcp (needs a token) |
| 4000 | cube | semantic layer REST API |
| 5432 | postgres | retail and agent_state databases |
| 6006 | phoenix | traces, project default |
| 11434 | ollama (host) | model server, outside Docker for GPU access |
Demo logins: alice, bob, carol, dave, erin; the password for all is demo. What a real deployment changes is in docs/path-to-production.md.
mcp-server is the server; Claude Desktop or the MCP Inspector could connect to the same server.sales.revenue) and dimensions (regions.name) defined once, with the joins and SQL behind them. The model picks names instead of writing joins, which a small model does far more reliably.app.row_scope and app.region_id inside each transaction; if they are missing, the policy returns nothing.sub, role and region_id. Minted per turn, valid 15 minutes, verified by the MCP server and again by Cube. Text in a prompt can't change it.interrupt(value) to pause the graph and hand value to the UI. The run resumes with Command(resume=decision).