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. On the authorization-code consent page, all requested scopes are selected by default for compatibility; the user can remove scopes before approval but cannot add scopes the client did not request. The effective grant is also restricted by the client’s registered scopes and the user’s role, and EmDash rejects an empty grant.
| Scope | Grants access to |
|---|---|
content:read | List, get, compare, and search content. List taxonomies, taxonomy terms, and menus. |
content:write | Create, update, delete, publish, unpublish, schedule, unschedule, duplicate, and restore content. Implicitly grants taxonomies:manage and menus:manage for backwards compatibility with tokens issued before those scopes existed. |
media:read | List and get media items. |
media:write | Register (create), update, and delete media metadata. |
schema:read | List collections and get collection schemas. |
schema:write | Create and delete collections and fields. |
taxonomies:manage | Create, update, and delete taxonomy terms. |
menus:manage | Create, update, and delete navigation menus and their items. |
settings:read | Read site-wide settings. |
settings:manage | Update site-wide settings. |
mcp:tools | Invoke explicitly enabled MCP tools from any plugin. |
mcp:tools:<pluginId> | Invoke explicitly enabled MCP tools from one plugin. |
admin | Full access to all operations. |
The admin scope grants access to core operations, but it does not grant plugin MCP access. Plugin tools always require mcp:tools or the matching plugin-specific scope. Session-based auth has access based on the user’s role and the plugin’s explicit admin enablement.
content:write implicitly grants taxonomies:manage and menus:manage so personal access tokens issued before those scopes were split out continue to work without re-issue. New tokens should request the granular scopes.
Role Requirements
In addition to scopes, some tools require a minimum RBAC role. Both must be satisfied — a token with the right scope still fails if the calling user’s role is too low.
Plugin tools use the permission declared by their underlying route. They are absent from tools/list until an administrator enables that plugin’s MCP surface. Tool names use the deterministic <pluginId>__<localName> form, and invocations are recorded in the audit log with plugin, tool, route, and actor provenance.
| Operation | Minimum role |
|---|---|
| Content read | Subscriber (10) for published items; Contributor (20) for drafts, scheduled, trash, and revisions |
| Content create | Contributor (20) |
| Content edit own / delete own | Author (30) |
| Content publish | Author (30) for own items; Editor (40) to act on others’ items |
| Schema read | Editor (40) |
| Schema write | Admin (50) |
| Taxonomies manage | Editor (40) |
| Menus manage | Editor (40) |
| Settings read | Editor (40) |
| Settings manage | Admin (50) |
Media upload (media_upload) | Contributor (20) |
Media register (media_create) | Author (30) |
| Media usage repair | 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 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. 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_unschedule
Cancel a previously scheduled publication. The item keeps its current status; only the scheduledAt timestamp is cleared. Idempotent — calling on an item that isn’t scheduled is a no-op.
| Parameter | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Collection slug |
id | string | Yes | Content item ID or slug |
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_upload
Upload a media file from base64-encoded data or an external URL and register it in the media library. Returns the media item with id, storageKey, and url — ready to reference from content fields (e.g. featured_image) via content_create / content_update.
Uploads are deduplicated by content hash: re-uploading identical bytes returns the existing item with deduplicated: true. Image uploads are enriched automatically with dimensions, a blurhash placeholder, and the dominant color.
| Parameter | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | Filename including extension (e.g. cover.png) |
base64 | string | One of base64 / url | Base64-encoded file contents |
url | string | One of base64 / url | Public http(s) URL to fetch the file from |
contentType | string | With base64 | MIME type (e.g. image/png). With url it defaults to the response’s Content-Type header. |
alt | string | No | Alt text for accessibility |
Scope: media:write | Minimum role: Contributor
media_create
Register a media file that has already been uploaded to storage. The caller is responsible for placing the file at storageKey (typically using a signed upload URL from the admin UI or a separate API). This tool persists the metadata record so the file is discoverable via media_list / media_get and can be referenced by content.
| Parameter | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | Original filename (e.g. logo.png) |
mimeType | string | Yes | MIME type (e.g. image/png) |
storageKey | string | Yes | Storage path/key the file was uploaded to |
size | integer | No | File size in bytes |
width | integer | No | Image width in pixels |
height | integer | No | Image height in pixels |
contentHash | string | No | Hash of the file contents (for dedupe) |
blurhash | string | No | Blurhash for image placeholders |
dominantColor | string | No | Hex color string for the image’s dominant color |
Scope: media:write | Minimum role: Author
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
media_usage_repair
Repair content media usage indexes for one collection or every collection. Repair runs synchronously and can be slow or expensive on large sites; prefer collection scope when possible.
| Parameter | Type | Required | Description |
|---|---|---|---|
scope | "collection" | "all" | Yes | Whether to repair one collection or all collections |
collection | string | For collection scope | Collection slug; omit when scope is all |
The result has a structured status of complete, partial, failed, or stale, plus aggregate and per-collection source counts. All four statuses are successful MCP tool results, so callers must inspect status rather than relying on isError. Authentication, validation, or unexpected repair errors return isError: true.
Scope: admin | Minimum role: Admin
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. The parent’s ancestor chain must not exceed 100 levels.
| 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: taxonomies:manage | Minimum role: Editor
taxonomy_update_term
Update an existing term in a taxonomy. Any field can be omitted to leave it unchanged. Renaming a slug must not collide with another term in the same taxonomy. Set parentId to null to detach from a parent. The new parent must exist, belong to the same taxonomy, and not introduce a cycle.
| Parameter | Type | Required | Description |
|---|---|---|---|
taxonomy | string | Yes | Taxonomy name |
termSlug | string | Yes | Current slug of the term to update |
slug | string | No | New slug (must be unique in the taxonomy) |
label | string | No | New display name |
parentId | string | null | No | New parent term ID; null to detach |
description | string | No | New description |
Scope: taxonomies:manage | Minimum role: Editor
taxonomy_delete_term
Permanently delete a term from a taxonomy. Any content tagged with the term loses the association. Cannot delete a term that has children — delete children first.
| Parameter | Type | Required | Description |
|---|---|---|---|
taxonomy | string | Yes | Taxonomy name |
termSlug | string | Yes | Slug of the term to delete |
Scope: taxonomies:manage | Minimum role: Editor | Destructive: Yes
Menu Tools
menu_list
List navigation menus. Menus are per-locale: pass locale to return one locale’s
rows only, or omit it to list every locale variant.
| Parameter | Type | Required | Description |
|---|---|---|---|
locale | string | No | Filter by locale (omit for all locale variants) |
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. When the same menu name exists in multiple locales,
pass locale to resolve the intended translation.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Menu name (e.g. main, footer) |
locale | string | No | Locale to resolve the menu for |
Scope: content:read | Read-only: Yes
menu_create
Create a new navigation menu. The name is the stable identifier used by site
templates; label is the human-readable name shown in the admin. Menus are
per-locale, so pass locale when the same menu name exists in multiple
translations. Add items afterwards with menu_set_items. If translationOf is
set, locale must also be set.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Stable identifier (/^[a-z][a-z0-9_]*$/) |
label | string | Yes | Display name for the admin |
locale | string | No | Locale for this menu (e.g. fr-fr) |
translationOf | string | No | Existing menu id to create this locale variant from |
Scope: menus:manage | Minimum role: Editor
menu_update
Update a menu’s label. The name (stable identifier) cannot be changed. On
multi-locale installs, pass locale so the correct translation is updated.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Menu name to update |
label | string | Yes | New display label |
locale | string | No | Locale of the menu to update |
Scope: menus:manage | Minimum role: Editor
menu_delete
Delete a menu and all its items. Cannot be undone. On multi-locale installs,
pass locale so only the intended translation is removed.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Menu name to delete |
locale | string | No | Locale of the menu to delete |
Scope: menus:manage | Minimum role: Editor | Destructive: Yes
menu_set_items
Replace the entire item list of a menu in one call. Atomic: existing items are
deleted and the new list is inserted in the order provided. Use this rather
than per-item add/remove operations so the resulting order and parent links are
unambiguous. On multi-locale installs, pass locale so only the intended
translation is rewritten.
Items are positioned by array index. Nesting is expressed via parentIndex — an item with parentIndex: 0 is nested under the item at index 0. The parent must appear earlier in the list. Items without parentIndex are top-level.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Menu name to update |
locale | string | No | Locale of the menu to rewrite |
items | MenuItem[] | Yes | Ordered list of menu items (see below) |
Each MenuItem has:
| Field | Type | Required | Description |
|---|---|---|---|
label | string | Yes | Item display text |
type | string | Yes | One of custom, page, post, taxonomy, collection |
customUrl | string | No | URL for type: "custom" items (ignored otherwise) |
referenceCollection | string | No | Target collection slug for content references |
referenceId | string | No | Target content / term ID for references |
titleAttr | string | No | HTML title attribute |
target | string | No | HTML target attribute (e.g. _blank) |
cssClasses | string | No | Space-separated CSS classes |
parentIndex | integer | No | Array index of the parent item. Omit for top-level items. |
Scope: menus:manage | Minimum role: Editor
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
Settings Tools
Site-wide settings — title, tagline, logo, favicon, canonical URL, default page size, date and time formatting, social handles, and SEO defaults.
settings_get
Get all site-wide settings. Media references (logo, favicon, seo.defaultOgImage) include resolved URLs alongside the underlying mediaId. Unset values are omitted from the response.
No parameters.
Scope: settings:read | Minimum role: Editor | Read-only: Yes
settings_update
Update one or more site-wide settings. Partial update: only the fields provided are changed; omitted fields are left as-is. Returns the full settings object after the update.
To set a media reference (logo, favicon, seo.defaultOgImage), pass an object with mediaId (and optional alt). The media item must already exist — use media_create first.
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string | No | Site title |
tagline | string | No | Short description shown alongside the title |
logo | MediaRef | No | Logo media reference ({ mediaId, alt? }) |
favicon | MediaRef | No | Favicon media reference |
url | string | No | Canonical site URL (http or https). Empty string clears it. |
postsPerPage | integer | No | Default page size for content listings (1-100) |
dateFormat | string | No | Date format token string |
timezone | string | No | IANA timezone identifier |
social | object | No | Social handles — twitter, github, facebook, instagram, linkedin, youtube |
seo | object | No | SEO defaults (see below) |
The seo object accepts:
| Field | Type | Description |
|---|---|---|
titleSeparator | string | Separator between page title and site title (e.g. " | " for a vertical bar) |
defaultOgImage | MediaRef | Default Open Graph image when content has none |
robotsTxt | string | Custom robots.txt body. Omit to use the EmDash default. |
googleVerification | string | Google Search Console verification token |
bingVerification | string | Bing Webmaster Tools verification token |
Scope: settings:manage | Minimum role: Admin
OAuth Discovery
Most MCP clients handle this for you; this section is for building an MCP client against EmDash directly. Clients that support OAuth 2.1 discover how to authenticate from two metadata documents the server publishes:
Protected Resource Metadata
Request the protected resource metadata at the following endpoint:
GET /.well-known/oauth-protected-resource
The server responds with the resource identifier, its authorization server, and supported scopes:
{
"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",
"taxonomies:manage", "menus:manage",
"settings:read", "settings:manage",
"admin"
],
"bearer_methods_supported": ["header"]
}
Authorization Server Metadata
Request the authorization server metadata at the following endpoint:
GET /.well-known/oauth-authorization-server/_emdash
The server responds with the endpoints, scopes, and grant types it supports:
{
"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. The message is prefixed with a stable [CODE], and the same code is repeated in _meta.code:
{
"content": [{ "type": "text", "text": "[NOT_FOUND] Collection 'nonexistent' not found" }],
"isError": true,
"_meta": { "code": "NOT_FOUND" }
}
Scope and permission errors use the same tool error envelope:
{
"content": [
{ "type": "text", "text": "[INSUFFICIENT_SCOPE] Insufficient scope: requires content:write" }
],
"isError": true,
"_meta": { "code": "INSUFFICIENT_SCOPE" }
}
Transport-level errors (server misconfiguration, unhandled exceptions) return JSON-RPC error code -32603 (Internal error) without leaking implementation details.