The Model Context Protocol (MCP) makes it trivial to hand an LLM a set of tools: list the tools, describe their arguments, and let the model call them. That convenience is also the risk. The moment an MCP server can run a query against your warehouse or write to a production table, the language model on the other end is effectively an operator with a keyboard, one that can be steered by whatever text lands in its context window.
This paper describes an architecture we've used to expose real enterprise data and tools to LLM clients without surrendering control: tools scoped by capability, grants issued per agent, SQL validated before execution, destructive actions gated behind approvals, and every write recorded. The examples draw on a self-hosted data platform that exposes 21 data connectors (warehouses, orchestrators, and knowledge bases) as governed MCP servers offering roughly 268 distinct tools.
Request path through a governed MCP server
LLM client
- ChatGPT, Claude
- or an agent
MCP server
- One per connector
- Scoped tools
Grant check
- Per-agent grants
- Refuse if ungranted
Validation
- Read-only SQL
- Approvals for writes
Data + audit
- Execute
- Record every write
Why MCP changes the risk model
Traditional data access is mediated by applications. A dashboard issues a fixed, reviewed query; a service account has a narrow, well-understood scope. MCP inverts this. The set of possible actions is no longer a handful of reviewed code paths; it is the full cross product of the tools you expose and the arguments a model can invent.
Two properties make this sharp. First, the caller is non-deterministic: the same prompt can produce different tool calls on different runs. Second, the caller is steerable by untrusted input. A document retrieved into context, a row returned from a previous query, or a user message can all influence which tool the model reaches for next. Prompt injection is not a corner case here; it is the threat model.
The consequence is that governance cannot live in the prompt. "You are a careful assistant that never drops tables" is a suggestion, not a control. Governance has to live in the server, enforced deterministically around every tool call, where the model's intentions are irrelevant and only its actual requests matter.
Scope tools, not just servers
The first design decision is granularity. It is tempting to expose one broad run_query tool per data source and be done. That collapses your entire security surface into a single tool whose blast radius is "anything expressible in SQL."
Instead, split tools by capability and name them by intent. In the platform above, every tool carries a prefix that declares its effect: read_list_tables, read_query_select, write_execute_sql, write_create_table. The prefix is not cosmetic; it is the primary axis of control. A read-only agent is simply one that has been granted only read_* tools; there is no code path by which it can reach a write_* tool, because that tool is never placed in its toolset.
This scoping also gives you per-category tool sets. A data warehouse exposes a different vocabulary than an orchestrator or a wiki, and the tools reflect that: querying tables, inspecting DAG runs, or searching documents are distinct verbs with distinct arguments. Connectors range from a handful of read tools to nearly twenty read plus eleven write tools each. A verification step in the build ensures every tool in the catalog has a matching executor and vice versa, so the advertised surface and the executable surface never drift apart.
Per-agent grants
Scoping tools is necessary but not sufficient. You also need to decide, per caller, which of those tools are reachable. We model this as an explicit grant: an agent is associated with a set of tool grants, and the executor validates the grant before executing anything.
The flow is deliberate. A tool call arrives carrying the calling agent's identity. Before the tool runs, the executor checks that this agent holds a grant for this specific tool. No grant, no execution: the call is refused before it touches a data source. Because grants are data, not code, they can be reviewed, changed, and revoked without a deploy, and they can be audited. At any moment you can answer "what is this agent actually able to do?"
This is the control that survives prompt injection. Even if an attacker convinces the model to attempt a destructive write, the attempt dies at the grant check unless that exact capability was deliberately extended to that exact agent.
Validating SQL before it runs
Tools that accept SQL are the sharpest edge, because a single write_execute_sql argument can smuggle arbitrary intent. Prefixes and grants tell you the category of a tool; they don't tell you what a specific SQL string will do. So the server parses and validates the statement itself.
Read tools enforce read-only SQL: the validator rejects anything that isn't a SELECT (and its safe relatives), so a tool advertised as read-only cannot be tricked into mutating data by embedding a write in its argument. Write tools run a separate validator that classifies the statement and flags the destructive ones: unbounded DELETE or UPDATE, DROP, TRUNCATE. Validation happens server-side, deterministically, on the actual string that will execute, never on the model's description of what it intends.
The principle generalizes beyond SQL: whenever a tool argument is itself a small language (a query, a path, a filter expression), validate it against an allowlist of shapes rather than trusting the caller to stay inside the lines.
Approvals for destructive actions
Some actions should never happen automatically, no matter who asked. Rather than executing a destructive statement, the server converts it into an approval-required event. The tool call does not silently succeed and it does not silently fail; it produces an alert that a human must act on, with the exact statement attached.
This turns the most dangerous class of operations into a review queue. The agent can propose a schema change or a bulk delete; a person decides whether it runs. Crucially, this is enforced at the same deterministic layer as everything else, so it applies uniformly across every connector and every agent, not as a per-tool afterthought. Combined with a per-tenant write gate that can disable writes entirely, you get a spectrum: fully read-only, writes-with-approval, or trusted automation, chosen per deployment rather than baked into the code.
Auditing every write
Governance you can't inspect after the fact is incomplete. Every write the platform executes is recorded in an audit trail: which agent, which tool, which statement, when. This does double duty. Operationally, it is how you answer "what changed and who changed it" when something looks wrong. For trust, it is what lets you extend write access at all: reversibility and accountability are what make automation acceptable to the people who own the data.
The audit log is also the raw material for tightening grants over time. If an agent has been granted a write tool it never legitimately uses, the log shows it, and the grant can be revoked. Governance is not a one-time configuration; it is a loop, and the audit trail closes it.
Grounding agents in a knowledge graph
A subtler failure mode is not a dangerous action but a confident wrong answer. An agent asked "which pipeline produces this table?" will happily hallucinate if it has nothing to ground on. The platform addresses this with a knowledge-graph compiler that turns wiki pages, warehouse metadata, and pipeline state into nodes, edges, and column-level lineage.
Grounding is a governance concern, not just a quality one. When agents cite a compiled graph (real lineage, real freshness, real ownership), their answers become checkable, and the tools they choose to call are informed by facts rather than guesses. An agent that knows a table's lineage is far less likely to reach for a destructive fix to a problem it misunderstood.
A checklist for production
The pattern that emerges is consistent, and it is worth stating as a checklist:
- Split tools by capability and name them by intent.
read_*andwrite_*prefixes make the security surface legible and make read-only truly read-only. - Grant tools per agent, as data. Enforce the grant before execution, review it independently, and revoke without a deploy.
- Validate argument-languages server-side. Parse SQL and reject statements that violate the tool's contract, on the real string, every time.
- Gate destructive actions behind approvals. Convert the dangerous class into a human review queue; keep a per-tenant kill switch for writes.
- Audit every mutation. Record agent, tool, statement, and time, and use the log to tighten grants.
- Ground agents in real metadata. Give them lineage and freshness to cite so their actions are informed, not invented.
None of these controls live in the prompt, and that is the point. MCP put a non-deterministic, steerable caller in front of your data. The way to make that safe is to assume the caller cannot be trusted and to enforce every limit in the one place the model cannot argue with: the server, around every call, on every run.