MemberPress AI Academy memberpress.ai

Auth · Auth and rate limits for agent traffic

Two-axis MCP authorization — scope and capability

Video14 min

Hook

An Author-role user with a full-scope MCP token created a WordPress admin through Claude Desktop. The dashboard would have stopped them at the click. The MCP server didn't, because it never asked WordPress whether this user was allowed.

Concept

Prerequisites: you have shipped a tool dispatcher behind an OAuth boundary, you know what a token scope is, and you have written a handler that calls some kind of current_user_can or request.user.has_perm. This lesson assumes you already picture the MCP server as an HTTP service with token-scoped clients (l1 is the other AI-traffic gotcha on this same surface).

One word to disambiguate first: capability here means the platform's permission — create_users, manage_options, the cap WordPress checks before letting a click through the dashboard. Not OAuth scope, not "what tools this MCP server offers." Same word, three uses. We mean the platform one.

Two axes, not one

A token scope tells you what kind of action the client can do at all: read or write. A platform capability tells you whether this user may do this specific action. They are independent.

Picture a hotel keycard and a room number. The keycard says you have a room in this hotel. The room number says which door it opens. A scope-only check is the lobby waving any keyholder onto every floor. The dashboard already knows the room numbers — every current_user_can call is a door — but the MCP server walked the guest past them.

When AI agents are the clients, this gap is reachable from a chat box. A user who would never click the WP "Add User" button can ask Claude to do it for them.

Walk-through

A tool descriptor that names both axes:

// includes/tool-registry.php
'memberpress_create_member' => [
    'requires_full_access' => true,           // axis 1: scope
    'requires_cap'         => 'create_users', // axis 2: WP cap
    'handler'              => 'create_member_handler',
],

The dispatcher checks them in order — scope first because it is cheap and broad, capability second because it is specific and needs user context:

// includes/dispatcher.php
if ($tool['requires_full_access'] && ! $token_has_full_scope) {
    return error('SCOPE_INSUFFICIENT', 403);
}

if (! empty($tool['requires_cap']) && ! current_user_can($tool['requires_cap'])) {
    return error('CAPABILITY_INSUFFICIENT', 403);
}

Two distinct error codes is load-bearing. SCOPE_INSUFFICIENT means "your token cannot do writes at all, request a fuller token." CAPABILITY_INSUFFICIENT means "your token is fine, your user isn't, talk to whoever owns the role." One 403 for both forces guesswork at the chat log.

The capability check only works if the request knows who the user is. In the auth callback that resolves the token, set the WP user before any handler runs:

// includes/auth-callback.php
$token = $this->resolve_token($bearer);
wp_set_current_user($token->user_id);

Skip this line and current_user_can returns false everywhere — including for the legitimate token owner — so every write tool 403s for the wrong reason.

The translation move

The shape ports to a Flask + DRF-style stack. Two permission classes, two distinct 403 responses, one per class:

# routes/tools.py
class CreateMemberView(APIView):
    permission_classes = [TokenHasFullScope, HasPlatformPerm("members.add_member")]

    def post(self, request):
        # request.user is bound by TokenAuthentication before this runs
        ...

Do not invent a new permission system for the MCP layer. If the platform's UI requires create_users to add a user, the MCP tool requires create_users to add a user. Match the existing model. A parallel one means two places to keep in sync, and they will drift.

If you get stuck

  • The capability check passes for users it should block. The auth callback never set the user identity. Add wp_set_current_user($token->user_id) (or request.user = token.user in a Django auth backend) before any handler runs.
  • A read-only token can call write tools as long as the user has the cap. You wired requires_cap and skipped scope. Both axes, every dispatch.
  • You are on a hosted platform with no auth-middleware hook. Vercel functions in front of an external API, Cloudflare Workers, Zapier-style runtimes — there is no place to set a platform user before the handler runs. Do the capability check inside the tool body using the platform's own API (call it with the user's credentials and let it refuse), or do not expose tools whose capability axis you cannot enforce.

Cross-reference

l1 in this module is the same auth surface from the other side: rate-limiting /register so AI traffic does not lock legitimate users out. l1 closes the door on credential-less abuse; this lesson closes the door on scope-only abuse once the user has credentials.

Deliverable

A tool descriptor with both requires_full_access and requires_cap fields, a dispatcher that checks them in that order and returns SCOPE_INSUFFICIENT and CAPABILITY_INSUFFICIENT as distinct codes, and a probe — Author-role token, full scope, calls memberpress_create_member — that returns 403 CAPABILITY_INSUFFICIENT. The same probe with an Admin token returns 200.

Further reading

Try these prompts

  • Write a tool descriptor for memberpress_create_member that declares requires_full_access true and requires_cap create_users, and write the dispatch function that checks scope first and capability second with distinct error codes.
  • Write the auth callback that calls wp_set_current_user from the token's user_id, and explain why current_user_can returns false everywhere downstream if you skip it.
  • Translate the two-axis check into a Flask handler that uses a permission_classes-style decorator — show the scope check, the capability check, and the distinct 403 responses for each axis.