AI Agents are rapidly evolving beyond simple Retrieval-Augmented Generation (RAG) and are now expected to take action. This is made possible through standards like the Model Context Protocol (MCP), which allows agents to interact with external tools and APIs. However, this new capability introduces a critical challenge: implementing fine-grained permissions and access controls based on “who can do what?”.
Hardcoding if/else
statements for user roles is not a scalable or secure solution. Modern applications require a dynamic authorization model that can make decisions based on a rich set of attributes - a model often referred to as Policy-Based Access Control or Attribute-Based Access Control.
This guide will walk you through building a secure MCP server where AI Agent tool access is managed by Cerbos, a decoupled, policy-driven authorization service. You will learn how to enforce fine-grained authorization by externalizing access controls into human-readable policies.
See how to implement dynamic authorization for AI agents, and fine-grained permissions in MCP servers, using Cerbos - speak with an engineer.
When an AI Agent acts on behalf of a user, it must be subject to delegated (or an attenuated form of) permissions as that user. The challenge is that these Permissions are often complex and context-dependent. For example:
Implementing this logic directly in the MCP server creates brittle, hard-to-manage code. A change in your authorization policy requires a code change and a full redeployment.
The solution is to decouple authorization logic from your application code.
The Model Context Protocol (MCP) is a specification that standardizes communication between AI Agents and external tools. An MCP server exposes a list of available tools, which any MCP client, be it a human in a chat application or a native AI agent, can then invoke to perform actions in your system.
Cerbos is a stateless, open source authorization service that externalizes access controls into declarative YAML policies. Your application queries the Cerbos Policy Decision Point with a question like, "Can this principal perform this action on this resource?" Cerbos evaluates the relevant policies and returns a simple allow/deny decision in milliseconds. This enables powerful PBAC and ABAC without complicating your application logic.
By combining MCP and Cerbos, you build a system where the MCP server defines all possible tools, but dynamically enables only the ones the user has permission to use for a given request.
This sample MCP server can be found at https://github.com/cerbos/cerbos-mcp-authorization-demo
First, define your access controls in a Cerbos policy. This policy will govern which roles have permission to use which tools (actions).
Create a policies
directory and add the following mcp_expenses.yaml
file.
File: policies/mcp_expenses.yaml
apiVersion: "api.cerbos.dev/v1"
resourcePolicy:
version: "default"
resource: "mcp::expenses"
rules:
- actions: ["list_expenses"]
effect: EFFECT_ALLOW
roles: ["admin", "manager", "user"]
- actions: ["add_expense"]
effect: EFFECT_ALLOW
roles: ["user"]
- actions: ["approve_expense", "reject_expense"]
effect: EFFECT_ALLOW
roles: ["admin", "manager"]
- actions: ["delete_expense", "superpower_tool"]
effect: EFFECT_ALLOW
roles: ["admin"]
Run the Cerbos PDP in Docker, mounting your policies directory. This makes your authorization policies live and ready to be queried.
docker run --rm -it -p 3593:3593 \
-v "$(pwd)/policies":/policies \
ghcr.io/cerbos/cerbos:latest
Create a Node.js Express server that connects to the Cerbos PDP.
npm install express @modelcontextprotocol/sdk @cerbos/grpc
cerbos.checkResource
to perform a central authorization check. Based on the response, it dynamically enables only the permitted tools for the session. How the identity gets passed to this is out of scope, but with the recent OAuth improvements in the MCP spec, you will be able to token with the user's identity from an OAuth2 authorization server and pass it through to the MCP server.File: server.js
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { GRPC } from "@cerbos/grpc";
import { randomUUID } from "node:crypto";
const cerbos = new GRPC("localhost:3593", { tls: false });
async function getServer({ user, sessionId }) {
const server = new McpServer({ name: "CerbFinance MCP Server" });
// Example tools - actual implementation is out of scope
const tools = {
list_expenses: server.tool(
"list_expenses",
"Lists expenses.",
{},
{ title: "List Expenses" },
async () => ({ content: [{ type: "text", text: "..." }] })
),
add_expense: server.tool(
"add_expense",
"Adds an expense.",
{},
{ title: "Add Expense" },
async () => ({ content: [{ type: "text", text: "..." }] })
),
approve_expense: server.tool(
"approve_expense",
"Approves an expense.",
{},
{ title: "Approve Expense" },
async () => ({ content: [{ type: "text", text: "..." }] })
),
reject_expense: server.tool(
"reject_expense",
"Rejects an expense.",
{},
{ title: "Reject Expense" },
async () => ({ content: [{ type: "text", text: "..." }] })
),
delete_expense: server.tool(
"delete_expense",
"Deletes an expense.",
{},
{ title: "Delete Expense" },
async () => ({ content: [{ type: "text", text: "..." }] })
),
superpower_tool: server.tool(
"superpower_tool",
"Grants superpowers.",
{},
{ title: "Superpower Tool" },
async () => ({ content: [{ type: "text", text: "..." }] })
),
};
const toolNames = Object.keys(tools);
// Central Authorization Check
const authorizedTools = await cerbos.checkResource({
principal: { id: user.id, roles: user.roles },
resource: { kind: "mcp::expenses", id: sessionId },
actions: toolNames,
});
for (const toolName of toolNames) {
if (authorizedTools.isAllowed(toolName)) {
tools[toolName].enable();
} else {
tools[toolName].disable();
}
}
server.sendToolListChanged();
return server;
}
const app = express();
app.use(express.json());
// Middleware to simulate user authentication - use OAuth in production
app.use((req, res, next) => {
req.user = { id: "user-123", roles: ["user"] }; // Test different roles here
next();
});
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
const server = await getServer({
user: req.user,
sessionId: req.sessionId || randomUUID(),
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.log("MCP Server running on port 3000"));
You can test your server using the MCP Client extension in VS Code.
http://localhost:3000/mcp
.Example prompts by role: Change the roles
in server.js
to simulate different users.
user
(roles: ['user']
):
"Add an expense for $100"
-> Succeeds"Approve the expense"
-> Fails (The agent reports it doesn't have the tool).manager
(roles: ['manager', 'user']
):
"Approve expense 123"
-> Succeeds"Delete expense 123"
-> Failsadmin
(roles: ['admin']
):
"Delete expense 123"
-> SucceedsRole-Based Access Control is just the beginning. The real power of a decoupled authorization system is implementing ABAC. With Cerbos, you can write policies that use attributes from the user (principal
), the resource, or the request itself.
For example, to restrict managers to approving expenses only up to a certain amount, you could pass the amount as an attribute and write a condition, then do an additional check inside the tool implementation:
server.js
(snippet of the Cerbos call):
await cerbos.checkResource({
principal: { id: user.id, roles: user.roles },
resource: {
kind: "mcp::expenses",
id: sessionId,
attr: { amount: 150 } // Pass resource attributes
},
actions: ["approve_expense"],
});
policies/mcp_expenses.yaml
(snippet of the policy rule):
- actions: ["approve_expense"]
effect: EFFECT_ALLOW
roles: ["manager"]
condition:
match:
# The manager can only approve if the expense amount is less than 1000
expr: request.resource.attr.amount < 1000
This demonstrates true fine-grained authorization that goes far beyond simple roles.
By decoupling your authorization logic using Cerbos, you can build powerful, secure, and scalable AI Agents. This architecture allows you to manage Permissions through declarative policies, enabling you to implement everything from simple role-based rules to sophisticated ABAC without touching your application code. As AI agents become more integrated into our workflows, a robust, policy-driven approach to access controls is a necessity.
For further details on mastering dynamic authorization for MCP servers with Cerbos, check out this piece.
If you want to dive deeper, check out Cerbos PDP, join one of our engineering demos or check out our in-depth documentation.
Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.