People Analytics MCP Server
Click on "Deploy 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., "@People Analytics MCP ServerWhat's our attrition rate for the sales team this year?"
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.
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:
"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 exceptrun_governed_sql, which is itself restricted (read-only, single-statement, noSELECT *, aggregate-only for sensitive fields, no direct identifiers, no individual-levelGROUP BY, row-capped). The LLM (app/api/llm_router.py) selects among these tools — it never writes SQL that reaches the database unguarded; even itssqlescape hatch goes through the exact samevalidate_sql()guard as everything else."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.Data governance.
app/governance/enforces two independent checks — a question-level PII screen before any tool is chosen, and a SQL-level guard insiderun_governed_sql— so a differently-worded question can't route around governance. Every decision (allowed or blocked) is logged toquery_logfor audit. See "Security & governance testing" below for how this is actually tested, adversarially, not just asserted.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."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.
Related MCP server: HR MCP
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.mdSetup
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.serverEverything 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
docker compose up --build
# API: http://localhost:8000
# Frontend: http://localhost:8501
# Postgres: localhost:5432docker-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 |
| 🚫 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 therun_governed_sqltool: rejects anything that isn't a single read-onlySELECT, rejectsSELECT */table.*outright (added after the adversarial security suite caught it letting every column, PII included, through unchecked — see "Security & governance testing"), rejectsfull_name/manageroutright, and requiressalary/performance_ratingto 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 |
| Company-wide | Never | Governed only | No | No |
| 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 |
| 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 whenENTRA_TENANT_ID/ENTRA_CLIENT_IDare 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.pyexercises 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 indocs/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.2xEvaluation
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:
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 itblocked=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.Governance layer in isolation — 12 SQL payloads run directly against
validate_sql(), the backstop between any tool-selection mechanism and the database.LLM escape-hatch defense in depth —
route_with_llmis 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 employeeswas allowed throughvalidate_sql(). The blocked-field and aggregate-only checks matched by column name, so a wildcard silently bypassed both and would have returned every row withfull_name,salary,manager, andperformance_ratingintact. Fixed by rejecting any wildcardSELECToutright (COUNT(*)is unaffected — the*there isn't immediately afterSELECT/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 includinghr_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 ( |
Entra ID authentication | Real validation logic, fully tested against a self-signed JWT and a fake JWKS client ( |
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 |
CI (GitHub Actions) | Real — |
Azure deployment (CD workflow, provisioning script) | Written, not executed — no Azure subscription available here; see |
This server cannot be deployed
Maintenance
Related MCP Connectors
The grounded data layer for any LLM: governed SQL, metrics, lineage and catalog over your data.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Query your warehouse or a CSV with Claude/ChatGPT over MCP, governed by table-level ACL + audit.
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables secure querying of analytics data stored in AWS Athena/S3 through natural language, with multiple security layers including query validation, resource limits, and automatic PII redaction to prevent data breaches and destructive operations.-
- FlicenseNot gradedqualityCmaintenanceEnables querying an HR analytics database (hr_db) using natural language, providing tools to get database schema and run read-only SQL queries for HR metrics like headcount, attrition, and payroll.-
- FlicenseNot gradedqualityCmaintenanceEnables natural-language supply-chain analysis over synthetic datasets through governed SQL/Python tools, providing supplier scorecards, forecast accuracy, risk detection, landed-cost comparisons, capacity planning, and quality trend insights without the LLM performing quantitative calculations.-
- AlicenseNot gradedqualityBmaintenanceEnables natural language queries to be converted into policy-verified SQL, vector search, and knowledge graph plans, with evidence-backed answers and an audit log.1Apache 2.0