Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/abr4xas/lean-mcp/archive/refs/heads/master.zip
Lean MCP
A small MCP server for WordPress. Any plugin can declare a Server that exposes its Abilities as MCP tools. There are no runtime dependencies, and nothing loads until a request reaches an MCP route.
- Speaks MCP 2026-07-28: stateless, with
server/discover, tools, prompts and resources. - Also answers clients of 2025-11-25 and 2025-06-18, without sessions.
- Authenticates with WordPress application passwords over HTTP Basic, or runs as a WP-CLI user over stdio.
- Requires WordPress 6.9+ (Abilities API in core) and PHP 8.4+.
Declaring a Server
add_action('lean_mcp_init', function () {
if (!function_exists('lean_mcp_register_server')) {
return;
}
lean_mcp_register_server('my-plugin', [
'title' => 'My Plugin', // Default: the id.
'version' => '1.0.0', // Reported to clients. Default: 1.0.0.
'instructions' => 'Read before you write.',
'capability' => 'edit_posts', // Connection Capability. Default: manage_options.
'abilities' => ['my-plugin/get-thing'], // By name, in this order...
'categories' => ['my-plugin'], // ...plus whole categories...
'meta' => ['mcp' => ['public' => true]], // ...plus a meta match...
'public' => true, // ...plus every Ability public for MCP.
'gateway' => false, // List discover/describe/call instead of every Tool.
'confirm_destructive' => false, // Ask the user before destructive calls.
'cache_ttl_ms' => 300000, // How long clients may cache lists and resource reads.
'cache_scope' => 'private', // Who may share that cache: private or public.
]);
});
The Server is then at /wp-json/lean-mcp/my-plugin. Its Tools take the Abilities' names with the slash replaced by a hyphen, so my-plugin/get-thing becomes my-plugin-get-thing. Each Ability keeps its own permission_callback. The Connection Capability only decides who may talk to the Server at all.
An Ability is public for MCP when meta.mcp.public is true, or when meta.public is true and meta.mcp.public is not set.
From an Ability, Lean MCP reads:
label→ titledescriptioninput_schema/output_schema. A schema that is not an object gets wrapped ininput/result.meta.annotations→ MCP hints:readonly,destructive,idempotent, or their MCP names (readOnlyHint…), plusopenWorldHintandtitlemeta.mcp.iconsmeta.mcp._metameta.mcp.type:tool(the default),promptorresource
These are the keys mcp-adapter reads, so Abilities written for it carry over.
Tool results
An Ability's result becomes structured content, mirrored as JSON text. A few shapes become something else:
| The Ability returns | The client gets |
|---|---|
a WP_Error |
an error result with its message; input errors name the parameter |
['success' => false, 'error' => '…'] |
an error result with that message |
['type' => 'image', 'data' => <Base64>, 'mimeType' => …], or results with the raw bytes |
an image block (PNG unless it says otherwise) |
['type' => 'resource', 'uri' => …, 'text' or 'blob' => …, 'mimeType' => …], flat or inside resource |
an embedded resource |
An exception, or the WP_Error WordPress 7.1+ makes of one, reaches the model only as "The tool failed with an unexpected error." The details go to the PHP error log, and to lean_mcp_after_tool_call in the WP_Error's data (exception, or WordPress's error).
Prompts
An Ability with meta.mcp.type = 'prompt' is listed by prompts/list; clients usually offer it as a command. Its arguments are meta.mcp.arguments (a list of {name, title?, description?, required?}), or the top-level properties of its input schema. Running the Ability fills the prompt in. It may return a string, ['text' => …], ['role' => …, 'content' => …], ['texts' => [...]] or ['messages' => [...]], and a description beside them.
Resources
An Ability with meta.mcp.type = 'resource' and a meta.mcp.uri with a scheme (site://settings) is listed by resources/list and read with resources/read, which runs it with no input. meta.mcp may add mimeType, size and annotations (audience, priority, lastModified). The Ability returns a string (the text), a list of {uri?, text|blob, mimeType?}, or anything else, which is sent as JSON.
Gateway mode
With 'gateway' => true the Server lists three Tools instead of one per Ability: discover-tools (names, titles, descriptions and hints), describe-tool (one Tool's full definition) and call-tool ({name, arguments}). The client loads three short definitions instead of every input schema. Every hook below still runs for the Tool that call-tool reaches.
Confirming destructive calls
With 'confirm_destructive' => true, a call to an Ability annotated destructive asks the user first. The Server answers with an input_required result carrying an elicitation/create form. The call runs only after the user accepts and the client calls again with that answer. The request state ties the answer to the same user, Tool and arguments for 15 minutes. Only 2026-07-28 clients that declare form elicitation can be asked; others run the call as before, behind their own permission prompts. lean_mcp_confirm_tool_call decides per call.
Connecting a client
Create an application password for a user who has the Connection Capability, then:
{
"mcpServers": {
"my-site": {
"type": "http",
"url": "https://example.com/wp-json/lean-mcp/my-plugin",
"headers": { "Authorization": "Basic <base64 of user:application-password>" }
}
}
}
wp lean-mcp list --user=<login> shows every Server with its URL, its capability, how many Tools, Prompts and Resources it has, and whether it runs in Gateway mode.
Over stdio
A local client can start the Server as a subprocess instead, as a WP-CLI user who holds the Connection Capability; no application password is needed:
{
"mcpServers": {
"my-site": {
"command": "wp",
"args": ["--path=/path/to/wordpress", "lean-mcp", "serve", "my-plugin", "--user=admin"]
}
}
}
Hooks
| Hook | Type | Use |
|---|---|---|
lean_mcp_init |
action | Declare Servers. |
lean_mcp_server_abilities |
filter | The Abilities a Server exposes, by name, in order: hide or reorder them, per user if needed. Receives ($abilities, $server). |
lean_mcp_tool_call_input |
filter | Change a call's input; WordPress still validates it. Return a WP_Error to stop the call. Receives ($input, $ability, $server). |
lean_mcp_before_tool_call |
filter | Return a WP_Error to stop a call. Receives (null, $ability, $input, $server). |
lean_mcp_confirm_tool_call |
filter | Whether a call needs the user's confirmation. Receives ($confirm, $ability, $input, $server). |
lean_mcp_tool_call_result |
filter | Change what a call returns, for instance to redact it. Runs for failed calls too. Receives ($result, $ability, $input, $server). |
lean_mcp_after_tool_call |
action | Runs after every call, failed or stopped ones included, with the filtered result. Receives ($result, $ability, $input, $server, $user), which is what an audit log needs. |
lean_mcp_allowed_origins |
filter | Browser origins allowed besides the site's own. Requests that send no Origin header, such as CLI clients, are always allowed. |
lean_mcp_default_server_enabled |
filter | Return true to turn on the Default Server, which exposes every Ability public for MCP. |
lean_mcp_default_server_args |
filter | The Default Server's arguments, plus its id. Defaults: default, with manage_options and public. |
Development
composer install
composer test # Pest: the Unit suite; WordPress tests are skipped
composer analyse # PHPStan, level 8
composer format # Pint, Laravel preset
composer lint # Pint, check only
composer check # lint, analyse and test, in one go
composer dist # the production copy, in dist/lean-mcp/
The WordPress suite runs against a disposable WordPress in wp-env (Docker):
pnpm install
pnpm env:start # once; pnpm env:stop when done
pnpm test:wordpress
Design decisions live in docs/adr/. The vocabulary is defined in GLOSSARY.md.