Skip to main content
Guide4 min read

MCP access control lists: role-based permissions for tool-calls

ACLs for MCP tools work differently from traditional RBAC. Here is the model, a working policy syntax, and the traps that catch every first implementation.

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 call query_db with a read-only statement against schema analytics" 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:

  1. Decision is in-process or sub-10ms. An MCP call that waits 200ms for auth is broken UX.
  2. 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" == 1 in 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:

  1. Tier annotations on every tool. Week 1.
  2. Role-based policies at the tier level. Week 2.
  3. Principal-chain verification. Weeks 3–4.
  4. Argument-level predicates for high-risk tools. Month 2.
  5. Context-aware policies (time, risk, approval). Month 3+.

Skip steps at your peril — each assumes the previous is in place.

Loadout

Build your AI agent loadout

Directory
Contact
© 2026 Loadout. Built on Angular 21 SSR.