RBAC solved "which endpoints can this user call" for the API era. Agents need a different shape: "which tool, with which arguments, on behalf of which principal, at what time." An ACL layer for MCP is where that logic lives.
What makes MCP ACLs different
Three properties distinguish MCP access control from classical RBAC:
- The caller is a model, not a user. The human is behind a chain of agents and tools; authorisation has to follow the chain end to end.
- Arguments matter. "Can call
query_db" is not granular enough. "Can callquery_dbwith a read-only statement against schemaanalytics" is. - Context shifts fast. A user escalates permissions for one task, then returns to baseline. MCP calls happen mid-session.
A useful ACL system answers four questions per call: who, what, with what arguments, and in what context.
A concrete policy model
Think of each MCP tool call as a triple plus context:
(principal, tool, arguments, context) → allow | deny | elevate
- principal — the end-user identity, plus the chain of agents.
- tool — server + tool name.
- arguments — the actual parameters.
- context — time, geography, risk score, current approval status.
The policy engine evaluates predicates over this tuple. An example policy in a Cedar-like syntax:
permit(
principal in Role::"support_agent",
action == Action::"call_tool",
resource == Tool::"postgres_mcp::query"
) when {
resource.argument("statement").startsWith("SELECT") &&
resource.argument("schema") == "analytics" &&
context.business_hours == true
};
Three clauses — role, tool, and argument/context predicates. Any OPA, Cedar, or custom decision service can express this.
The four permission tiers
Encode tools into tiers so policies compose predictably:
| Tier | Default policy | Examples |
|---|---|---|
public |
allow | search, docs, public API |
read |
allow with scope | SELECT, GET, list |
write |
require confirmation | POST, INSERT, PR comment |
destructive |
require dual approval | DELETE, DROP, merge, payment |
Annotate each tool's manifest with its tier. The policy engine uses the annotation; the agent host uses it for UI hints (e.g., auto-approve read, prompt for destructive).
Principal chains
The principal is not a single user. It is a chain: user → supervisor agent → worker agent → tool. Each link can constrain or amplify permissions. The key rule: no link may grant more than the weakest upstream link already has.
Express the chain in a signed JWT-like token that travels with every call:
{
"iss": "auth-service",
"sub": "user:alice",
"agents": ["supervisor:triage-bot", "worker:sql-writer"],
"scopes": ["postgres.read.analytics"],
"exp": 1713960000,
"nonce": "…"
}
The MCP server verifies the chain and only executes if all links are within allowed scopes. This is the enforcement twin of zero-trust MCP architecture.
Building the policy engine
Three viable stacks as of April 2026:
- Open Policy Agent (OPA). Most mature, Rego language. Good for heavy enforcement.
- Cedar. AWS-backed, cleaner syntax for per-request decisions.
- Custom decision service. A small HTTP service with TypeScript rules. Lowest learning curve.
Whichever you pick, two architectural constants:
- Decision is in-process or sub-10ms. An MCP call that waits 200ms for auth is broken UX.
- Decision is logged. Every allow/deny with reasoning goes to an immutable log — needed for audit trails.
Argument-level predicates are the hard part
Most teams ship role-level ACLs in a week and argument-level ACLs in six months. The hard parts:
- Parsing structured arguments. SQL, paths, URLs, JSON-Path — each needs a parser to assert invariants. "Statement starts with SELECT" is trivially bypassed by
SELECT...;DROP TABLE. - Type coercion bugs.
"1" == 1in JavaScript is a common source of accidental allow. Pick a policy language with strict types. - Schema drift. The MCP server adds a new parameter; your predicates still pass. Gate schema changes through the policy review.
Pattern: do not write predicates directly against raw arguments. Write them against a normalised, validated representation — produce the normal form in the host and include it in the call.
Audit, alert, evolve
Every denied call is a signal. Structure the output:
{
"decision": "deny",
"principal": "user:alice+supervisor:triage-bot",
"tool": "postgres_mcp::execute",
"reason": "destructive verb, dual approval missing",
"risk_score": 0.7,
"trace_id": "…"
}
Feed this into your monitoring stack. A burst of denies for one principal is either a reconfiguration fail (annoy the user) or an attempted bypass (page the security team).
Rollout order
In order of ROI:
- Tier annotations on every tool. Week 1.
- Role-based policies at the tier level. Week 2.
- Principal-chain verification. Weeks 3–4.
- Argument-level predicates for high-risk tools. Month 2.
- Context-aware policies (time, risk, approval). Month 3+.
Skip steps at your peril — each assumes the previous is in place.