# Two-axis MCP authorization — scope and capability

Web page: https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l2/

Lesson 5 of 7 in [Agent-Discoverable Surfaces](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/), 14 min.

Video: https://www.youtube.com/watch?v=1QCy-a_lSPQ

**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:

```php
// 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:

```php
// 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:

```php
// 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:

```python
# 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**

- ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html) — the broader framing for why agent-reachable surfaces stress assumptions single-developer testing never does.

## 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.

Next: [Allowlist OAuth redirect URIs by host, not full path](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l3/)
