---
title: MCP Server Reference
description: Protocol details, tool specifications, and OAuth configuration for the MCP server.
---

import { Aside } from "@astrojs/starlight/components";

EmDash includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server at `/_emdash/api/mcp` that exposes content management operations as tools for AI assistants.

<Aside>
	Looking to connect Claude, ChatGPT, or another AI tool to your site? See the [AI Tools guide](/guides/ai-tools) for setup instructions and usage tips.
</Aside>

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 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. |
| `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.

`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.

| Operation | Minimum role |
| --- | --- |
| Content read | Subscriber (10) for published items; Contributor (20) for drafts, scheduled, trash, and revisions |
| Content create / 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_create`) | Author (30) |

See the [Authentication guide](/guides/authentication#user-roles) 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 calls
- **`GET /_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](https://www.jsonrpc.org/specification) format. Errors use standard JSON-RPC error codes, with MCP-specific codes for scope and permission failures.

## Tools

The server exposes 43 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

<Aside>
	The `_rev` token in the response is used for conflict detection. Pass it back when updating to ensure no one else has modified the item since you read it.
</Aside>

#### `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

<Aside type="caution">
	Schema tools modify the database structure. Creating or deleting collections and fields changes the underlying tables. These operations require Admin role.
</Aside>

#### `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_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.

<Aside>
	The MCP transport is not appropriate for binary uploads. Use the signed-upload flow to put the bytes in storage, then call `media_create` to register the record.
</Aside>

| 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

### 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 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

#### `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. Add items afterwards with `menu_set_items`.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Stable identifier (`/^[a-z][a-z0-9_]*$/`) |
| `label` | `string` | Yes | Display name for the admin |

**Scope:** `menus:manage` | **Minimum role:** Editor

#### `menu_update`

Update a menu's label. The `name` (stable identifier) cannot be changed.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Menu name to update |
| `label` | `string` | Yes | New display label |

**Scope:** `menus:manage` | **Minimum role:** Editor

#### `menu_delete`

Delete a menu and all its items. Cannot be undone.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Menu name 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.

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 |
| `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

MCP clients that support OAuth 2.1 can automatically discover how to authenticate. The server publishes two metadata documents:

### Protected Resource Metadata

```http
GET /.well-known/oauth-protected-resource
```

```json
{
  "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

```http
GET /.well-known/oauth-authorization-server/_emdash
```

```json
{
  "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
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`:

```json
{
  "content": [{ "type": "text", "text": "Collection 'nonexistent' not found" }],
  "isError": true
}
```

Scope and permission errors throw MCP protocol errors:

```json
{
  "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.
