Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/entry-edit-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The lock lasts seven minutes. The admin renews it every two minutes while the en

#### Who is newly refused

Scripts, API tokens and the CLI that update, delete, publish, unpublish, schedule or discard an entry while an editor has it open in the admin now receive `409 ENTRY_LOCKED` where the write used to succeed. This applies to every collection once the migration has run. The response's `error.message` names the holder and `error.details` carries their `userId`, `userName`, `acquiredAt` and `expiresAt`. Pass `"overrideLock": true` in the request body to write anyway, or `?overrideLock=true` on `DELETE`, which has no body. The CLI takes `--override-lock` on `content update`, `content delete`, `content publish`, `content unpublish` and `content schedule`. The MCP content tools do not honour the lock yet.
Scripts, API tokens and the CLI that update, delete, publish, unpublish, schedule or discard an entry while an editor has it open in the admin now receive `409 ENTRY_LOCKED` where the write used to succeed. This applies to every collection once the migration has run. The response's `error.message` names the holder and `error.details` carries their `userId`, `userName`, `acquiredAt` and `expiresAt`. Pass `"overrideLock": true` in the request body to write anyway, or `?overrideLock=true` on `DELETE`, which has no body. The CLI takes `--override-lock` on `content update`, `content delete`, `content publish`, `content unpublish` and `content schedule`.

Locks are per entry and per locale, so two translations of the same entry can be edited at once.

Expand Down
11 changes: 11 additions & 0 deletions .changeset/mcp-honour-entry-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"emdash": minor
---

Updates the MCP content tools and revision restore to honor an entry's edit lock, so an AI tool connected over MCP no longer overwrites an entry that someone else has 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` while another user holds the entry's lock, where the call used to succeed. The error message names the holder, and `_meta.details` carries their `userId`, `userName`, `acquiredAt` and `expiresAt`. Reading the item again does not clear the refusal. Each of these tools takes an optional `overrideLock: true` to write anyway.

`POST /_emdash/api/revisions/{revisionId}/restore` now returns `409 ENTRY_LOCKED` in the same case. Pass `"overrideLock": true` in the request body to restore anyway.

To keep the previous behavior for a whole collection, switch edit locking off for it under **Content Types** → your collection → **Edit locking**.
8 changes: 5 additions & 3 deletions docs/src/content/docs/guides/working-with-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,11 @@ If the browser or computer closes without releasing it, the lock expires seven m
last renewal.

Locks are independent for each locale, so two people can edit different translations of the same
entry. A script or API client is also refused while another editor holds the lock unless it uses the
documented override. See [Entry edit lock](/reference/rest-api/#entry-edit-lock) for the complete
API behavior.
entry. A script, API client, or MCP client is also refused while another editor holds the lock
unless it uses the documented override. See [Entry edit lock](/reference/rest-api/#entry-edit-lock)
for the complete API behavior and
[Content lifecycle and bylines](/reference/mcp-server/#content-lifecycle-and-bylines) for the MCP
tools.

An administrator can turn locking off for a collection under **Content Types**, then the collection,
then **Edit locking**.
Expand Down
20 changes: 19 additions & 1 deletion docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ The result is returned as JSON text in the first content block. A tool with an o

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.

MCP writes do not participate in the admin's [entry edit lock](/reference/rest-api/#entry-edit-lock). The `_rev` check protects the operations that accept it, but other write tools can change an entry while an editor has it open.
While another user holds an entry's [edit lock](/reference/rest-api/#entry-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

Expand Down Expand Up @@ -283,4 +283,22 @@ A tool failure has `isError: true`. The first text block starts with a stable co
}
```

A refusal from an entry's edit lock carries the holder in `_meta.details`:

```json
{
"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.
4 changes: 4 additions & 0 deletions packages/core/src/api/schemas/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ export const contentRevisionConditionBody = z.object({
overrideLock: overrideLockFlag,
});

export const revisionRestoreBody = z.object({
overrideLock: overrideLockFlag,
});

export const contentPublishBody = contentRevisionConditionBody
.extend({
// .optional() rather than .nullish(): publishing has no semantic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@
* Restore revision endpoint - injected by EmDash integration
*
* POST /_emdash/api/revisions/{revisionId}/restore - Restore revision
*
* Optional JSON body: { overrideLock?: boolean }
*/

import type { APIRoute } from "astro";

import { requireOwnerPerm } from "#api/authorize.js";
import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
import { claimEntryLockForWrite } from "#api/handlers/entry-lock.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
import { revisionRestoreBody } from "#api/schemas.js";

export const prerender = false;

export const POST: APIRoute = async ({ params, locals }) => {
export const POST: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const revisionId = params.revisionId!;
const body = await parseOptionalBody(request, revisionRestoreBody, {});
if (isParseError(body)) return body;

if (!emdash?.handleRevisionRestore || !emdash?.handleRevisionGet || !emdash?.handleContentGet) {
return apiError("NOT_CONFIGURED", "EmDash not configured", 500);
Expand Down Expand Up @@ -52,6 +59,15 @@ export const POST: APIRoute = async ({ params, locals }) => {
const denied = requireOwnerPerm(user, authorId, "content:edit_own", "content:edit_any");
if (denied) return denied;

const refusal = await claimEntryLockForWrite(emdash.db, collection, entryId, user!.id, {
override: body.overrideLock,
});
if (refusal) {
return apiError(refusal.code, refusal.message, mapErrorStatus(refusal.code), {
...refusal.details,
});
}

const result = await emdash.handleRevisionRestore(revisionId, user!.id);

return unwrapResult(result);
Expand Down
67 changes: 65 additions & 2 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
updateTaxonomyDefBody,
} from "#api/schemas.js";

import { claimEntryLockForWrite } from "../api/handlers/entry-lock.js";
import type { MediaUsageRepairRequest } from "../api/schemas/media-usage.js";
import type { EmDashHandlers } from "../astro/types.js";
import { hasScope } from "../auth/api-tokens.js";
Expand Down Expand Up @@ -56,6 +57,17 @@ const REV_PARAM_DESCRIPTION =
const REV_MISSING_ERROR =
"_rev is required: call content_get for this item and pass back the _rev it returns.";

/**
* Shared wording for the `overrideLock` parameter on write tools. Agents only
* see the tool schema, and re-reading never clears ENTRY_LOCKED, so when to
* override is stated here.
*/
const OVERRIDE_LOCK_PARAM_DESCRIPTION =
"Write even though someone else has this item open in the admin. Without it, " +
"the call fails with ENTRY_LOCKED and the error names who is editing; calling " +
"content_get again does not clear it. Tell the user who holds the item, and " +
"set this only if they ask you to write anyway.";

const TAXONOMY_CURSOR_VERSION = 2;
const MAX_TAXONOMY_CURSOR_LENGTH = 2048;
const contentDateTimeInputSchema = z.iso
Expand Down Expand Up @@ -657,6 +669,24 @@ function extractContentId(data: unknown): string | undefined {
return typeof item?.id === "string" ? item.id : undefined;
}

/**
* Refuses a write while another user holds the entry's edit lock. Resolves to
* `null` when the write may proceed, and extends the caller's own lease if they
* hold it.
*/
async function refuseLockedEntry(
extra: { authInfo?: { extra?: Record<string, unknown> } },
collection: string,
entryId: string,
overrideLock: boolean | undefined,
): Promise<ErrorEnvelope | null> {
const { emdash, userId } = getExtra(extra);
const refusal = await claimEntryLockForWrite(emdash.db, collection, entryId, userId, {
override: overrideLock,
});
return refusal ? respondError(refusal.code, refusal.message, { ...refusal.details }) : null;
}

/** Extract the `_rev` token from a content handler response. */
function extractContentRev(data: unknown): string | undefined {
if (!data || typeof data !== "object") return undefined;
Expand Down Expand Up @@ -1103,6 +1133,7 @@ export function createMcpServer(
"Override the publication timestamp (ISO 8601). Requires content:publish_any permission. Pass null to clear. Useful for content migrations.",
),
_rev: z.string({ error: REV_MISSING_ERROR }).describe(REV_PARAM_DESCRIPTION),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
},
async (args, extra) => {
Expand Down Expand Up @@ -1138,9 +1169,15 @@ export function createMcpServer(

const resolvedId = extractContentId(existing.data) ?? args.id;

if (args.status !== undefined) {
requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
}

const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock);
if (locked) return locked;

// Status transitions route through dedicated handlers for proper revision management
if (args.status === "published") {
requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
let rev: string | undefined = args._rev;
if (
args.data ||
Expand Down Expand Up @@ -1170,7 +1207,6 @@ export function createMcpServer(
}

if (args.status === "draft") {
requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
let rev: string | undefined = args._rev;
if (
args.data ||
Expand Down Expand Up @@ -1226,6 +1262,7 @@ export function createMcpServer(
inputSchema: z.object({
collection: z.string().describe("Collection slug"),
id: z.string().describe("Content item ID or slug"),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
annotations: { destructiveHint: true },
},
Expand All @@ -1247,6 +1284,8 @@ export function createMcpServer(
);

const resolvedId = extractContentId(existing.data) ?? args.id;
const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock);
if (locked) return locked;
return unwrap(await ec.handleContentDelete(args.collection, resolvedId));
},
);
Expand Down Expand Up @@ -1325,6 +1364,7 @@ export function createMcpServer(
.describe(
"Override publication timestamp (ISO 8601). Requires content:publish_any permission. Useful when importing content with original publish dates.",
),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
},
async (args, extra) => {
Expand Down Expand Up @@ -1353,6 +1393,8 @@ export function createMcpServer(
}

const resolvedId = extractContentId(existing.data) ?? args.id;
const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock);
if (locked) return locked;
return unwrap(
await emdash.handleContentPublish(args.collection, resolvedId, {
publishedAt: args.publishedAt,
Expand All @@ -1373,6 +1415,7 @@ export function createMcpServer(
collection: z.string().describe("Collection slug"),
id: z.string().describe("Content item ID or slug"),
_rev: z.string({ error: REV_MISSING_ERROR }).describe(REV_PARAM_DESCRIPTION),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
},
async (args, extra) => {
Expand All @@ -1393,6 +1436,8 @@ export function createMcpServer(
);

const resolvedId = extractContentId(existing.data) ?? args.id;
const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock);
if (locked) return locked;
return unwrap(
await ec.handleContentUnpublish(args.collection, resolvedId, { _rev: args._rev }),
);
Expand All @@ -1413,6 +1458,7 @@ export function createMcpServer(
scheduledAt: z
.string()
.describe("ISO 8601 datetime for publication (e.g. '2025-06-01T09:00:00Z')"),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
},
async (args, extra) => {
Expand All @@ -1433,6 +1479,8 @@ export function createMcpServer(
);

const resolvedId = extractContentId(existing.data) ?? args.id;
const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock);
if (locked) return locked;
return unwrap(await ec.handleContentSchedule(args.collection, resolvedId, args.scheduledAt));
},
);
Expand All @@ -1448,6 +1496,7 @@ export function createMcpServer(
inputSchema: z.object({
collection: z.string().describe("Collection slug"),
id: z.string().describe("Content item ID or slug"),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
},
async (args, extra) => {
Expand All @@ -1467,6 +1516,8 @@ export function createMcpServer(
);

const resolvedId = extractContentId(existing.data) ?? args.id;
const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock);
if (locked) return locked;
return unwrap(await ec.handleContentUnschedule(args.collection, resolvedId));
},
);
Expand Down Expand Up @@ -1504,6 +1555,7 @@ export function createMcpServer(
collection: z.string().describe("Collection slug"),
id: z.string().describe("Content item ID or slug"),
_rev: z.string({ error: REV_MISSING_ERROR }).describe(REV_PARAM_DESCRIPTION),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
annotations: { destructiveHint: true },
},
Expand All @@ -1525,6 +1577,8 @@ export function createMcpServer(
);

const resolvedId = extractContentId(existing.data) ?? args.id;
const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock);
if (locked) return locked;
return unwrap(
await ec.handleContentDiscardDraft(args.collection, resolvedId, { _rev: args._rev }),
);
Expand Down Expand Up @@ -3164,6 +3218,7 @@ export function createMcpServer(
"use content_publish afterward if needed.",
inputSchema: z.object({
revisionId: z.string().describe("Revision ID to restore"),
overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION),
}),
},
async (args, extra) => {
Expand Down Expand Up @@ -3196,6 +3251,14 @@ export function createMcpServer(
"content:edit_any",
);

const locked = await refuseLockedEntry(
extra,
revItem.collection,
revItem.entryId,
args.overrideLock,
);
if (locked) return locked;

return unwrap(await emdash.handleRevisionRestore(args.revisionId, userId));
},
);
Expand Down
Loading
Loading