HTTP reference
MarkdownMCP server
From externa-core 1.4.0 (#121), Externa ships an optional first-party Model Context Protocol 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. In-app agent: AI assistant model. Key scopes: API abuse controls & scopes. Env keys: AI configuration.
Not the docs MCP
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 (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. |
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.
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). 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.
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.
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) after the call has been approved (#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, 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, andread_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).
Client setup
Any MCP client that supports streamable HTTP with a custom header works. Examples (replace the host and key):
# Claude Code
claude mcp add --header "Authorization: Bearer ek_YOUR_SECRET" \
--transport http externa https://cms.example.com/mcp
{
"mcpServers": {
"externa": {
"url": "https://cms.example.com/mcp",
"headers": { "Authorization": "Bearer ek_YOUR_SECRET" }
}
}
}
Least privilege
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 |