|

How to scope an MCP server’s WordPress access (so an agent can’t do everything an admin can)

You’ve installed the MCP adapter on a site, pointed Claude Code or Cursor at it, and the setup asks for a credential. The only one WordPress hands you by default is an Application Password, generated from your own profile screen under Users. You’re an Administrator, so the agent is now an Administrator. It can install a plugin, add a user, edit every post, and change the site URL. Nothing in the connection setup asked you to narrow that, and nothing in it will.

Narrowing it is a job with four separate levers, and they sit in four different places: the ability’s own permission callback, the MCP server’s transport gate, the WordPress user account the whole connection authenticates as, and the credential’s lifetime. Three of those you can set today. The fourth is where every current implementation stops.

Our overview of what today’s WordPress MCP servers let an agent do compares the servers; this piece is about configuring one.

Every permission check runs against one WordPress user

Whatever account you point the connection at is the whole of what the agent can do, because an MCP server has no permissions of its own. It runs inside WordPress, and every check it performs asks the same question WordPress always asks: can the current user do this? The agent is never a subject in that sentence. Whichever WordPress account the connection authenticates as is the account being asked about, on every single call.

The STDIO transport puts that account on the command line. WordPress core’s MCP Adapter ships a WP-CLI command, and its documented syntax is wp mcp-adapter serve [--server=<server-id>] [--user=<id|login|email>], where the --user flag means “Run as a specific WordPress user for permission checks. Without this, runs as unauthenticated (limited capabilities).”

That flag is the entire scoping decision, typed on one line. Most tutorials fill it with admin because that’s the account the author was logged into. Every capability check downstream then evaluates against an Administrator, and every ability that checks any capability at all returns true.

What the Abilities API scopes, and what it only annotates

WordPress 6.9 introduced the Abilities API, a registry where a plugin declares a discrete named action rather than exposing the REST API wholesale. Registration is wp_register_ability( string $name, array $args ): ?\WP_Ability, hooked to wp_abilities_api_init, and two of its arguments do the security work.

permission_callback is required, documented as “A callback function to check if the current user has permission to execute this ability.” Writing in the developer blog on February 4, 2026, Jonathan Bossenger states the rule for it plainly: “Each ability should check the minimum capability needed (manage_options, edit_posts, etc.).”

Exposure is the next gate, and it’s opt-in twice over. In the Abilities API itself, meta.show_in_rest defaults to false, and when it is false “the ability will be hidden from REST API listings and cannot be executed via REST endpoints, but remains available for internal PHP usage.” The MCP Adapter adds its own: “WordPress abilities are private by default. Set meta.public (or meta.mcp.public) to true to expose one.”

Annotations are the block most often mistaken for scoping. Abilities carry meta.annotations with readonly (“Whether the ability only reads data without modifying its environment,” default false), destructive (default true), and idempotent (default false). These are hints published to the agent, and nothing enforces them against it. An ability annotated readonly => true whose execute callback writes to the database will write to the database. Treat annotations as documentation the agent reads, and the permission callback as the thing that stops a call.

The transport gate, and the default that catches people

Above the per-ability checks sits a per-server one. The MCP Adapter’s transport permission guide is explicit about what happens when you don’t set it: “By default, servers use is_user_logged_in(), but you can implement custom authentication logic.” A server left at that default admits any authenticated user on the site and delegates every real decision to the individual abilities.

Setting a stricter gate is a closure passed as the last argument to create_server(); the guide’s own example returns current_user_can('manage_options'). A narrower one is usually what you want:

function(): bool {
    return current_user_can( 'edit_posts' );
}

Read the fallback behavior before you rely on a custom callback. The MCP Adapter documents its error handling as “Automatic Fallback: Exceptions fall back to is_user_logged_in().” A permission callback that throws, whether from a null dereference on an edge case or a helper function that isn’t loaded yet in that request, does not fail closed. It fails back to the weakest gate the server offers, and the failure is logged rather than surfaced. Test the callback’s error paths alongside its happy path.

The two transports, and what each does to your risk

The adapter supports HTTP and STDIO, and they carry different credentials.

STDIO, over WP-CLI, is for local development. The client launches wp as a subprocess, so there’s no network credential at all; the trust boundary is your filesystem and whatever --user you named. This is the safer transport, and it’s also the one most agent tutorials demonstrate, so a walkthrough’s threat model does not carry over to the production site you point at over HTTP.

HTTP reaches a running site at /wp-json/mcp/mcp-adapter-default-server. For clients that can’t speak HTTP MCP directly, the adapter’s docs point at the @automattic/mcp-wordpress-remote proxy, and state its credential outright: “Authentication uses WordPress Application Passwords.” That credential decides your blast radius. Application Passwords carry the full capability set of the account that created them, and WordPress’s own Application Passwords guidance offers no scoping mechanism. Its only advice is to “Revoke any credential that is no longer needed,” which is a person’s job on a schedule nobody set.

The managed path shows what the defaults could look like. WordPress.com’s MCP documentation states that “The WordPress.com MCP server uses OAuth 2.1 for secure authentication,” that “By default, all read-only MCP tools are enabled, and write tools are disabled,” and that “only secure, expiring access tokens are used.” On a self-hosted site over HTTP, none of those three defaults is available to you. You supply them yourself or go without.

Build the agent its own account before you build it a connection

The practical sequence, in the order that keeps each step from undoing the last:

  1. Create a dedicated WordPress user for the agent. It should be its own user, separate from any shared service account and from your own login. One user per agent connection, named for the connection, so a look at Users tells you what’s connected.
  2. Give it the lowest role the task needs. An agent that drafts posts is a Contributor. An agent that reads settings for diagnosis is whatever custom role you build with only the read capabilities in question. If a role in the list feels close enough, build the custom one anyway; capabilities are cheap to define and expensive to over-grant.
  3. Register or audit the abilities it will call, confirming each one’s permission_callback checks a capability that role has, and that nothing else in the exposed set checks the same capability by accident.
  4. Set the server’s transport permission callback explicitly, rather than inheriting is_user_logged_in().
  5. Generate the Application Password from the agent’s account, not yours, and name it for the connection so the profile screen is an inventory rather than a list of unlabeled strings.
  6. Put a revocation date in the same place you track everything else that expires. Nothing in WordPress will remind you.

Where each layer helps, and where it stops

LayerWhat it decidesWhat it can’t decide
The WordPress user’s roleWhich capabilities exist to be checked at allAnything about time, or who is driving the session
permission_callback per abilityWhether this specific call is allowed for that userWhether a different ability checking the same capability is allowed
meta.mcp.public / show_in_restWhether the ability is reachable or even discoverableWhat happens once it is reachable
Transport permission callbackWho gets to talk to this server at allWhich of the server’s abilities they may call
Application PasswordNothing. It carries the account wholeScope, expiry, or which session is using it

What role-scoping still doesn’t do

Do all six steps and you’ve genuinely narrowed the connection. Three gaps remain, and no amount of configuration closes them.

Capabilities can’t separate two abilities that check the same one. The permission layer’s unit is a capability, not an action. If the read your agent needs is gated on manage_options, then every write also gated on manage_options is open to it, and no permission callback in that set can tell the two apart. “Read-only agent” is a statement about which abilities you exposed, not a property the credential can enforce. Expose one write ability by accident and the read-only claim is gone with no error anywhere.

The credential doesn’t expire. An Application Password on a Contributor account is a smaller problem than one on an Administrator account, but it’s the same shape of problem: it sits in the Users profile screen until a human deletes it, long after the chat session that needed it ended.

Nothing records which session was driving. The site knows a user made a call. It doesn’t know whether that was your agent run on Tuesday, the same credential pasted into a second client, or someone who found it in a config file. Attribution stops at the account.

The same three gaps, already solved for human support

Those three gaps aren’t specific to agents. They’re the reason support access is a product category at all: scope answers what, and it takes time, revocation, and identity to answer how long and by whom.

For human support sessions, TrustedLogin already closes all three. The customer’s site creates a real, scoped WordPress account when they click Grant Access inside the vendor’s plugin, and its own WP-Cron deletes that account on schedule whether or not TrustedLogin’s servers are reachable. The customer can revoke it from their own Users screen, every session is on the record against a named support agent, and the login is encrypted before it leaves their server, decryptable only by the Connector running on the support team’s own site, never by TrustedLogin’s servers, which relay ciphertext they hold no key for. The exchange is drawn out step by step on how it works.

An agent that touches a customer’s site needs the same arrangement. We’re extending it to agents next. Until then, the configuration above is what you have: the agent’s own account, the lowest role that works, an explicitly gated server, and a revocation date on your calendar, because WordPress will not set one for you.

Similar Posts