EmDash exposes a built-in Model Context Protocol (MCP) server at /_emdash/api/mcp. MCP clients use it to read and manage content, bylines, schemas, media, taxonomies, menus, revisions, and settings, and to export or import the whole site.
Authentication
The MCP endpoint requires a Bearer token. EmDash supports these token flows:
| Method | Use |
|---|---|
| OAuth 2.1 Authorization Code with Proof Key for Code Exchange (PKCE) | Interactive MCP clients. The user approves the requested scopes in a browser. |
| Personal access token | Long-lived access for a client or automation. Tokens use the ec_pat_ prefix and are created in the admin. |
| OAuth 2.0 Device Authorization Grant | Command-line clients that ask the user to approve a code in a browser. emdash login uses this flow. |
Session cookies do not authenticate the MCP endpoint.
Scopes
OAuth and personal access tokens limit which tools a client can call. The user’s role is checked separately, so a scope never grants a permission the user does not have.
| Scope | Access |
|---|---|
content:read | Read and search content, bylines, taxonomies, terms, menus, and revisions. Draft-like content also requires the user’s content:read_drafts permission. |
content:write | Create and change content, bylines, and revisions. It also grants taxonomies:manage and menus:manage for compatibility with existing tokens. |
media:read | Read media records. |
media:write | Upload, register, update, and delete media. |
schema:read | Read collections and fields. |
schema:write | Create, update, and delete collections and fields. |
taxonomies:manage | Create, update, and delete taxonomy definitions and terms. |
menus:manage | Create, update, and delete menus and menu items. |
settings:read | Read site settings. |
settings:manage | Update site settings. |
mcp:tools | Call MCP tools exposed by any enabled plugin. |
mcp:tools:<pluginId> | Call MCP tools exposed by one enabled plugin. |
transfer:export | Export the whole site as a site package and download it. |
transfer:analyze | Upload a site package and analyze it for import. |
transfer:execute | Start, advance, cancel, and abandon a site import. |
admin | Call every core tool, including the site transfer tools. Plugin tools still require mcp:tools or the plugin-specific scope. |
The admin scope includes transfer:export, transfer:analyze, and transfer:execute. Each transfer scope grants only its own actions and requires the administrator role. To let a client, such as an agent, analyze a site package without exporting or importing, grant transfer:analyze instead of admin.
The authorization-code consent page lets the user remove requested scopes. EmDash also intersects the request with the client’s registered scopes and the user’s role, and refuses an empty grant.
Role requirements
The following table shows the minimum role for the broad capability. Ownership checks can require a higher role when a user acts on another user’s content.
| Capability | Minimum role |
|---|---|
| Read published content, media, taxonomies, terms, and menus | Subscriber |
| Read drafts, scheduled content, trash, comparisons, and revisions | Contributor |
| Create content or upload media | Contributor |
| Edit or publish owned content and register media | Author |
| Manage bylines, taxonomies, menus, or all users’ content | Editor |
| Read schemas or settings | Editor |
| Change schemas or settings, permanently delete content, or repair media usage | Admin |
| Export or import the whole site | Admin |
See user roles for the complete role definitions.
Transport
The server uses stateless Streamable HTTP. Each request is independent; the server does not keep an MCP session or a Server-Sent Events connection.
| Method | Endpoint | Behavior |
|---|---|---|
POST | /_emdash/api/mcp | Accepts JSON-RPC initialization, tool listing, and tool calls. |
GET | /_emdash/api/mcp | Returns 405 Method Not Allowed. |
DELETE | /_emdash/api/mcp | Returns 405 Method Not Allowed. |
Responses use JSON-RPC 2.0. Call tools/list to obtain the current input schemas and MCP annotations before constructing a tool request.
Tool inventory
The following inventory matches the static tools returned by tools/list. The registered title is included because clients may display it instead of the tool name.
Content tools
| Tool | Registered title | Required scope |
|---|---|---|
content_list | List Content | content:read |
content_get | Get Content | content:read |
content_create | Create Content | content:write |
content_update | Update Content | content:write |
content_delete | Delete Content (Trash) | content:write |
content_restore | Restore Content | content:write |
content_permanent_delete | Permanently Delete Content | content:write |
content_publish | Publish Content | content:write |
content_unpublish | Unpublish Content | content:write |
content_schedule | Schedule Content | content:write |
content_unschedule | Cancel Scheduled Publication | content:write |
content_compare | Compare Live vs Draft | content:read |
content_discard_draft | Discard Draft | content:write |
content_list_trashed | List Trashed Content | content:read |
content_duplicate | Duplicate Content | content:write |
content_translations | Get Content Translations | content:read |
Byline tools
| Tool | Registered title | Required scope |
|---|---|---|
byline_list | List Bylines | content:read |
byline_get | Get Byline | content:read |
byline_create | Create Byline | content:write |
byline_update | Update Byline | content:write |
byline_delete | Delete Byline | content:write |
byline_translations | List Byline Translations | content:read |
Schema tools
| Tool | Registered title | Required scope |
|---|---|---|
schema_list_collections | List Collections | schema:read |
schema_get_collection | Get Collection Schema | schema:read |
schema_list_block_types | List Block Types | schema:read |
schema_get_block_type | Get Block Type | schema:read |
schema_create_block_type | Create Block Type | schema:write |
schema_update_block_type | Update Block Type | schema:write |
schema_activate_block_type_version | Activate Block Type Version | schema:write |
schema_create_collection | Create Collection | schema:write |
schema_delete_collection | Delete Collection | schema:write |
schema_update_collection | Update Collection | schema:write |
schema_create_field | Add Field to Collection | schema:write |
schema_delete_field | Remove Field from Collection | schema:write |
schema_update_field | Update Field | schema:write |
Media tools
| Tool | Registered title | Required scope |
|---|---|---|
media_list | List Media | media:read |
media_create | Confirm Signed Media Upload | media:write |
media_upload | Upload Media | media:write |
media_get | Get Media Item | media:read |
media_update | Update Media Metadata | media:write |
media_delete | Delete Media | media:write |
media_usage_repair | Repair Media Usage Index | admin |
Search tool
| Tool | Registered title | Required scope |
|---|---|---|
search | Search Content | content:read |
Taxonomy tools
| Tool | Registered title | Required scope |
|---|---|---|
taxonomy_list | List Taxonomies | content:read |
taxonomy_get | Get Taxonomy Definition | content:read |
taxonomy_create | Create Taxonomy Definition | taxonomies:manage |
taxonomy_update | Update Taxonomy Definition | taxonomies:manage |
taxonomy_delete | Delete Taxonomy Definition | taxonomies:manage |
taxonomy_list_terms | List Taxonomy Terms | content:read |
taxonomy_create_term | Create Taxonomy Term | taxonomies:manage |
taxonomy_update_term | Update Taxonomy Term | taxonomies:manage |
taxonomy_delete_term | Delete Taxonomy Term | taxonomies:manage |
taxonomy_term_translations | List Term Translations | content:read |
Menu tools
| Tool | Registered title | Required scope |
|---|---|---|
menu_list | List Menus | content:read |
menu_get | Get Menu with Items | content:read |
menu_translations | List Menu Translations | content:read |
menu_create | Create Menu | menus:manage |
menu_update | Update Menu | menus:manage |
menu_delete | Delete Menu | menus:manage |
menu_set_items | Set Menu Items | menus:manage |
Revision tools
| Tool | Registered title | Required scope |
|---|---|---|
revision_list | List Revisions | content:read |
revision_restore | Restore Revision | content:write |
Settings tools
| Tool | Registered title | Required scope |
|---|---|---|
settings_get | Get Site Settings | settings:read |
settings_update | Update Site Settings | settings:manage |
Site transfer tools
transfer:* means any one of transfer:export, transfer:analyze, or transfer:execute. The admin scope satisfies every requirement in this table.
| Tool | Registered title | Required scope |
|---|---|---|
site_transfer_capabilities | Get Site Transfer Capabilities | transfer:* |
site_export_start | Start Site Export | transfer:export |
site_export_status | Get Site Export Status | transfer:export |
site_import_analyze | Analyze Site Import | transfer:analyze |
site_import_start | Start Site Import | transfer:execute |
site_import_status | Get Site Import Status | transfer:* |
site_import_resume | Resume Site Import | transfer:execute |
site_import_receipt | Get Site Import Receipt | transfer:* |
site_export_start and site_import_start also accept a token without the scope when an admin approves the request. For the operation an approved request starts, site_export_status, site_import_status, site_import_resume, and site_import_receipt then accept the same token without the scope.
Use the tool schemas
tools/list returns each tool’s description, JSON input schema, and annotations. Read that metadata before constructing a call so your client uses the fields, allowed values, and limits supported by the installed EmDash version.
For example, a client updating an article first calls content_get and keeps the returned _rev. It can then send this JSON-RPC request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "content_update",
"arguments": {
"collection": "articles",
"id": "01JARTICLE0000000000000000",
"data": { "title": "Updated title" },
"_rev": "opaque-revision-token"
}
}
}
The result is returned as JSON text in the first content block. A tool with an output schema may also return the same value in structuredContent.
Content lifecycle and bylines
The content lifecycle reference defines the state, revision, permission, conflict, and hook behavior shared by MCP, REST, the CLI, and the admin panel.
content_get returns an opaque _rev value. Pass it to content_update, content_publish, content_unpublish, content_schedule, or content_discard_draft. A stale value returns a conflict, so read the item again before retrying.
content_update is a partial update: omitted fields keep their current values. Updating a published item stages a draft while the live version remains unchanged. Use content_compare to review the live and draft values, then call content_publish to make the draft live or content_discard_draft to remove it. content_delete moves an item to trash; only content_permanent_delete removes a trashed item permanently.
Bylines are reusable author or contributor credits. byline_create can create a guest credit or link a byline to a CMS user. Pass the returned byline ID in the bylines input accepted by content_create and content_update. Deleting a byline removes that credit from content and clears it as the primary byline.
While another user holds an entry’s edit lock because they have it open in the admin, content_update, content_delete, content_publish, content_unpublish, content_schedule, content_unschedule, content_discard_draft and revision_restore fail with ENTRY_LOCKED, and the error names the holder. Reading the item again does not clear the refusal. Pass overrideLock: true to write anyway.
Translations
Content, byline, taxonomy term, and menu translation tools return every locale variant in the relevant translation group. Use the creation tool’s translationOf input when its schema provides one; tools/list is authoritative for the required fields.
content_translations accepts a collection and content ID or slug. The byline, taxonomy-term, and menu translation tools accept either one record’s ID or the shared translation-group ID. A user without draft access sees only published content translations.
Schemas, media, taxonomies, and menus
Schema tools change the database structure. Use schema_get_collection before creating content or changing fields; it returns the available field names, types, constraints, and validation rules. Collection and field deletion remove stored content or field values and cannot be undone.
Use media_upload to send base64-encoded bytes. Uploads are subject to the configured size and MIME-type limits, and identical bytes may return an existing media item with deduplicated: true.
media_create confirms a pending upload created through POST /_emdash/api/media/upload-url. Upload the file with the returned signed URL, then call media_create from the same user account with the returned storageKey. The tool checks that the stored file exists and matches the size supplied when the upload URL was requested before making it available in the media library.
Taxonomy definitions describe the classification and the collections it applies to; terms are the individual values assigned to content. Hierarchical terms can use parentId, but a parent must belong to the same taxonomy and cannot create a cycle. Creating or updating a term with parentId in a non-hierarchical taxonomy returns VALIDATION_ERROR. A term with children must have those children removed or moved before deletion.
menu_set_items replaces a menu’s complete item list in one atomic operation. The array order becomes the menu order. A nested item’s parentIndex points to an earlier item in the same array, so place every parent before its children.
media_usage_repair can process one collection or every collection and may run for a long time on a large site. Its complete, partial, failed, and stale statuses are successful tool responses. Inspect the returned status and counts instead of relying on isError; authentication, validation, and unexpected execution failures set isError: true.
Site transfer
The site_* tools export a whole site as a site package and import a package into an empty site. They start and drive operations and return bounded summaries. They never carry package bytes, media, record contents, principal email addresses, or download URLs. Download an export, and upload a package for import, with the CLI or the REST API, then refer to the operation by its ID.
Every transfer tool requires the Admin role. The role is checked before the scope, so a non-admin caller receives INSUFFICIENT_PERMISSIONS and no approval request is created.
Export
site_export_start accepts comments (default true) and returns the new operation. site_export_status runs one bounded export step on each call and reports the operation and nextRequestInMs. Call it again after that delay until nextRequestInMs is null. Pass advance: false to read the status without running a step. Once the export is complete, the result also includes totals: record counts by kind, media count and bytes, and package file count and bytes.
Import
Upload the package first. The CLI’s emdash site import <file> --analyze uploads and analyzes it and prints the operation ID.
site_import_analyze runs one bounded analysis step per call. Repeat it until nextRequestInMs is null; the result then includes a plan summary with packageDigest, planDigest, executable, counts, sizes, principals, decisions, transformations, warnings, and blockers. Each transformation is listed as its code, the record kind when it has one, and a count, without the IDs or values it applies to. Principals, warnings, and blockers list at most 50 items each, with the full count in total. Principals are listed without email addresses, with the suggested and currently mapped target user IDs. Pass decisions to map principals to target user IDs (or null) and to choose the package or target title and tagline. Each change produces a new planDigest.
site_import_start takes the operation ID and the packageDigest and planDigest of the latest plan. The plan must have no blockers. The tool has destructiveHint: true: once started, the import writes to the site and blocks other writes until it completes or an administrator abandons it. Show the user the plan and get their confirmation before calling it.
site_import_resume runs one bounded import step and reports the operation and nextRequestInMs. Call it until nextRequestInMs is null; it is safe to repeat after a disconnect. site_import_status reports the operation and uploaded file counts without advancing the import. site_import_receipt returns the complete receipt, including receiptDigest, once the import is complete.
While an import is executing, and after it fails or is cancelled until it is abandoned, every other tool that can write fails with TRANSFER_IMPORT_IN_PROGRESS. This includes plugin tools. Tools annotated readOnlyHint: true and the eight site_* tools keep working, and initialize and tools/list are never blocked. During media usage activation, write tools fail with MEDIA_USAGE_ACTIVATION_IN_PROGRESS in the same way.
Operation summaries include id, kind, state, stage, progress, packageDigest, planDigest, error ({ code } or null), and timestamps. progress is { done, total } steps, plus records, the records an export has written so far, and bytesDone and bytesTotal, once they are known. The MCP tools cannot cancel or abandon an import; use the REST API.
Approvals
A token with admin or the needed transfer scope never asks for approval. For a token without either, such as an agent granted only transfer:analyze, site_export_start and site_import_start run when an administrator approves the request:
- The first call without the scope creates a pending approval request and fails with
TRANSFER_APPROVAL_REQUIRED. The message text and_meta.detailscarry theapprovalIdand itsexpiresAt. Calling again with the same arguments and noapprovalIdreturns the same open request. - An administrator approves the request under Approval requests in Settings → Transfer, or with the session-only approval endpoint of the REST API. API tokens cannot approve requests.
- The client repeats the call with the same arguments and the
approvalId. The approval is used up when that call starts the operation. If the operation fails to start, the client can retry with the sameapprovalIduntil it expires.
A request is bound to the user, the token, the action, and the exact arguments: the export options, or the import operation ID and both digests. A pending request expires 15 minutes after it is made, and an approved one 15 minutes after approval. A call with different arguments or another token, or with a denied, expired, or used approval, fails with TRANSFER_APPROVAL_INVALID.
site_import_start checks the digests, the operation state, and the plan’s blockers before it creates a request, so an administrator is only asked to approve an import that can run. An approval needs a token ID, so a caller without one receives INSUFFICIENT_SCOPE.
After an approved call starts an operation, the same user and token can call site_export_status, or site_import_status, site_import_resume, and site_import_receipt, for that operation without the scope.
Plugin tools
An administrator must enable each plugin’s MCP surface. Enabled tools appear in tools/list as <pluginId>__<localName> and require mcp:tools or mcp:tools:<pluginId> for token-authenticated calls. EmDash also checks the permission declared by the plugin route and records the plugin, tool, route, and actor in the audit log.
Because plugin tools are installation-specific, they are not part of the static inventory above.
OAuth discovery
MCP clients discover the authorization server from the protected-resource metadata:
GET /.well-known/oauth-protected-resource
The response identifies /_emdash/api/mcp as the protected resource and links to the authorization server. Clients then read its metadata at:
GET /.well-known/oauth-authorization-server/_emdash
That document supplies the current authorization, token, registration, and device-authorization endpoints, supported scopes, grant types, and the S256 PKCE method. Use the discovered values instead of hard-coding the OAuth protocol routes.
An unauthenticated MCP request returns a 401 response with the discovery URL:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"
Errors
A tool failure has isError: true. The first text block starts with a stable code, and _meta.code repeats it for clients that read structured metadata:
{
"content": [{ "type": "text", "text": "[NOT_FOUND] Collection 'articles' not found" }],
"isError": true,
"_meta": { "code": "NOT_FOUND" }
}
A refusal from an entry’s edit lock carries the holder in _meta.details:
{
"content": [{ "type": "text", "text": "[ENTRY_LOCKED] Ada is holding this entry" }],
"isError": true,
"_meta": {
"code": "ENTRY_LOCKED",
"details": {
"userId": "01JB...",
"userName": "Ada",
"acquiredAt": "2026-05-01T09:12:04.117Z",
"expiresAt": "2026-05-01T09:19:04.117Z"
}
}
}
Authentication failures use codes such as INSUFFICIENT_SCOPE and INSUFFICIENT_PERMISSIONS. Transport failures use the JSON-RPC internal-error code -32603 and do not expose the underlying exception.