MCP threat model: surface, countermeasures, residual risk
This is the written result of reviewing the MCP surface. It says what is worth protecting, what each tool exposes, what stops an abuse of it — and, in its own section, what this design does not protect you from. A threat model that only lists strengths is marketing.
Read MCP transport: the trust boundary first. The process and environment assumptions live there and are referenced rather than repeated.
What is worth protecting
- Database credentials in process reach. The server inherits the environment that holds them.
- Catalog and schema content. What tables exist, what they look like, how large they are.
- Repository files. Migrations, the debt ledger, the last report.
Who is at the other end
- You, running your own agent. The ordinary case.
- Your agent's client, which starts the server and speaks to it. It is as trusted as the process that started it, which is you.
- A prompt-injected or compromised agent context. This is the actor that matters, and the one the surface below is designed around: an agent that has been persuaded to ask for something you would not have asked for.
The surface, tool by tool
| Tool | What a caller controls | Effect | The boundary |
|---|---|---|---|
lint_pending | strictness, category, connection name, run profile, one migration path | reads migration FILES and, for a full run, opens the configured connection | every parameter is bounded or a name the application already configured; the path must resolve inside a configured migration path |
explain_rule | one rule identifier | reads a shipped data file | patterned identifier; an unknown one is an error naming the closest matches |
get_findings | severity floor, category, level ceiling, page size and offset | reads one report artifact the project already wrote | enumerated and bounded values only; never starts a run |
get_debt_ledger | minimum age, debt kind, reference date, page size and offset | reads one ledger file | patterned kind, fixed date format, bounded paging |
lint_shadow | strictness, category, connection name | creates and drops a throwaway database and runs migrations for real | off by default; enabled by name; additionally requires a recorded consent; still refused by the production guard |
predeploy | connection name, one emergency-exit flag | reads catalog and state views on a real target, bounded and lock-free | off by default; enabled by name; writes nothing; the flag opens exactly one door and is recorded in the answer |
No tool accepts SQL, a fragment of SQL, a connection string, a host, a user, or a password. That is not a convention — it is checked against every tool's published schema on every run.
What stops each abuse, and what holds it true
| If an agent tries to… | What stops it | Held by |
|---|---|---|
| send SQL through a parameter | no parameter is free text; every one is bounded, patterned or a name from a closed set | tests/Feature/Architecture/McpParameterAllowlistTest.php |
| send a hostile VALUE into a legitimate parameter | named refusal before anything connects, in both engine lanes | tests/Postgres/Agent/McpAbuseTest.php |
| reach a file outside the project | the path must resolve inside a configured migration path — and a link that legitimately points inside still works | tests/Postgres/Agent/FastPathConfinementReviewTest.php |
| read a credential out of an answer | no configured connection value survives into any frame, over a whole session | tests/Feature/Agent/McpOutputHygieneTest.php |
| get a database-touching tool switched on | each is enabled by name; no profile, variable or flag widens it, and nothing persists across a restart | tests/Feature/Agent/MutatingOptInNonEscalationTest.php |
| run the truth mode without anybody agreeing | a recorded consent is required, and the production guard refuses regardless | tests/Feature/Agent/LintShadowToolTest.php |
| make the server reach the network | no tool call attempts a request, and none triggers an advisory refresh | tests/Feature/Agent/McpNoImplicitNetworkTest.php |
| get logic or rights the engine did not grant | the agent layer holds no engine internals and reaches it only through named contracts | tests/Feature/Architecture/AgentLayerArchTest.php |
| read a clean answer out of a run that never happened | a run that ended in a misconfiguration is undetermined with a named reason, never an empty success | tests/Feature/Agent/LintPendingToolTest.php |
Residual risk, named rather than argued away
A compromised local agent already has your environment. It can open your database connections directly, without this server's help. That is the boundary of this model rather than a gap in the tool: nothing SQLens does at the protocol layer can protect a machine whose agent is already acting against you. What the design does buy is that the server adds no NEW reach — no port, no stored credential, no writes from the default tools.
An enabled shadow tool creates and drops databases. Behind the production guard, in an allowed environment, with a recorded consent — three checks, and none of them is overridable by a caller. It remains the largest thing this surface can do, and it is off until somebody decides otherwise.
Statistics-based context is estimated. Row counts and sizes come from what the server already knows rather than from a count, so they are approximations by design. They are labeled as estimates wherever they appear, and no verdict is derived from one alone.
Advisory data is as fresh as the last explicit refresh. Nothing fetches on its own — that is the point — so an old data file yields an old answer. A refresh is a command somebody runs.
A refusal still tells you something. An agent learns that a connection name exists, or that a rule identifier is real, from the refusal it gets. That is a small and deliberate leak: the alternative is a surface that cannot say why it said no, which costs far more than it saves.
What is decided rather than open
The transport is stdio, and HTTP is out of scope for this version — with the reasoning in the trust boundary page. There is no undecided question hiding in this model; anything that is not settled is in the section above, as a risk.