Governed analytics MCP server

AegisQuery

A read-only transaction is not a security boundary. Proving that took reproducing the exact injection that defeats one.

Role
Sole engineer. Protocol implementation, safety pipeline, adversarial harness, eval, hosted console.
Shape
TypeScript on Node 22, built against MCP specification revision 2025-11-25.
Links
Live demoSource
AegisQuery console with a role switcher, a governed SQL query editor, the warehouse catalog, and a live audit trail.
AegisQuery console with a role switcher, a governed SQL query editor, the warehouse catalog, and a live audit trail.

Architecture

tools/callallowedLLM agentMCP clientMCP serverstdio · HTTPGovernanceallowlist · PIIAST guardsingle SELECTCost guardplan budgetWarehouseread-onlyAuditper call
SAFETY PIPELINE

The problem

Giving a language model access to a production warehouse is the most useful and most dangerous thing you can do with tool-calling. The obvious guard is to open the connection read-only and let the model write whatever SQL it likes.

That guard is weaker than it looks. A stacked statement can close the read-only transaction and open a destructive one behind it. This is not hypothetical: the same class of bug defeated a widely used reference implementation of a Postgres MCP server. The transaction was read-only, and the schema still dropped.

The approach

AegisQuery treats the query as a structure to be validated rather than a string to be filtered. Every request passes an ordered nine-stage pipeline before a single row is returned: parse to an abstract syntax tree, enforce a single read-only statement, extract references, check them against a deny-by-default allowlist, mask personally identifiable columns, inject row-level policy predicates by rewriting the tree, estimate cost against the planner, execute on a read-only connection, truncate to a token budget, and write a structured audit record.

Structural validation is what actually closes the injection. Multiple statements parse to an array, and an array is rejected before anything reaches the database. The read-only connection stays in place behind it as defence in depth, so a parser gap still cannot mutate data.

The tools are workflow-shaped rather than endpoint mirrors. Curated metrics are preferred over model-authored SQL, and a cost-preview tool validates and prices a query without executing it, so an agent can find out that a query is too expensive without paying for the discovery.

Decisions

Reproduce the vulnerability before claiming to fix it

The repository contains the attack, not just the mitigation. A 20-assertion adversarial harness fires 9 injection attempts and asserts each is rejected, then runs a post-attack data-integrity check to confirm the warehouse is unchanged. A fix nobody has attacked is a hypothesis.

Row-level policy is injected, not requested

Scoping predicates are added to the syntax tree from the caller's principal attributes rather than trusted from the query. A regional analyst is confined to their region because the engine rewrote the query, not because the agent was asked nicely to include a WHERE clause.

Stateless-first transport

The HTTP transport carries no session id, so any instance can serve any request and it scales behind a round-robin load balancer with no sticky routing. Unauthenticated requests receive a 401 with RFC 9728 protected-resource metadata, which is the discovery flow the specification is moving toward.

The demo runs the real engine

The hosted console is not a mockup of the governance layer. It runs the same pipeline on serverless. Switching role from analyst to admin changes what the identical query returns: masked or raw, cost column denied or visible, adversarial SQL rejected on the spot. A governance claim you can try in a browser is worth more than a paragraph describing one.

Evidence

9
pipeline stages before a row is returned
9
injection attempts rejected in the harness
10
golden tasks scored against an oracle
2
transports: stdio and Streamable HTTP

What it does not do

The governed surface is read-only by design. There is no write path, and adding one would require a different threat model entirely.

Cost estimation uses the planner, which is an estimate. A pathological query can still be slower than its plan suggested.

The reference warehouse is SQLite locally and Postgres in production behind one adapter interface, so engine-specific SQL is deliberately not supported.

Other systems