Authorization·RBAC·OWASP API5

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:

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 →