# MCP server

Source: https://docs.qiick.io/externa/mcp-server

From externa-core **1.4.0** ([#121](https://github.com/qiick-io/externa-core/issues/121)), Externa ships an optional first-party [Model Context Protocol](https://modelcontextprotocol.io) server at `/mcp` (streamable HTTP, `laravel/mcp`). AI clients such as Claude Code, Cursor, or VS Code can read collections, items, and files, and propose writes, with the same permissions as the in-app assistant. It is **off by default**.

> **Related**
> Shared tool gates and the approval inbox: [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals). In-app agent: [AI assistant model](https://docs.qiick.io/externa/ai-assistant). Key scopes: [API abuse controls & scopes](https://docs.qiick.io/externa/api-abuse-controls#api-key-scopes). Env keys: [AI configuration](https://docs.qiick.io/externa/ai-configuration#mcp-server).

> This page is about the MCP server **inside your Externa install**. To give coding agents up-to-date Externa documentation, see [Using with AI coding tools](https://docs.qiick.io/externa/ai-coding-tools) (Context7, `llms.txt`).

## Enable

| Env                            | Default | Effect                                                                                                                         |
| ------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `AI_MCP_ENABLED`               | `false` | Turns the endpoint on. When `false`, every request to `/mcp` returns **404** (`EnsureMcpEnabledMiddleware`).                   |
| `AI_MCP_WRITE_ENABLED`         | `false` | When `false`, write tools are **not listed** and refuse calls, even if `AI_WRITE_TOOLS` is on.                                 |
| `AI_MCP_RATE_LIMIT_PER_MINUTE` | `60`    | Dedicated `mcp` rate limiter. Keyed per API key (the key's own `rate_limit_per_minute` wins when set), else per user, else IP. |

```env
AI_MCP_ENABLED=true
AI_MCP_WRITE_ENABLED=false
AI_MCP_RATE_LIMIT_PER_MINUTE=60
```

Run `php artisan optimize:clear` (or `externa:upgrade`) after changing these keys when config is cached. The endpoint is CSRF-exempt (`mcp`, `mcp/*` in `bootstrap/app.php`).

## Authentication

Clients authenticate with an **owned API key** sent as a Bearer token. OAuth / Passport is not wired yet.

```http
POST /mcp
Authorization: Bearer ek_YOUR_SECRET
```

`AuthenticateMcpRequestMiddleware` rejects the request when:

| Check                                                 | Response |
| ----------------------------------------------------- | -------- |
| Unknown, revoked, or expired key                      | **401**  |
| Key has **no owner** (`owner_id` null)                | **401**  |
| Owner is deactivated or deleted                       | **401**  |
| Request IP not in the key's IP allowlist              | **401**  |
| Key role is `super-admin`                             | **401**  |
| Key lacks the `mcp:read` scope                        | **403**  |
| No Bearer token and no authenticated user on the call | **401**  |

After these checks the request runs **as the key owner**, with the key's role and scopes as an extra ceiling. Each call updates the key's `last_used_at`.

### API key scopes and owners

API keys gain two fields in 1.4.0:

| Field      | Values                  | Notes                                                                                                                                                                                            |
| ---------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `scopes`   | `mcp:read`, `mcp:write` | Named capability scopes (`ApiKeyScopeEnum`). `null` = none, so **existing keys cannot call MCP** until you grant `mcp:read`.                                                                     |
| `owner_id` | user id                 | Effective access = key role ∩ key scopes (`collection_slugs`, `read_only`, `files_scope`) ∩ owner's permissions ([#215](https://github.com/qiick-io/externa-core/issues/215)). Required for MCP. |

Create or edit keys under **Settings → API Keys** (`/settings/api-keys`); the form picks the owner and the scopes. Keys are **revoked automatically** when their owner is deactivated or deleted. The upgrade backfills owners for existing keys — see [Upgrade — 1.3.x → 1.4.0](https://docs.qiick.io/externa/upgrade#1-3-x-1-4-0).

Write tools need **all** of: `AI_MCP_WRITE_ENABLED=true`, the `mcp:write` scope, and a key that is **not** `read_only`. Otherwise they are hidden from `tools/list` and refuse calls.

## Tools

Tools are thin adapters over the shared AI tool registry (`app/Mcp/Tools/*`), so they run the same permission checks as the in-app assistant and write the same audit trail (`surface=mcp`).

### Read tools

| Tool                  | Arguments                                       | Backed by                                   |
| --------------------- | ----------------------------------------------- | ------------------------------------------- |
| `list_collections`    | `trashed` (optional)                            | `ManageCollections` `list`                  |
| `describe_collection` | `collection_id`                                 | `ManageCollections` `get` (fields, types)   |
| `list_items`          | `collection_id`, `filter_json`, `limit` (1–100) | `QueryCollectionItems`                      |
| `get_item`            | `item_id`                                       | `ManageCollectionItems` `get`               |
| `search`              | `collection_id`, `query`, `limit`               | `SearchSimilarCollectionItems`              |
| `list_files`          | `parent_id`, `query`, `limit` (1–100)           | `ManageFiles` `list` (metadata only)        |
| `get_file`            | `file_id`                                       | File metadata, no binary (`can-show-files`) |

### Write tools

Listed only when the write gate above allows it. There are **no delete tools**.

| Tool           | Arguments                      | Backed by                           |
| -------------- | ------------------------------ | ----------------------------------- |
| `create_item`  | `collection_id`, `data_json`   | `ManageCollectionItems` `create`    |
| `update_item`  | `item_id`, `data_json` (merge) | `ManageCollectionItems` `update`    |
| `publish_item` | `item_id`                      | `ManageCollectionItems` `publish`   |
| `move_file`    | `file_id`, `target_parent_id`  | `ManageFiles` `move`                |
| `tag_file`     | `file_id`, `tags_json`         | `ManageFiles` `tag` (replaces tags) |

Write tools go through `AiToolExecutorService`, which also requires the **AI write gate**: `AI_WRITE_TOOLS=true` and the tool class (`ManageCollectionItems` or `ManageFiles`) on the write allowlist. Every mutating MCP call is then **parked for human approval** (`pending_approval`) instead of running. See [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals).

`publish_item` also applies the collection `update` permission, the role's item filter, and the versioning checks of the admin publish action. Publishing through MCP only fires deploy hooks ([Project settings](https://docs.qiick.io/externa/project-settings)) after the call has been approved ([#435](https://github.com/qiick-io/externa-core/issues/435)).

## Resources

| Resource            | URI                                   | Content                                       |
| ------------------- | ------------------------------------- | --------------------------------------------- |
| `collections`       | `externa://collections`               | Collections visible to the caller             |
| `collection_schema` | `externa://collections/{slug}/schema` | Field schema of one collection                |
| `collection_item`   | `externa://items/{collection}/{id}`   | One item (same checks as `get_item`)          |
| `file_metadata`     | `externa://files/{id}`                | File metadata, no binary (same as `get_file`) |

## Authorization

From [#435](https://github.com/qiick-io/externa-core/issues/435), every MCP tool and resource enforces the same rules as the chat tools:

- collection, item, and field permissions (including the role's item filter and field-level read rules);
- file ACL and `read_private`;
- the API key's `collection_slugs`, `files_scope`, and `read_only`.

A key scoped to `collection_slugs=[blog]` cannot read other collections over MCP, and a key with `files_scope=none` cannot read files. Prompt-injection and denied-permission cases for chat and MCP are covered by the Pest suite under `tests/Feature/Ai/Security/*` ([#204](https://github.com/qiick-io/externa-core/issues/204)).

## Client setup

Any MCP client that supports streamable HTTP with a custom header works. Examples (replace the host and key):

```bash
# Claude Code
claude mcp add --header "Authorization: Bearer ek_YOUR_SECRET" \
  --transport http externa https://cms.example.com/mcp
```

```json
{
  "mcpServers": {
    "externa": {
      "url": "https://cms.example.com/mcp",
      "headers": { "Authorization": "Bearer ek_YOUR_SECRET" }
    }
  }
}
```

> Give MCP keys a dedicated non-admin role, an owner who should actually see that content, `collection_slugs` / `files_scope` set to what the agent needs, and only `mcp:read` unless you plan to approve writes. Treat the key like a password.

## Source map

| Concern      | Location (externa-core)                                                                      |
| ------------ | -------------------------------------------------------------------------------------------- |
| Route        | `routes/mcp.php` (`Mcp::web('/mcp', ExternaServer::class)`)                                  |
| Server       | `app/Mcp/Servers/ExternaServer.php`                                                          |
| Tools        | `app/Mcp/Tools/*`, `app/Mcp/Tools/Concerns/DelegatesToRegistryMcpToolTrait.php`              |
| Resources    | `app/Mcp/Resources/*`                                                                        |
| Auth         | `app/Http/Middleware/AuthenticateMcpRequestMiddleware.php`, `EnsureMcpEnabledMiddleware.php` |
| Rate limiter | `AppServiceProvider` (`RateLimiter::for('mcp', …)`)                                          |
| Config       | `config/ai.php` → `mcp.*`                                                                    |
| Tests        | `tests/Feature/Mcp/*`, `tests/Feature/Ai/Security/McpAuthorizationParityTest.php`            |
