compliance-evidence-mcp
# compliance-evidence-mcp
An MCP server that lets AI agents query security control evidence for SOC 2 and
ISO 27001 audit readiness — with the authorization boundary enforced **in the
server**, not in the prompt.
Agents are increasingly pointed at compliance data: *which controls are missing
evidence, what's expired, what's still open from the last audit.* That data is
not uniformly readable. An external auditor, an internal analyst, and an
autonomous reporting agent should each see a different slice of it, and the
difference has to be enforced somewhere that a model cannot talk its way past.
> **All data in this repository is synthetic.** The control references are real
> SOC 2 and ISO 27001 identifiers, which are public; every owner, evidence
> artifact, and audit finding attached to them is invented for demonstration and
> describes no real organization, system, or vulnerability.
## The design decision
**There is no `run_sql` tool.**
Most database-backed MCP servers expose query execution and let the model
compose SQL. That moves the authorization decision into the model's judgement,
where it can be neither constrained nor audited — and where a prompt injection
in a document, a ticket, or a scraped page can reach it.
This server exposes seven intent-shaped tools instead. Because the server knows
what each call *means*, it can compose the caller's authorization predicates
into the SQL before execution:
| Layer | Enforcement |
|---|---|
| **Scope** | Deny-by-default. A missing grant raises, so a refusal is distinguishable from an empty result. |
| **Row (classification)** | `classification_level <= principal.clearance`, in the `WHERE` clause. |
| **Row (business unit)** | Evidence inherits its business unit transitively from the control it supports. |
| **Column** | Free-text notes and finding detail are masked below `CONFIDENTIAL`. |
| **Aggregate** | Coverage rollups join under the predicate, so counts never include invisible rows. |
| **Existence** | Out-of-scope and nonexistent IDs both return `null` — the tool can't be used to probe for restricted IDs. |
Filtering after the query returns is not equivalent, and aggregates are where
that usually goes wrong: a rollup computed over all rows and then trimmed still
tells the caller how many rows they weren't allowed to see.
## What it looks like
```
$ python demo.py
1. list_controls() -- rows visible
agent-coverage-bot clearance=INTERNAL 9 controls units=corp-it,gpu-cloud,platform
analyst-platform clearance=INTERNAL 8 controls units=corp-it,platform
auditor-external clearance=CONFIDENTIAL 5 controls units=platform
security-lead clearance=RESTRICTED 13 controls units=corp-it,gpu-cloud,platform,security
2. list_findings(status='open') -- scope + masking
agent-coverage-bot DENIED (no findings:read grant)
analyst-platform DENIED (no findings:read grant)
auditor-external DENIED (no findings:read grant)
security-lead 5 open findings, 0 with detail masked
3. get_evidence_gaps() -- aggregates respect the boundary
agent-coverage-bot 4 gaps: A.5.23, A1.2, CC6.2, CC6.6
analyst-platform 3 gaps: A1.2, CC6.2, CC6.6
auditor-external 2 gaps: A1.2, CC6.6
security-lead 4 gaps: A.5.23, A1.2, CC6.6, CC7.3
```
Same query, four identities. The interesting row is **CC6.2**: `analyst-platform`
reports it as an evidence gap, `security-lead` does not. The control is covered —
but by evidence classified above the analyst's clearance. The aggregate degrades
to "uncovered" rather than confirming that evidence exists. That is the behaviour
you want; it is also the one that post-filtering silently gets wrong.
## Tools
| Tool | Required scope |
|---|---|
| `whoami()` | — |
| `list_controls(framework, business_unit)` | `controls:read` |
| `get_control(control_id)` | `controls:read` |
| `list_evidence(control_id, kind)` | `evidence:read` |
| `get_control_coverage(framework, as_of)` | `coverage:read` |
| `get_evidence_gaps(framework, as_of)` | `coverage:read` |
| `list_findings(status, severity)` | `findings:read` |
| `get_access_log(limit)` | `findings:read` |
`whoami()` is unscoped by design: an agent should be able to discover what it is
allowed to see, so it can interpret an empty result correctly instead of
concluding that no findings exist.
## Identity
Principals are resolved in `principals.py` — deliberately outside the warehouse.
In production `resolve()` maps the OAuth subject presented by the MCP client to a
principal; it is never read from a table the agent can also query, and never
taken from a tool argument the model could influence. Resolution fails closed:
an unset identity defaults to the **least**-privileged principal.
For local runs, select an identity with an environment variable:
```bash
COMPLIANCE_MCP_PRINCIPAL=security-lead python -m compliance_mcp.server
```
## Audit trail
Every invocation is appended to `access_log` with principal, arguments, decision,
and row count. **Denials are logged too** — in an assurance context, the record
of what an agent was refused is itself evidence, and it is the first thing you
want when reconstructing an incident.
## Running it
```bash
pip install -e .
python demo.py # the walkthrough above
pytest -q # 17 authorization tests
```
Wire it into Claude Code:
```jsonc
// .mcp.json
{
"mcpServers": {
"compliance-evidence": {
"command": "python",
"args": ["-m", "compliance_mcp.server"],
"env": { "COMPLIANCE_MCP_PRINCIPAL": "analyst-platform" }
}
}
}
```
## Tests
`tests/test_authorization.py` is the substance of this repo. It asserts that a
principal cannot reach data outside its grant by row, by column, by business
unit, or through aggregates — plus that resolution fails closed, that
out-of-scope lookups are indistinguishable from missing ones, and that no
raw-SQL passthrough has been added.
## Scope and honesty
This is a reference implementation, not a product. DuckDB with a seeded fixture
stands in for the warehouse so it runs anywhere with no credentials; the
repository layer issues plain parameterized SQL, so Snowflake or Databricks is a
connector swap. The fixture controls are real SOC 2 TSC and ISO 27001 Annex A
references; the findings are invented.
What it is meant to demonstrate is the pattern: **agent-accessible data surfaces
need an authorization boundary that lives in code, is enforced in the query, and
leaves an audit trail.**
Built with Python 3.10+ and the official MCP Python SDK (2.x).
TDQS
Scored across 8 tools
Most tools have clearly distinct roles: identity, controls, evidence artifacts, coverage status, gap worklist, findings, and access log. The only mild overlap is between get_control_coverage and get_evidence_gaps, but their descriptions differentiate a status view from a filtered worklist.
The set follows list_noun and get_noun conventions consistently, with whoami as a standard, recognizable exception. There is slight semantic variation where get_ is used for both single entities and aggregate reports, but the pattern remains predictable and readable.
Eight tools is well within the ideal range for a read-only compliance evidence service. Each tool earns its place by covering a distinct part of the audit-readiness workflow without redundancy.
The surface covers the core audit-readiness workflow: identity/scope, controls, evidence, coverage, gaps, findings, and access logs. Minor gaps exist, such as no single-evidence retrieval or finding-detail endpoint, but they are unlikely to cause dead ends for common agent workflows.