MCP server function-level authorization: per-tool access control patterns
Function-level authorization means enforcing access control at the granularity of individual tool invocations — not just at session establishment. OWASP API Security lists this as API5:2023. In MCP servers, the failure mode is an authenticated session that can invoke any tool, including admin and destructive tools, simply because no per-tool authorization check was implemented. Authentication answers "who"; authorization answers "what are they allowed to do."
The authentication vs. authorization gap
Most MCP servers authenticate the caller — they verify that the session token is valid before processing any tool call. This is necessary but not sufficient. Consider a server with three tools: read_document, update_document, and delete_all_documents. If the session token check is the only gate, then any authenticated caller — including a read-only integration with a narrow scope — can invoke delete_all_documents. The tool's destructive capability is only protected by authentication, not authorization.
This is OWASP API5:2023 (Broken Function Level Authorization) applied to MCP servers. The pattern is common because MCP server tutorials focus on authentication setup and leave per-tool authorization as an exercise. In practice, "exercise" means "never implemented."
Pattern 1: per-tool RBAC check
Each tool handler must check whether the caller's role permits invocation of that specific tool. Define a tool-to-required-role mapping and enforce it before any tool logic executes:
type Role = 'reader' | 'editor' | 'admin';
const TOOL_REQUIRED_ROLES: Record<string, Role[]> = {
read_document: ['reader', 'editor', 'admin'],
list_documents: ['reader', 'editor', 'admin'],
update_document: ['editor', 'admin'],
create_document: ['editor', 'admin'],
delete_document: ['admin'],
delete_all_documents: ['admin'],
export_workspace: ['admin'],
};
function requireRole(toolName: string, callerRole: Role): void {
const allowed = TOOL_REQUIRED_ROLES[toolName];
if (!allowed) {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
}
if (!allowed.includes(callerRole)) {
throw new McpError(
ErrorCode.InvalidRequest,
`Role '${callerRole}' is not authorized for tool '${toolName}'`
);
}
}
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const callerRole = extractRole(req.params._meta?.authToken);
requireRole(req.params.name, callerRole); // runs before any tool logic
// ... tool dispatch
});
The RBAC check runs before the tool dispatch switch. A role that is not in the allowed list for a tool receives a 403-equivalent error, not just a missing result. This is the minimum viable function-level authorization for any MCP server that has more than one caller type.
Pattern 2: tool capability scopes
Role-based access control works well for user-facing scenarios. For machine-to-machine integrations, OAuth-style capability scopes are a better model — the caller specifies which tool groups they need at token issuance time:
// Scope definitions
const TOOL_SCOPES: Record<string, string> = {
read_document: 'documents:read',
list_documents: 'documents:read',
update_document: 'documents:write',
create_document: 'documents:write',
delete_document: 'documents:delete',
delete_all_documents: 'documents:delete',
list_users: 'users:read',
update_user_role: 'users:admin',
};
function requireScope(toolName: string, callerScopes: string[]): void {
const required = TOOL_SCOPES[toolName];
if (!required) throw new McpError(ErrorCode.MethodNotFound, `Unknown tool`);
if (!callerScopes.includes(required)) {
throw new McpError(
ErrorCode.InvalidRequest,
`Missing scope '${required}' for tool '${toolName}'`
);
}
}
Scopes are included in the JWT at issuance. An integration issued with ['documents:read'] cannot invoke update_document even if it presents a valid token. The scope check is structural — the integration never had the capability, not just the permission.
Pattern 3: deny-by-default tool registry
The safest architecture is one where tools are registered with explicit allow-lists per role or scope, and any tool not in the registry is rejected. This inverts the default: unknown tools fail closed rather than fail open:
class ToolRegistry {
private tools = new Map<string, {
handler: ToolHandler,
requiredScope: string,
description: string
}>();
register(name: string, requiredScope: string, handler: ToolHandler) {
this.tools.set(name, { handler, requiredScope, description: '' });
}
async dispatch(name: string, args: unknown, callerScopes: string[]) {
const entry = this.tools.get(name);
if (!entry) {
// Deny-by-default: unknown tool = method not found, not an open pass-through
throw new McpError(ErrorCode.MethodNotFound, `Tool not registered: ${name}`);
}
if (!callerScopes.includes(entry.requiredScope)) {
throw new McpError(ErrorCode.InvalidRequest, `Insufficient scope for ${name}`);
}
return entry.handler(args);
}
}
const registry = new ToolRegistry();
registry.register('read_document', 'documents:read', handleReadDocument);
registry.register('delete_document', 'documents:delete', handleDeleteDocument);
The deny-by-default registry means that a newly-added tool handler that was accidentally registered without a scope check cannot be reached until it is explicitly added to the registry with its required scope. New tools are safe-by-default.
Pattern 4: tool name injection prevention
An attacker who can control the tool name in a request could attempt to invoke tools they shouldn't reach by guessing or enumerating tool names. The deny-by-default registry prevents unknown tool names from succeeding, but you should also validate that tool names match a known pattern before reaching the dispatch layer:
const TOOL_NAME_RE = /^[a-z][a-z0-9_]{1,63}$/;
function validateToolName(name: unknown): string {
if (typeof name !== 'string') throw new McpError(ErrorCode.InvalidParams, 'tool name must be a string');
if (!TOOL_NAME_RE.test(name)) throw new McpError(ErrorCode.InvalidParams, `invalid tool name: ${JSON.stringify(name)}`);
return name;
}
This prevents path-traversal-style attacks on tool names (../admin_tool), null byte injection (read_document\x00), and excessively long names designed to cause log injection or buffer issues in downstream consumers.
Pattern 5: runtime tool invocation audit log
Log every tool invocation with the caller's identity, the tool name, the authorization decision (permit/deny), and the outcome. This creates an audit trail that supports both security monitoring and post-incident forensics:
function auditToolCall(
toolName: string,
callerId: string,
callerScopes: string[],
decision: 'permit' | 'deny',
reason?: string
) {
logger.info('tool_authz', {
tool: toolName,
caller: callerId,
scopes: callerScopes,
decision,
reason: reason ?? null,
ts: new Date().toISOString(),
});
}
Denied invocations are especially important to log — a pattern of deny events for delete_all_documents from a read-only integration is a strong signal of either a misconfigured caller or an active privilege escalation attempt. The audit trail and SOC2/GDPR compliance post covers audit log retention and SIEM forwarding.
What SkillAudit checks for function-level authorization
SkillAudit scans for function-level authorization failures by inspecting the authorization architecture of tool handlers:
- Missing per-tool auth check: tool handlers that execute business logic without a preceding role or scope verification step
- Same handler for read/write: a single tool handler that performs both read and destructive operations without differentiating based on caller scope
- No authorization on destructive tools: tools with names matching
delete_*,purge_*,reset_*,drop_*,revoke_*that have no scope check - Admin tool without admin scope: tools with names matching
admin_*,manage_*,configure_*accessible with non-admin roles
Function-level authorization gaps map to the Authorization sub-score. A server with destructive tools accessible without explicit authorization will not receive a grade above C. For the full authorization scoring model, see the scorecard methodology. For the foundational ambient authority problem that function-level authorization solves, see the ambient authority post.
Check your MCP server's function-level authorization
SkillAudit scans tool handlers for missing scope checks, admin tools without admin-only guards, and read/write conflation. Get your grade and prioritized fixes in under 2 minutes.
Run free scan →