EmDash includes a built-in Model Context Protocol (MCP) server at /_emdash/api/mcp that exposes content management operations as tools for AI assistants.
This page covers the protocol details: authentication, transport, tool specifications, OAuth discovery, and error handling.
Authentication
The MCP server supports three authentication methods:
| Method | How it works |
|---|---|
| OAuth 2.1 Authorization Code + PKCE | Standard flow for MCP clients. User approves scopes in the browser. |
| Personal Access Token (PAT) | Long-lived ec_pat_* tokens created in the admin panel. |
| Device Flow | CLI-style flow where you approve a code in the browser. Used by emdash login. |
Session cookies (from the admin UI) also work but aren’t practical for external MCP clients.
Scopes
Tokens are scoped to limit what operations a client can perform. Scopes are requested during OAuth authorization and enforced on every tool call.
| Scope | Grants access to |
|---|---|
content:read | List, get, compare, and search content. List taxonomy terms and menus. |
content:write | Create, update, delete, publish, unpublish, schedule, duplicate, and restore content. Create taxonomy terms. |
media:read | List and get media items. |
media:write | Update and delete media metadata. |
schema:read | List collections and get collection schemas. |
schema:write | Create and delete collections and fields. |
admin | Full access to all operations. |
The admin scope grants access to everything. Session-based auth (no token) also has full access based on the user’s role.
Role Requirements
In addition to scopes, some tools require a minimum RBAC role:
| Operation | Minimum role |
|---|---|
| Content operations | No minimum (scopes control access) |
| Schema read | Editor (40) |
| Schema write | Admin (50) |
See the Authentication guide for role definitions.
Transport
The server uses the Streamable HTTP transport in stateless mode. Each request is independent — there are no sessions or long-lived connections.
POST /_emdash/api/mcp— Send JSON-RPC tool callsGET /_emdash/api/mcp— Returns 405 (no SSE in stateless mode)DELETE /_emdash/api/mcp— Returns 405 (no session to close)
Responses follow the JSON-RPC 2.0 format. Errors use standard JSON-RPC error codes, with MCP-specific codes for scope and permission failures.
Tools
The server exposes 33 tools across seven domains. Each tool returns results as JSON text content, or an error message with isError: true on failure.
Content Tools
content_list
List content items in a collection with optional filtering and pagination.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug (e.g. posts, pages) |
status | string | No | Filter: draft, published, or scheduled |
limit | integer | No | Max items to return (1-100, default 50) |
cursor | string | No | Pagination cursor from a previous response |
orderBy | string | No | Field to sort by (e.g. created_at, updated_at) |
order | string | No | Sort direction: asc or desc (default desc) |
locale | string | No | Filter by locale (e.g. en, fr). Only relevant with i18n. |
Scope: content:read | Read-only: Yes
content_get
Get a single content item by ID or slug. Returns all field values, metadata, and a _rev token for optimistic concurrency.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID (ULID) or slug |
locale | string | No | Locale for slug lookup. IDs are globally unique. |
Scope: content:read | Read-only: Yes
content_create
Create a new content item. The data object should contain field values matching the collection’s schema — use schema_get_collection to check what fields are available. Items are created as draft by default.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
data | object | Yes | Field values as key-value pairs |
slug | string | No | URL slug (auto-generated from title if omitted) |
status | string | No | Initial status: draft or published (default draft) |
locale | string | No | Locale for this content (defaults to site default) |
translationOf | string | No | ID of the item this is a translation of |
Scope: content:write
content_update
Update an existing content item. Only include fields you want to change — unspecified fields are left unchanged.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
data | object | No | Field values to update |
slug | string | No | New URL slug |
status | string | No | New status: draft or published |
_rev | string | No | Revision token from content_get for conflict detection |
Scope: content:write
content_delete
Soft-delete a content item by moving it to the trash. Use content_restore to undo, or content_permanent_delete to remove it forever.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:write | Destructive: Yes
content_restore
Restore a soft-deleted content item from the trash.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:write
content_permanent_delete
Permanently and irreversibly delete a trashed content item. The item must be in the trash first.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:write | Destructive: Yes
content_publish
Publish a content item, making it live on the site. Creates a published revision from the current draft. Further edits create a new draft without affecting the live version until re-published.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:write
content_unpublish
Revert a published item to draft status. It will no longer be visible on the live site but its content is preserved.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:write
content_schedule
Schedule a content item for future publication. It will be automatically published at the specified date/time.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
scheduledAt | string | Yes | ISO 8601 datetime (e.g. 2026-06-01T09:00:00Z) |
Scope: content:write
content_compare
Compare the published (live) version of a content item with its current draft. Returns both versions and a flag indicating whether there are changes.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:read | Read-only: Yes
content_discard_draft
Discard the current draft and revert to the last published version. Only works on items that have been published at least once.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:write | Destructive: Yes
content_list_trashed
List soft-deleted content items in a collection’s trash.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
limit | integer | No | Max items (1-100, default 50) |
cursor | string | No | Pagination cursor |
Scope: content:read | Read-only: Yes
content_duplicate
Create a copy of an existing content item. The duplicate is created as a draft with “(Copy)” appended to the title and an auto-generated slug.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug to duplicate |
Scope: content:write
content_translations
Get all locale variants of a content item. Returns the translation group and a summary of each locale version. Only relevant when i18n is enabled.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
Scope: content:read | Read-only: Yes
Schema Tools
schema_list_collections
List all content collections defined in the CMS. Returns slug, label, supported features, and timestamps.
No parameters.
Scope: schema:read | Minimum role: Editor | Read-only: Yes
schema_get_collection
Get detailed info about a collection including all field definitions. Fields describe the data model: name, type, constraints, and validation rules. Use this to understand what content_create and content_update expect.
| Parameter | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Collection slug (e.g. posts) |
Scope: schema:read | Minimum role: Editor | Read-only: Yes
schema_create_collection
Create a new content collection. This creates a database table and schema definition. The slug must be lowercase alphanumeric with underscores, starting with a letter.
| Parameter | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Unique identifier (/^[a-z][a-z0-9_]*$/) |
label | string | Yes | Display name (plural, e.g. “Blog Posts”) |
labelSingular | string | No | Singular display name |
description | string | No | Description of this collection |
icon | string | No | Icon name for the admin UI |
supports | string[] | No | Features: drafts, revisions, preview, scheduling, search (default: ['drafts', 'revisions']) |
Scope: schema:write | Minimum role: Admin
schema_delete_collection
Delete a collection and its database table. This is irreversible and deletes all content in the collection.
| Parameter | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Collection slug to delete |
force | boolean | No | Force deletion even if the collection has content |
Scope: schema:write | Minimum role: Admin | Destructive: Yes
schema_create_field
Add a new field to a collection’s schema. This adds a column to the database table.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
slug | string | Yes | Field identifier (/^[a-z][a-z0-9_]*$/) |
label | string | Yes | Display name |
type | string | Yes | Data type (see below) |
required | boolean | No | Whether the field is required |
unique | boolean | No | Whether values must be unique |
defaultValue | any | No | Default value for new items |
validation | object | No | Constraints: min, max, minLength, maxLength, pattern, options |
options | object | No | Widget config: collection (for references), rows (for textarea) |
searchable | boolean | No | Include in full-text search index |
translatable | boolean | No | Whether this field is translatable (default true) |
Field types: string, text, number, integer, boolean, datetime, select, multiSelect, portableText, image, file, reference, json, slug.
For select and multiSelect types, provide allowed values in validation.options.
Scope: schema:write | Minimum role: Admin
schema_delete_field
Remove a field from a collection. This drops the column and deletes all data in that field. Irreversible.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
fieldSlug | string | Yes | Field slug to remove |
Scope: schema:write | Minimum role: Admin | Destructive: Yes
Media Tools
media_list
List uploaded media files with optional MIME type filtering and pagination.
| Parameter | Type | Required | Description |
|---|---|---|---|
mimeType | string | No | Filter by MIME type prefix (e.g. image/, application/pdf) |
limit | integer | No | Max items (1-100, default 50) |
cursor | string | No | Pagination cursor |
Scope: media:read | Read-only: Yes
media_get
Get details of a single media file by ID. Returns metadata including filename, MIME type, size, dimensions, alt text, and URL.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Media item ID |
Scope: media:read | Read-only: Yes
media_update
Update metadata of an uploaded media file. The file itself cannot be changed.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Media item ID |
alt | string | No | Alt text for accessibility |
caption | string | No | Caption text |
width | integer | No | Image width in pixels |
height | integer | No | Image height in pixels |
Scope: media:write
media_delete
Permanently delete a media file. Removes the database record and the file from storage. Content referencing this media will have broken references.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Media item ID |
Scope: media:write | Destructive: Yes
Search Tool
search
Full-text search across content collections. Collections must have search in their supports list and fields must be marked as searchable.
| Parameter | Type | Required | Description |
|---|---|---|---|
query | string | Yes | Search query text |
collections | string[] | No | Limit search to specific collection slugs |
locale | string | No | Filter results by locale |
limit | integer | No | Max results (1-50, default 20) |
Scope: content:read | Read-only: Yes
Taxonomy Tools
taxonomy_list
List all taxonomy definitions (e.g. categories, tags). Returns name, label, whether hierarchical, and associated collections.
No parameters.
Scope: content:read | Read-only: Yes
taxonomy_list_terms
List terms in a taxonomy with pagination.
| Parameter | Type | Required | Description |
|---|---|---|---|
taxonomy | string | Yes | Taxonomy name (e.g. categories, tags) |
limit | integer | No | Max items (1-100, default 50) |
cursor | string | No | Pagination cursor |
Scope: content:read | Read-only: Yes
taxonomy_create_term
Create a new term in a taxonomy. For hierarchical taxonomies, specify a parentId to create a child term.
| Parameter | Type | Required | Description |
|---|---|---|---|
taxonomy | string | Yes | Taxonomy name |
slug | string | Yes | URL-safe identifier |
label | string | Yes | Display name |
parentId | string | No | Parent term ID (for hierarchical taxonomies) |
description | string | No | Description of the term |
Scope: content:write
Menu Tools
menu_list
List all navigation menus. Returns name, label, and timestamps.
No parameters.
Scope: content:read | Read-only: Yes
menu_get
Get a menu by name including all its items in order. Items have a label, URL, type, and optional parent for nesting.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Menu name (e.g. main, footer) |
Scope: content:read | Read-only: Yes
Revision Tools
revision_list
List revision history for a content item, newest first. Requires the collection to support revisions.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
limit | integer | No | Max revisions (1-50, default 20) |
Scope: content:read | Read-only: Yes
revision_restore
Restore a content item to a previous revision. Replaces the current draft with the specified revision’s data. Not automatically published — use content_publish afterward if needed.
| Parameter | Type | Required | Description |
|---|---|---|---|
revisionId | string | Yes | Revision ID to restore |
Scope: content:write
OAuth Discovery
MCP clients that support OAuth 2.1 can automatically discover how to authenticate. The server publishes two metadata documents:
Protected Resource Metadata
GET /.well-known/oauth-protected-resource
{
"resource": "https://example.com/_emdash/api/mcp",
"authorization_servers": ["https://example.com/_emdash"],
"scopes_supported": [
"content:read", "content:write",
"media:read", "media:write",
"schema:read", "schema:write",
"admin"
],
"bearer_methods_supported": ["header"]
}
Authorization Server Metadata
GET /.well-known/oauth-authorization-server/_emdash
{
"issuer": "https://example.com/_emdash",
"authorization_endpoint": "https://example.com/_emdash/oauth/authorize",
"token_endpoint": "https://example.com/_emdash/api/oauth/token",
"scopes_supported": ["content:read", "content:write", "..."],
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code"
],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"device_authorization_endpoint": "https://example.com/_emdash/api/oauth/device/code"
}
When an unauthenticated request hits the MCP endpoint, the server returns:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"
This triggers the standard MCP client discovery flow.
Error Handling
Tool errors are returned as text content with isError: true:
{
"content": [{ "type": "text", "text": "Collection 'nonexistent' not found" }],
"isError": true
}
Scope and permission errors throw MCP protocol errors:
{
"jsonrpc": "2.0",
"error": {
"code": -32600,
"message": "Insufficient scope: requires content:write"
},
"id": 1
}
Transport-level errors (server misconfiguration, unhandled exceptions) return JSON-RPC error code -32603 (Internal error) without leaking implementation details.