Skip to main content
Glama
README.md
<picture>
  <source media="(prefers-color-scheme: dark)" srcset=".github/lean-mcp-dark.png">
  <img alt="lean-mcp" src=".github/lean-mcp-light.png" width="100%">
</picture>

# Lean MCP

A small MCP server for WordPress. Any plugin can declare a **Server** that exposes its [Abilities](https://developer.wordpress.org/apis/abilities-api/) 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

```php
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` → title
- `description`
- `input_schema` / `output_schema`. A schema that is not an object gets wrapped in `input` / `result`.
- `meta.annotations` → MCP hints: `readonly`, `destructive`, `idempotent`, or their MCP names (`readOnlyHint`…), plus `openWorldHint` and `title`
- `meta.mcp.icons`
- `meta.mcp._meta`
- `meta.mcp.type`: `tool` (the default), `prompt` or `resource`

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:

```json
{
  "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:

```json
{
  "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

```sh
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](https://www.npmjs.com/package/@wordpress/env) (Docker):

```sh
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`.