MCP Authorization
VoltAgent provides an authorization layer for MCP (Model Context Protocol) tools. This allows you to control which tools users can see and execute based on roles, permissions, or any custom logic.
Overview
The MCP authorization layer supports two modes:
- Tool Discovery Filtering (
filterOnDiscovery) - Hide tools from users who don't have permission to use them - Execution-Time Checks (
checkOnExecution) - Verify permissions before each tool call
You can enable either or both modes depending on your security requirements.
Quick Start
Add authorization with the can function:
import { MCPConfiguration } from "@voltagent/core";
const mcp = new MCPConfiguration({
servers: {
myServer: {
type: "http",
url: "http://localhost:3000/mcp",
},
},
authorization: {
can: async ({ toolName, action, userId, context }) => {
const roles = (context?.get("roles") as string[]) ?? [];
// Admin-only tools
if (toolName === "delete_item" && !roles.includes("admin")) {
return { allowed: false, reason: "Only admins can delete items" };
}
return true;
},
filterOnDiscovery: true,
checkOnExecution: true,
},
});
// Get tools with authorization context
const tools = await mcp.getTools({
userId: "user-123",
context: { roles: ["manager"], department: "engineering" },
});
The can Function
The can function receives authorization parameters and returns whether access is allowed.
Parameters
interface MCPCanParams {
/** Tool name (without server prefix) */
toolName: string;
/** Server/resource identifier */
serverName: string;
/** The action being authorized: "discovery" or "execution" */
action: "discovery" | "execution";
/** Tool arguments (only available for "execution" action) */
arguments?: Record<string, unknown>;
/** User identifier */
userId?: string;
/** User-defined context Map */
context?: Map<string | symbol, unknown>;
}
Return Value
Return a boolean or an object with allowed and optional reason:
// Simple boolean
return true;
return false;
// Object with reason (shown in error message when denied)
return { allowed: true };
return { allowed: false, reason: "Insufficient permissions" };
When Actions Are Called
The action parameter tells you whether the check is for listing tools or executing a tool.
Discovery Action
When you call getTools() with filterOnDiscovery: true, the can function is called for each tool with action: "discovery".
Your code:
const mcp = new MCPConfiguration({
servers: { expenses: { type: "http", url: "http://localhost:8080/mcp" } },
authorization: {
can: async (params) => {
console.log("can() called with:", JSON.stringify(params, null, 2));
return true;
},
filterOnDiscovery: true,
},
});
const tools = await mcp.getTools({
userId: "user-123",
context: { roles: ["manager"] },
});
What can receives (for each tool on the server):
{
"toolName": "list_expenses",
"serverName": "expenses",
"action": "discovery",
"userId": "user-123",
"context": { "roles": ["manager"] }
}
{
"toolName": "add_expense",
"serverName": "expenses",
"action": "discovery",
"userId": "user-123",
"context": { "roles": ["manager"] }
}
{
"toolName": "delete_expense",
"serverName": "expenses",
"action": "discovery",
"userId": "user-123",
"context": { "roles": ["manager"] }
}
Note: arguments is undefined during discovery since no tool is being executed yet.
Execution Action
When a tool is executed (via agent interaction or direct call) with checkOnExecution: true, the can function is called with action: "execution" and the tool's arguments.
Your code:
const mcp = new MCPConfiguration({
servers: { expenses: { type: "http", url: "http://localhost:8080/mcp" } },
authorization: {
can: async (params) => {
console.log("can() called with:", JSON.stringify(params, null, 2));
return true;
},
checkOnExecution: true,
},
});
const tools = await mcp.getTools();
const agent = new Agent({
name: "Assistant",
model: "openai/gpt-4o",
tools,
});
// When user says "add an expense for $50 coffee"
// and the agent decides to call the add_expense tool:
await agent.generateText("Add an expense for $50 coffee", {
userId: "user-123",
context: new Map([["roles", ["manager"]]]),
});
What can receives when the agent calls add_expense:
{
"toolName": "add_expense",
"serverName": "expenses",
"action": "execution",
"arguments": {
"amount": 50,
"description": "coffee"
},
"userId": "user-123",
"context": { "roles": ["manager"] }
}
Different Logic per Action
You can use different authorization logic depending on the action:
can: async ({ toolName, action, arguments: args, userId, context }) => {
const roles = (context?.get("roles") as string[]) ?? [];
if (action === "discovery") {
// During getTools(): hide admin tools from non-admins
if (toolName.startsWith("admin_") && !roles.includes("admin")) {
return false; // Tool won't appear in the list
}
}
if (action === "execution") {
// During tool execution: check specific permissions
if (toolName === "delete_expense") {
if (!roles.includes("admin")) {
return { allowed: false, reason: "Only admins can delete expenses" };
}
}
// Check argument-based permissions (only available during execution)
if (toolName === "transfer_funds" && args?.amount > 10000) {
if (!roles.includes("senior_manager")) {
return { allowed: false, reason: "Transfers over $10,000 require senior manager approval" };
}
}
}
return true;
};
Authorization Context
When calling getTools() or during tool execution, provide authorization context:
interface MCPAuthorizationContext {
userId?: string;
context?: Map<string | symbol, unknown> | Record<string, unknown>;
}
// Example: pass context to getTools()
const tools = await mcp.getTools({
userId: "user-123",
context: {
roles: ["admin"],
tenantId: "acme-corp",
permissions: ["read", "write"],
},
});
The context is converted to a Map and passed to your can function.
Examples
Role-Based Access
const mcp = new MCPConfiguration({
servers: {
/* ... */
},
authorization: {
can: async ({ toolName, context }) => {
const roles = (context?.get("roles") as string[]) ?? [];
const toolPermissions: Record<string, string[]> = {
list_items: ["user", "manager", "admin"],
create_item: ["user", "manager", "admin"],
delete_item: ["admin"],
admin_panel: ["admin"],
};
const allowedRoles = toolPermissions[toolName] ?? [];
const hasPermission = roles.some((role) => allowedRoles.includes(role));
if (!hasPermission) {
return { allowed: false, reason: `Requires one of: ${allowedRoles.join(", ")}` };
}
return true;
},
filterOnDiscovery: true,
checkOnExecution: true,
},
});
Attribute-Based Access Control
Use tool arguments for attribute-based access control (only available during execution):
const mcp = new MCPConfiguration({
servers: {
/* ... */
},
authorization: {
can: async ({ toolName, action, arguments: args, userId }) => {
// arguments is only available during execution
if (action === "execution" && toolName === "get_user_data") {
const requestedUserId = args?.userId as string;
if (requestedUserId !== userId) {
return { allowed: false, reason: "Can only access your own data" };
}
}
return true;
},
checkOnExecution: true,
},
});
Policy-Based Authorization with Cerbos
For production applications, use a dedicated authorization service like Cerbos. Cerbos is an open-source authorization layer that lets you define access policies in YAML files.
See the full working example: examples/with-cerbos
Project Structure
with-cerbos/
├── docker-compose.yml # Cerbos PDP container
├── policies/
│ └── mcp_tools.yaml # Authorization policies
├── src/
│ ├── index.ts # Agent with MCP authorization
│ └── mcp-server.ts # MCP server with tools
└── package.json