Topic: mcp server role-based access control
MCP server role-based access control — implementing RBAC for multi-user agent deployments
A single-user stdio-transport MCP server runs under the same credentials as the developer using it — access control is handled by the OS. But team deployments change the threat model: when a single HTTP-transport MCP server accepts connections from multiple agents under different users, there is no ambient OS-level access boundary between them. A junior developer's agent can call the same delete_record tool as the database admin's agent unless the server explicitly enforces a role boundary. This is the RBAC problem, and the MCP protocol provides no built-in facility for it — the server must implement it.
Pattern 1 — tool-level authorization decorator
The simplest RBAC pattern wraps individual tool handlers with an authorization check. The caller's identity and role come from the MCP auth context set by the server's authentication middleware.
type CallerRole = 'viewer' | 'editor' | 'admin';
interface CallerContext {
userId: string;
role: CallerRole;
}
// Role hierarchy: admin > editor > viewer
function hasRole(caller: CallerContext, required: CallerRole): boolean {
const rank: Record<CallerRole, number> = { viewer: 1, editor: 2, admin: 3 };
return rank[caller.role] >= rank[required];
}
function requireRole(role: CallerRole, handler: (args: unknown, ctx: CallerContext) => Promise<CallToolResult>) {
return async (args: unknown, context: RequestContext): Promise<CallToolResult> => {
const caller = context.authInfo?.caller as CallerContext | undefined;
if (!caller) {
return { isError: true, content: [{ type: 'text', text: 'Authentication required.' }] };
}
if (!hasRole(caller, role)) {
return { isError: true, content: [{ type: 'text', text: `Role '${role}' required. Your role: '${caller.role}'.` }] };
}
return handler(args, caller);
};
}
// Tool registration with explicit role requirements:
server.tool('list_records', requireRole('viewer', async ({ filter }, caller) => { /* ... */ }));
server.tool('update_record', requireRole('editor', async ({ id, data }, caller) => { /* ... */ }));
server.tool('delete_record', requireRole('admin', async ({ id }, caller) => { /* ... */ }));
server.tool('admin_purge', requireRole('admin', async ({ scope }, caller) => { /* ... */ }));
Every tool's required role is explicit at registration time — a grep for requireRole gives you the complete access matrix for the server. New tools default to no authorization if the decorator is not applied, which is a bug; make the decorator mandatory in your team's code review checklist.
Pattern 2 — scope-based authorization (OAuth-style)
For MCP servers that integrate with OAuth-authenticated callers, map OAuth scopes to allowed tools rather than mapping user roles. This is appropriate when the caller identity comes from an OAuth access token rather than a direct session.
// Map each tool to the minimum OAuth scope required to call it
const TOOL_SCOPE_REQUIREMENTS: Record<string, string[]> = {
'list_records': ['read'],
'search_records': ['read'],
'update_record': ['read', 'write'],
'delete_record': ['read', 'write', 'delete'],
'admin_purge': ['read', 'write', 'delete', 'admin'],
};
function checkScopes(callerScopes: string[], required: string[]): boolean {
return required.every(s => callerScopes.includes(s));
}
// In each tool handler, or in a central dispatcher:
server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
const toolName = request.params.name;
const callerScopes: string[] = context.authInfo?.scopes ?? [];
const requiredScopes = TOOL_SCOPE_REQUIREMENTS[toolName];
if (!requiredScopes) {
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${toolName}` }] };
}
if (!checkScopes(callerScopes, requiredScopes)) {
const missing = requiredScopes.filter(s => !callerScopes.includes(s));
return { isError: true, content: [{
type: 'text',
text: `Insufficient scopes. Required: ${requiredScopes.join(', ')}. Missing: ${missing.join(', ')}.`
}]};
}
// Dispatch to the actual tool handler
return handleTool(toolName, request.params.arguments, context);
});
The scope map lives as a central constant rather than being distributed across handlers — changing the access requirements for a tool is one line change in one place. Audit it in code review: every new tool must have an entry in TOOL_SCOPE_REQUIREMENTS before the PR merges.
Pattern 3 — per-resource ownership check
Role-level checks tell you whether the caller's role can access the tool. But for tools that operate on specific records or resources, you also need to verify that the caller owns or has permission to access the specific resource being acted on — not just that they have the editor role in general.
server.tool('update_record', requireRole('editor', async ({ id, data }, caller) => {
// Role check passed (editor or above) — now check ownership
const record = await db.getRecord(id);
if (!record) {
return { isError: true, content: [{ type: 'text', text: 'Record not found.' }] };
}
// Admins can update any record; editors can only update their own
if (caller.role !== 'admin' && record.owner_id !== caller.userId) {
return { isError: true, content: [{ type: 'text', text: 'Permission denied: not your record.' }] };
}
const updated = await db.updateRecord(id, sanitize(data));
return { content: [{ type: 'text', text: `Record ${id} updated.` }] };
}));
The two-layer check (role gate → ownership gate) is the standard pattern for data APIs and applies directly to MCP tool handlers. Skip the ownership check and an editor can update any record by ID enumeration — a common over-privilege finding in team-deployed MCP servers.
Pattern 4 — org-level policy enforcement
For enterprise deployments where the role configuration should be centrally managed rather than hardcoded in the server, externalize the access policy to a policy store that the server consults at runtime:
// Policy store interface — backed by your IAM system (Okta, Auth0, AWS IAM, etc.)
interface PolicyStore {
isAllowed(userId: string, action: string, resource: string): Promise<boolean>;
}
// OPA (Open Policy Agent) implementation:
class OPAPolicyStore implements PolicyStore {
constructor(private opaUrl: string) {}
async isAllowed(userId: string, action: string, resource: string): Promise<boolean> {
const res = await fetch(`${this.opaUrl}/v1/data/mcp/allow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: { user: userId, action, resource } })
});
const { result } = await res.json() as { result: boolean };
return result === true;
}
}
// Tool handler using centralized policy:
server.tool('delete_record', async ({ id }, context) => {
const userId = context.authInfo?.caller?.userId;
if (!userId) return { isError: true, content: [{ type: 'text', text: 'Auth required.' }] };
const allowed = await policy.isAllowed(userId, 'delete', `record/${id}`);
if (!allowed) return { isError: true, content: [{ type: 'text', text: 'Not authorized.' }] };
await db.deleteRecord(id);
return { content: [{ type: 'text', text: `Deleted record ${id}.` }] };
});
OPA is one choice; HashiCorp Vault's policy engine, AWS IAM, and Cerbos are other options that follow the same pattern. The key invariant: the tool handler never contains hardcoded role names or user IDs — all access decisions go through the policy store, which can be updated without redeploying the MCP server.
Tools the SkillAudit engine flags for missing RBAC
The permissions axis flags: tool handlers that call delete, drop, truncate, admin, purge, or reset methods without any preceding authorization check; tools with names matching /^(admin|delete|remove|drop|purge|reset)_/ with no if (!authorized) return pattern in the handler body; and API clients initialized with admin-scoped tokens where only read-scoped operations are performed.
Check your MCP server's permissions grade at skillaudit.dev — paste the GitHub URL for a 60-second audit.