Scope the MCP Tools You Hand an Agent: A Least-Privilege Allowlist Design
As you add MCP servers to Antigravity 2.0, the set of tools every agent can reach quietly grows into an all-you-can-eat buffet. An agent that only needs to read files seeing delete and deploy tools is an accident waiting to happen. This walks through a least-privilege design that scopes tools per agent role, denies at call time, and gates destructive operations behind a second step, with working Python and field notes.
A background agent I had assigned read-only work turned out, one day, to be holding a file-delete tool. Nothing was actually deleted. But the simple fact that a permission it never needed had drifted within reach stayed with me for a while.
With Antigravity 2.0, this state arrives on its own as you connect MCP servers. Every server you wire in piles its exposed tools onto the heap of "available tools." The heap looks identical to every agent, and the line between who may use what is written down nowhere.
What this article works through is putting that line into code. The guiding idea is least privilege: show each agent only the tools its job requires, and nothing else.
Why an all-in tool set breeds accidents
Let me make the danger concrete first.
MCP's convenience is that connecting a server makes its tools immediately usable: file operations, shell execution, external APIs, databases, billing. The more you connect, the more tools appear. The trouble is that these new tools become visible to every agent without being sorted by role.
A nightly aggregation agent needs reading and summarizing, nothing more. But if deploy and delete tools live in the same environment, the moment the model misreads context there is room for it to reach for a tool it should never touch. In unattended background runs with no review, that room is the entrance to an incident.
A design without scoping fails two ways. Show destructive tools to a read-only agent and the odds of a misfire rise above zero. Narrow every agent uniformly and the agents that genuinely need power can no longer do their work. Drawing the line per role is the only path that satisfies both.
Hold the allowlist per agent role
The center of the design is an allowlist that enumerates, per agent role, the tools it may see. The key move is to attach permission to the agent, not to the tool.
Agent role
Allowed tools
Denied tools
Reporting (read-only)
read_file, list_dir, query_db_readonly
write_file, run_shell, deploy, delete_*
Article build (writes)
read_file, write_file, run_build
run_shell, deploy, charge_*, delete_*
Deploy (destructive, gated)
read_file, deploy (two-step)
delete_db, charge_*
The rows are roles; the columns are allowed and denied. The same tool set exposes a different surface depending on role — that is the point of the design. Default to "denied" and let through only what you explicitly list. Holding that deny-by-default line means a newly connected server never leaks to every agent the instant you wire it in.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A registry design that scopes MCP tools per agent role with deny-by-default allowlists, shown as Python you can drop in, so adding a new server never silently leaks tools to every agent
✦A guard that checks permission right before each tool call, plus a two-step approval gate that applies only to destructive tools like delete, charge, and deploy, with the exact ordering of checks
✦A minimal audit log that lets you reconstruct who was allowed what, and a safe way to grow allowlists over time, drawn from running automation across four indie sites
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
An allowlist that is merely defined does nothing. You need a single place, hit right before every tool call, that consults it. Wrap MCP tool execution in one layer and insert the check there.
from dataclasses import dataclass, field# Per-role allowlists (deny by default)ALLOWLISTS: dict[str, set[str]] = { "reporter": {"read_file", "list_dir", "query_db_readonly"}, "builder": {"read_file", "write_file", "run_build"}, "deployer": {"read_file", "deploy"},}# Destructive tools — require a second-step approval even when allowedDESTRUCTIVE = {"deploy", "delete_file", "delete_db", "charge_card", "run_shell"}class PermissionError_(Exception): pass@dataclassclass CallContext: role: str approved: set[str] = field(default_factory=set) # approved destructive opsdef guard(ctx: CallContext, tool: str) -> None: allowed = ALLOWLISTS.get(ctx.role, set()) if tool not in allowed: raise PermissionError_( f"[deny] role='{ctx.role}' is not permitted to use tool '{tool}'" ) if tool in DESTRUCTIVE and tool not in ctx.approved: raise PermissionError_( f"[hold] '{tool}' is destructive and needs an approval flag" )async def call_mcp_tool(ctx: CallContext, tool: str, **args): guard(ctx, tool) # 1. permission check (pre-call) result = await _raw_mcp_call(tool, **args) # 2. the actual call audit(ctx.role, tool, args) # 3. audit log return result
The order matters. Deny on the role allowlist first, and only after passing check whether the tool is destructive. Reverse it and you open a bypass where even an unauthorized tool "goes through if approved." Keep the priority fixed: allow first, approve second.
Make destructive tools two-step
Even a tool that clears the allowlist deserves a brake when the operation is irreversible — deleting, charging, deploying. Insert the small extra step of raising an approved flag through a separate path.
# Split destructive operations into "plan" and "execute"async def plan_then_execute(ctx: CallContext, tool: str, **args): if tool in DESTRUCTIVE: preview = await dry_run(tool, **args) # surface the blast radius if not human_or_policy_approves(preview): # human or policy approves raise PermissionError_(f"[abort] '{tool}' was not approved; stopping") ctx.approved.add(tool) # approve for this run only return await call_mcp_tool(ctx, tool, **args)
Here human_or_policy_approves becomes a human confirmation in an interactive session, or a mechanical check that "the target sits within the expected range" when fully unattended. For unattended deploys I started from a plain policy of halting automatically when the number of changed files crossed a threshold. Plain as it is, just having something that stops changes how the night feels.
Keep an audit trail of what was allowed
Allowlists are things you grow, so you need a record you can look back on: when, who, and what was allowed or denied. Don't over-engineer the audit log; a structured single line is enough.
import json, timedef audit(role: str, tool: str, args: dict, verdict: str = "allow"): line = json.dumps({ "ts": int(time.time()), "role": role, "tool": tool, "verdict": verdict, # allow / deny / hold "arg_keys": sorted(args.keys()), # keep keys, not values (avoid secrets) }) with open("mcp_audit.jsonl", "a") as f: f.write(line + "\n")
The small care is recording the argument keys, not their values. You avoid leaking secrets into the log while still being able to count how often each tool was denied. A role with frequent denials reads as a sign the allowlist hasn't caught up to reality.
How an allowlist grows in practice
Finally, I'd recommend not trying to finish it on paper. Write a perfect allowlist up front and it usually skews either too wide or too narrow.
The procedure I settled on has three steps:
Start a new agent with the minimal, read-only permissions.
Review each deny in the audit log and add only the tools that are genuinely needed.
Whenever you add a destructive tool, place it under the two-step approval gate at the same moment.
Running four sites — Claude Lab, Gemini Lab, Antigravity Lab, Rork Lab — as an indie developer, the kinds of agents quietly multiply. One that only reads AdMob reports, one that assembles App Store assets, one that pushes to production: different roles, different tools they should be allowed to see. Gathering those differences into the one place an allowlist provides shrinks the decision when wiring a new server down to a single question — does this agent get to see it?
Permission is far harder to remove than to add. That is exactly why I start narrow and open up a little at a time. It is what lets me trust an agent that runs quietly through the night.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.