Skip to main content
Glama
nikhilx23

People Analytics MCP Server

by nikhilx23
README.md
# People Analytics AI Copilot

A governed, natural-language analytics assistant for HR data. An analyst
asks a question in plain English; the system answers it using versioned,
reproducible analytical methods executed through an MCP tool layer — never
by letting an LLM freehand-calculate a number or query the database
directly.

This started as a local MVP (rule-based NL parsing, SQLite, a token→role
auth stub) and has since been upgraded vertically, in the order below, into
a production-oriented platform: a real LLM tool-selection layer with the
rule-based parser kept as an automatic fallback, real Microsoft Entra ID
authentication, PostgreSQL, Docker, performance instrumentation, a 204-case
accuracy evaluation, an adversarial security/governance test suite, CI on
GitHub Actions, and a documented (unexecuted) Azure deployment path. Every
number in this document — accuracy percentages, latency figures, test
counts — comes from actually running the code in this repo, not from
assumption; see "Evaluation" and "Security & governance testing" for how.

```
                    HR Analyst
                        |
                        v
              Streamlit UI  /  FastAPI /ask
                        |
                        v
   ┌───────────────────────────────────────┐
   │  Governance: question-level PII screen  │
   │  Entra ID auth -> role -> RBAC scope     │
   └───────────────────────────────────────┘
                        |
                        v
      LLM tool selection (real LLM; rule-based
        NL parser as automatic fallback)
                        |
                        v
                   MCP Server
        ------------------+------------------
        |                                    |
   Analytics Tools                  run_governed_sql
   (attrition, headcount,           (governed SQL guard:
    tenure, salary,                  read-only, no blocked
    promotion, ...)                  fields, aggregate-only
        |                            sensitive fields, no
        v                            wildcard SELECT, row-capped)
     pandas                                  |
        |                                    v
        +-------------> PostgreSQL <---------+
                       (or SQLite)
                        |
                        v
             Result Validation Layer
                        |
                        v
        AI answer + "Explain this answer"
        (LLM-phrased, template fallback)
                        |
                        v
    Governance audit log + Performance log + Feedback (👍/👎/report)
```

## Why it's built this way

The brief this project is based on (a Microsoft People Analytics posting)
calls out several things explicitly, and the architecture is organized
around each of them:

1. **"The LLM shouldn't have unrestricted access to your database."** Every
   analytics question resolves to one of nine MCP tools
   (`app/mcp/server.py`); none of them accept or execute arbitrary SQL
   except `run_governed_sql`, which is itself restricted (read-only,
   single-statement, no `SELECT *`, aggregate-only for sensitive fields, no
   direct identifiers, no individual-level `GROUP BY`, row-capped). The LLM
   (`app/api/llm_router.py`) **selects among these tools** — it never
   writes SQL that reaches the database unguarded; even its `sql` escape
   hatch goes through the exact same `validate_sql()` guard as everything
   else.
2. **"Encode analytical methods."** `app/analytics/` contains versioned,
   documented formulas (e.g. *Governed Attrition Calculation v1.2*). Neither
   the rule-based parser nor the LLM computes a metric itself — each only
   chooses which encoded method to call and with what parameters. The same
   question always produces the same number.
3. **Data governance.** `app/governance/` enforces two independent checks —
   a question-level PII screen before any tool is chosen, and a SQL-level
   guard inside `run_governed_sql` — so a differently-worded question can't
   route around governance. Every decision (allowed or blocked) is logged
   to `query_log` for audit. See "Security & governance testing" below for
   how this is actually tested, adversarially, not just asserted.
4. **Role-based access, never individual PII.** Authentication is real
   Entra ID when configured, a demo stub otherwise; authorization
   (`app/governance/permissions.py`) is real either way, and no role —
   analyst, manager, or admin — is ever granted individual-employee PII
   access. Elevated roles get a wider aggregate scope and visibility into
   audit/performance dashboards, not looser data access.
5. **"Cut response times."** Every question is timed stage-by-stage
   (`app/api/perf_context.py`, `app/api/performance.py`) and the numbers are
   real, measured, and visible on the "⚡ Performance" dashboard — see that
   section for the actual bottleneck found and fixed.

## Repository layout

```
people-analytics-ai-copilot/
├── app/
│   ├── api/
│   │   ├── main.py            # /ask /feedback /stats /audit-log /performance /health
│   │   ├── copilot.py         # full pipeline: PII screen -> auth/RBAC -> routing -> tool -> validation -> explain
│   │   ├── nl_parser.py       # rule-based NL -> tool/params mapping (LLM fallback)
│   │   ├── llm_router.py      # real LLM tool-selection layer (Anthropic tool-use)
│   │   ├── perf_context.py    # per-request DB-time accumulator
│   │   ├── performance.py     # performance_log storage + stats (avg/p95/min/max)
│   │   ├── validation.py      # sanity-checks tool results before they reach the analyst
│   │   └── feedback_store.py
│   ├── mcp/
│   │   ├── server.py          # MCP server exposing 9 governed tools
│   │   └── tools/              # tool implementations (analytics_tools.py, sql_tool.py)
│   ├── analytics/         # encoded, versioned analytical methods
│   │   ├── base.py            # AnalyticsResult - the "Explain this answer" data model
│   │   ├── attrition.py, headcount.py, tenure.py, compensation.py
│   │   └── data_access.py     # de-identified data loading (full_name never loaded), in-process DataFrame cache
│   ├── governance/
│   │   ├── permissions.py     # role -> capabilities (RBAC), demo auth + Entra ID dispatch
│   │   ├── entra_auth.py      # real Entra ID JWT validation (RS256, JWKS, role/department claims)
│   │   └── policies.py        # PII screen + SQL guard + audit logging
│   ├── database/          # schema.sql (SQLite), schema_postgres.sql, connection.py, load_data.py
│   └── evaluation/
│       ├── generate_eval_dataset.py     # deterministic generator -> eval_dataset.csv (204 cases, 11 categories)
│       ├── eval_dataset.csv
│       ├── reference_calculations.py    # independent ground truth (no shared code with app/analytics)
│       ├── eval_runner.py               # runs the pipeline, checks answers, reports accuracy; CI exit code
│       └── security_tests.py            # adversarial security/governance test suite; CI exit code
├── data/
│   └── generate_synthetic_data.py   # fictional 520-employee dataset generator
├── frontend/
│   └── app.py              # Streamlit UI: chat, explain-this-answer, feedback, evaluation, audit log, performance
├── tests/                   # pytest suite (66 tests)
├── infra/
│   └── provision.sh         # Azure resource provisioning (az CLI) - written, not executed, see docs/
├── docs/
│   └── AZURE_DEPLOYMENT.md  # full deployment walkthrough
├── .github/workflows/
│   ├── ci.yml                # tests + eval + security suite, on SQLite AND PostgreSQL, every push/PR
│   └── cd.yml                 # Azure deploy - manual-only, gated on secrets, never run
├── requirements.txt
├── Dockerfile / docker-compose.yml   # api + frontend + Postgres + db-init, all services
└── README.md
```

## Setup

```bash
pip install -r requirements.txt

# 1. Generate the synthetic dataset and load it (SQLite by default; set
#    DATABASE_URL to point at PostgreSQL instead - see .env.example)
cd data && python3 generate_synthetic_data.py && cd ..
python3 -m app.database.load_data

# 2. Run the test suite
python3 -m pytest -v

# 3. Run the accuracy evaluation (204 generated cases)
python3 -m app.evaluation.eval_runner

# 4. Run the adversarial security & governance test suite
python3 -m app.evaluation.security_tests

# 5. Run the Streamlit demo (talks to the pipeline in-process)
streamlit run frontend/app.py

# 6. (optional) Run the FastAPI backend separately
uvicorn app.api.main:app --reload --port 8000

# 7. (optional) Run the MCP server standalone (stdio transport, for an
#    MCP-compatible client or the `mcp dev` inspector)
python3 -m app.mcp.server
```

Everything above runs with **zero configuration** — no API key, no Azure
tenant, no Postgres install required. `.env.example` documents every
optional variable (`DATABASE_URL`, `ANTHROPIC_API_KEY`, `LLM_MODEL`,
`ENTRA_TENANT_ID`/`ENTRA_CLIENT_ID`/`ENTRA_CLIENT_SECRET`) and what each
one's absence falls back to.

### Docker

```bash
docker compose up --build
# API:      http://localhost:8000
# Frontend: http://localhost:8501
# Postgres: localhost:5432
```

`docker-compose.yml` runs four services: `db` (Postgres 16), `db-init` (a
one-shot container that applies the schema and loads the dataset, so `api`
and `frontend` never start against an empty database), `api`, and
`frontend`. **Honesty note:** this was written and validated
(`docker compose config` parses cleanly; the exact same startup sequence —
`load_data` against this Postgres image, then serving — has been run
directly on the host and passes every test/eval/security check below) but
`docker build`/`docker compose up` were never run to completion end-to-end
in the sandbox this project was built in, because that sandbox's network
policy blocks every container registry (Docker Hub, GHCR, MCR, ECR, GCR all
returned 403 on the base-image pull — confirmed directly, not assumed). The
`docker-build` job in `.github/workflows/ci.yml` builds the image for real
on every push, on a runner that has normal registry access.

## The dataset

`data/generate_synthetic_data.py` produces 520 entirely fictional employees
across 8 departments (Engineering, Sales, Marketing, Customer Support,
Finance, Human Resources, Product, Operations) with hire/termination dates,
salary, performance rating, job level, location, manager, and promotion
history — enough realism to make department comparisons and trend questions
meaningful, none of it real.

## LLM tool-selection layer

`app/api/llm_router.py` calls the Anthropic API with the exact tool schemas
registered on the MCP server (`app/mcp/server.py`'s `list_tools()` — pulled
live, not hand-duplicated), so the LLM can only select among the same
governed tools the rule-based parser uses. It's activated automatically
when `ANTHROPIC_API_KEY` is set; every question routes through it first
(`app/api/copilot.py::_route_question`), and any failure — no key, a
network error, an unexpected response — falls back to the deterministic
rule-based parser automatically (`routing_method` on every response records
which path actually ran: `llm`, `rule_based`, or
`rule_based_fallback_after_llm_error`). `tests/test_llm_router.py` exercises
every branch of this (LLM unavailable, LLM error, LLM decline text, the SQL
escape hatch, RBAC applying identically regardless of router) with mocked
LLM responses, since no API key is available in this environment — the
routing *logic* is fully tested even though the *model call itself* has
never been exercised for real here.

The default model (`LLM_MODEL`, `claude-sonnet-5`) and the tool-use request
shape (`client.messages.create(..., tools=..., messages=...)`, reading
`tool_use`/`text` blocks off `response.content`) were checked against
Anthropic's live API docs during this session rather than left as
originally written from training knowledge — the model ID had gone stale
(a dated `claude-sonnet-4-5-20250929` string that's no longer current) and
was updated; the request/response shape itself was already correct and
needed no change.

## Governance model

| Question | Result |
|---|---|
| "What is the attrition rate in Engineering?" | ✅ Answered — aggregate, department-level |
| "What is the average salary by department?" | ✅ Answered — aggregate, grouped |
| "What is John Smith's salary?" | 🚫 Refused — names a specific employee |
| "Show me every employee's salary." | 🚫 Refused — individual-level ask, plural phrasing |
| "Give me a full export of the employee table." | 🚫 Refused — raw row-level export |
| `SELECT * FROM employees` (via the SQL escape hatch) | 🚫 Refused — wildcard bypasses field checks |

Enforcement happens twice, independently:

* **`check_question_for_pii`** (`app/governance/policies.py`) screens the
  question text itself, before any tool is chosen — it matches against the
  employee roster, "individual lookup" phrasing, and raw-export phrasing
  (including plural forms like "every employee's salary", added after the
  expanded evaluation surfaced it as a real gap — see "Evaluation").
* **`validate_sql`** (same file) is the guard inside the `run_governed_sql`
  tool: rejects anything that isn't a single read-only `SELECT`, rejects
  `SELECT *`/`table.*` outright (added after the adversarial security suite
  caught it letting every column, PII included, through unchecked — see
  "Security & governance testing"), rejects `full_name`/`manager` outright,
  and requires `salary`/`performance_rating` to appear only inside an
  aggregate function (`AVG`, `SUM`, `MIN`, `MAX`, `COUNT`) — never as a raw
  per-row value.

Both paths write to `query_log` (visible in the Streamlit "Governance audit
log" tab, `hr_admin`-only, and via `GET /audit-log`), so governance is
auditable, not just enforced silently.

## Roles, RBAC, and Entra ID

| Role | Aggregate scope | Individual PII | Raw SQL | Audit log | Admin dashboards |
|---|---|---|---|---|---|
| `hr_analyst` | Company-wide | Never | Governed only | No | No |
| `hr_manager` | Own department only (auto-scoped or hard-blocked outside it) | Never | Never (the SQL escape hatch is closed entirely to scoped roles — free-form SQL can't be safely department-verified) | No | No |
| `hr_admin` | Company-wide | **Still never** | Governed only | Yes | Yes |

No role gets raw employee-level PII — that boundary doesn't move with
seniority; it's a product decision, not a gap. `hr_admin`'s extra
capability is visibility (audit log, performance dashboard), not looser
data access.

Authentication has two backends, chosen automatically
(`app/governance/permissions.py`):

* **Real Microsoft Entra ID** (`app/governance/entra_auth.py`) — activated
  when `ENTRA_TENANT_ID`/`ENTRA_CLIENT_ID` are set. Validates a bearer
  token exactly the way a real Azure AD-protected API would: fetches the
  tenant's JWKS, verifies the RS256 signature, checks audience/issuer/
  expiry, and maps the token's App Role claim (`HR.Analyst`/`HR.Manager`/
  `HR.Admin`) to this app's RBAC roles. `tests/test_entra_auth.py` exercises
  this real validation logic end-to-end with a self-signed token and a
  dependency-injected fake JWKS client — a legitimate test of the actual
  code path, not a mock of the outcome — since no real Azure tenant is
  available here. Full Azure Portal setup steps are in that module's
  docstring and in `docs/AZURE_DEPLOYMENT.md`.
* **Demo token→role stub** (this module) — used whenever Entra ID isn't
  configured, so the app is fully runnable and fully governed without an
  Azure tenant: `demo-analyst-token`, `demo-manager-token` (scoped to
  Engineering), `demo-admin-token`. The Streamlit sidebar role switcher uses
  these.

## "Explain this answer"

Every `AnalyticsResult` (`app/analytics/base.py`) carries its method name and
version, the human-readable formula, the input parameters, the intermediate
numbers behind the headline figure, and the data source — not just the
number. The Streamlit "Explain this answer" expander and the `explanation`
field in the `/ask` API response both render straight from this. When the
LLM layer is active, the headline answer text is additionally rephrased in
natural language by `generate_llm_explanation()` — instructed to use *only*
numbers already present in the result JSON, never to introduce a new one —
falling back to the deterministic template below if that call fails for any
reason:

```
Answer: Voluntary Attrition Rate (Engineering department) = 6.4%

Method: Governed Attrition Calculation v1.2
Formula: Attrition Rate (%) = (Employees who left in period / Average headcount) x 100,
         where Average headcount = (headcount at period start + headcount at period end) / 2

Breakdown:
  employees_who_left: 3
  headcount_at_period_start: 50
  headcount_at_period_end: 59
  average_headcount: 54.5

Data source: Employee Analytics Dataset (governed view)
```

## Performance monitoring

Every question is timed stage-by-stage — routing (LLM call or rule-based
parse), tool execution, DB query time specifically, validation, total — and
recorded to `performance_log`, visible on the "⚡ Performance" dashboard
tab (`hr_admin`-only) and via `GET /performance`. A real snapshot measured
during this project's own test/eval/security runs (1,953 recorded
questions):

| Stage | Avg (ms) |
|---|---|
| Routing | 0.1 |
| Tool execution | 4.9 |
| DB query | 0.2 |
| Validation | 0.0 |
| **Total** | **5.5 avg / 11.9 p95** |

By tool, `calculate_attrition` was consistently the slowest (avg 8.6ms, p95
13.3ms) — profiling traced this to `app/analytics/data_access.py` reloading
the full employee DataFrame from the database on every single call, even
though most questions in a session query the same static dataset
repeatedly. The fix was an in-process cache
(`load_employees_df()`/`clear_cache()`, invalidated automatically on every
`load_data.py` run so it never serves stale data after a reload). Measured
before/after, just now, on this repo:

```
4x uncached loads: 127.4ms
1 load + 3 cache hits: 10.5ms
speedup: 12.2x
```

## Evaluation

`app/evaluation/eval_runner.py` runs **204 generated questions**
(`generate_eval_dataset.py`, 11 categories — core, normal, company-wide,
comparison, department alias, date range, typo, ambiguous, unsupported
metric, boundary, PII) through the full pipeline and checks each result
against an **independently computed** reference value
(`reference_calculations.py`, which shares no code with `app/analytics` —
otherwise a bug in the formula would always "pass").

**Current measured accuracy: 99.5% (203/204)**, identical on both SQLite
and PostgreSQL:

| Category | Accuracy |
|---|---|
| alias | 100.0% (8/8) |
| ambiguous | 100.0% (8/8) |
| boundary | 100.0% (8/8) |
| company_wide | 100.0% (6/6) |
| comparison | 100.0% (24/24) |
| core | 100.0% (17/17) |
| date_range | 100.0% (8/8) |
| normal | 100.0% (96/96) |
| pii | 100.0% (6/6) |
| typo | 100.0% (8/8) |
| unsupported | 93.3% (14/15) |

**Known limitation, documented rather than hidden:** "How many employees
are enrolled in the 401k plan?" is misclassified as a headcount question
with high confidence, because it happens to contain the literal substring
"how many employees are" — one of the regex triggers for the headcount
metric in the rule-based parser (`app/api/nl_parser.py`). This is a real,
honest gap in keyword-based intent parsing, not a bug that was patched
around; it's exactly the class of error the LLM tool-selection layer (which
understands semantics rather than matching substrings) is expected to
avoid, and it's called out here rather than adjusted away to reach 100%.

`app/evaluation/eval_runner.py`'s `__main__` exits non-zero if accuracy
drops below 98% — a CI regression gate with headroom for that one known
miss, not for a new one.

## Security & governance testing

`app/evaluation/security_tests.py` is a separate adversarial test suite
from the accuracy evaluation above — it measures whether the system
**refuses to leak anything it shouldn't**, not whether it gives the right
answer, across 41 cases spanning three independent defense layers:

1. **Live pipeline** — real, unmocked adversarial natural-language
   questions (prompt injection, SQL-injection-as-text, PII extraction, data
   exfiltration phrasing, claimed-identity privilege escalation, RBAC
   bypass attempts, unsupported-field probing) run through
   `answer_question()`. The check isn't "was it `blocked=True`" — a
   question that safely falls back to a harmless aggregate is exactly as
   safe as one that trips the explicit PII screen — it's whether the
   response leaks an individual employee's name or row-level sensitive
   data, checked against the actual employee roster.
2. **Governance layer in isolation** — 12 SQL payloads run directly against
   `validate_sql()`, the backstop between *any* tool-selection mechanism
   and the database.
3. **LLM escape-hatch defense in depth** — `route_with_llm` is mocked to
   simulate an LLM that *was* talked into emitting malicious SQL or an
   RBAC-violating tool call, proving the governance/RBAC layer is what
   actually stops the attack, not the model's good behavior.

**Current measured result: 100% (41/41 safe)** — but not on the first run.
Building this suite found two real, previously-unnoticed governance gaps,
both fixed (not routed around) before this number was reached:

* **`SELECT * FROM employees` was allowed through `validate_sql()`.** The
  blocked-field and aggregate-only checks matched by column *name*, so a
  wildcard silently bypassed both and would have returned every row with
  `full_name`, `salary`, `manager`, and `performance_rating` intact. Fixed
  by rejecting any wildcard `SELECT` outright (`COUNT(*)` is unaffected —
  the `*` there isn't immediately after `SELECT`/`DISTINCT`).
* **The PII screen's raw-export detection only matched a few literal
  phrases** ("list all employees", "dump", "export all"), missing plural
  phrasings like "Show me every employee's salary." and "Give me a full
  export of the employee table." Fixed by broadening the check (see
  "Governance model" above).

A third, unrelated bug surfaced while re-verifying the fix against
PostgreSQL specifically: `AVG()`/`SUM()` over an `INTEGER` column returns
SQL `numeric`, which psycopg2 surfaces as Python `Decimal` (and `DATE`
columns as `datetime.date`) — neither is JSON-serializable, which would
have broken the LLM explanation layer and any JSON response carrying a raw
`run_governed_sql` aggregate against Postgres specifically (SQLite returns
plain `float`/`str` for the same query, which is why this wasn't caught
until this suite was run against both dialects). Fixed at the source, in
`app/mcp/tools/sql_tool.py`, where raw driver rows are converted to plain
JSON types before they go anywhere else in the app.

`security_tests.py`'s `__main__` exits non-zero on **any** unsafe result —
unlike the accuracy floor, there's no acceptable non-zero baseline for a
governance regression.

## Feedback loop

Every answer in the Streamlit UI can be rated 👍 / 👎 / reported with a
free-text issue. Feedback is stored in the `feedback` table (question, tool
used, answer, feedback, error type, response time) and surfaced on the
"Feedback dashboard" tab: satisfaction %, most-used tools, most common
questions, recently flagged questions, and average response time.

## CI

`.github/workflows/ci.yml` runs, on every push and PR: the full pytest
suite, the 204-case accuracy evaluation, and the adversarial security suite
— each **twice**, once against SQLite and once against a real PostgreSQL
16 service container, plus a Docker image build. A regression in accuracy
below 98% or any unsafe security result fails the build (see the exit-code
notes above).

## Azure deployment

`docs/AZURE_DEPLOYMENT.md` + `infra/provision.sh` describe a full path to
Azure Container Apps, Azure Database for PostgreSQL Flexible Server, and
Microsoft Entra ID, plus a manual-only, secret-gated deploy workflow
(`.github/workflows/cd.yml`). **Written, not executed** — no Azure
subscription is available in the environment this project was built in;
see that doc for exactly what has and hasn't been verified.

## Design decisions worth knowing about

* **Rule-based NL layer kept as the automatic fallback, not replaced.** The
  LLM router is tried first when configured; any failure falls back to the
  deterministic rule-based parser (`app/api/nl_parser.py`) rather than
  erroring, so the system degrades to "fully deterministic and testable
  without an API key," never to "broken."
* **No role, at any level, gets raw SQL or individual PII**
  (`app/governance/permissions.py`). Individual employee lookups belong in
  the system of record, not a chat interface — a deliberate product
  decision, documented in the code and enforced identically for every role
  including `hr_admin`.
* **Low-confidence questions ask for clarification instead of guessing.**
  If neither router can identify a metric with confidence, the response
  returns example questions rather than routing to a plausible-but-wrong
  tool — a wrong confident answer is worse than an admitted "I'm not sure."
* **PostgreSQL and SQLite are both first-class, always.** All SQL goes
  through one dialect-agnostic module (`app/database/connection.py`, via
  SQLAlchemy); every test, the 204-case evaluation, and the 41-case
  security suite are run against both dialects, not just developed against
  one and assumed to work on the other.

## What's real vs. what's documented-but-unexecuted

| Area | Status |
|---|---|
| Analytics methods, governance guard (PII screen + SQL guard), MCP tools, RBAC, validation layer, audit log, performance instrumentation, feedback loop, evaluation harness, security test suite | **Real** — implemented, tested, and re-verified on both SQLite and PostgreSQL |
| LLM tool-selection layer | **Real logic**, fully tested via mocked LLM responses (`tests/test_llm_router.py`) — the actual model call has not been exercised, since no `ANTHROPIC_API_KEY` is available in this environment; automatic, tested fallback to the rule-based parser either way |
| Entra ID authentication | **Real validation logic**, fully tested against a self-signed JWT and a fake JWKS client (`tests/test_entra_auth.py`) — never exercised against a real Azure tenant, since none is available here; automatic, tested fallback to the demo token stub either way |
| Docker | **Written and config-validated**; build never completed end-to-end locally (container registries are blocked by this sandbox's network policy) — builds for real in `.github/workflows/ci.yml`'s `docker-build` job |
| CI (GitHub Actions) | **Real** — `.github/workflows/ci.yml` runs on every push/PR |
| Azure deployment (CD workflow, provisioning script) | **Written, not executed** — no Azure subscription available here; see `docs/AZURE_DEPLOYMENT.md` |