@abinnovision/payloadcms-mcpx 1.0.0-beta.12 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,13 +13,14 @@ resolved server-side against the real config and the real document, so unknown
13
13
  fields, misplaced blocks and unusable rich text nodes or node fields are refused with the
14
14
  valid alternatives listed, never silently dropped.
15
15
 
16
- Writes are RFC 6902 patches that land as drafts, with `allowLiveWrites` as the
17
- one documented exception; publishing stays a human action in the admin panel.
18
- Every write returns the publish
19
- blockers: the validation failures that still prevent a human from publishing
20
- the draft. Capabilities are declared twice: the plugin config decides what
21
- can exist, a checkbox on each API key decides what does, and a missing checkbox
22
- means no (fail-closed).
16
+ Writes are RFC 6902 patches that land as drafts. One config axis decides how far
17
+ they reach: `write: "draft"` never changes live content, `write: "live"` does
18
+ by exposing `publishDocument` where versions exist, and by permitting the write
19
+ at all where they do not. Every write returns the publish blockers: the
20
+ validation failures that still prevent the draft from being published.
21
+ Capabilities are declared twice: the plugin config decides what can exist, a
22
+ checkbox on each API key decides what does, and a missing checkbox means no
23
+ (fail-closed).
23
24
 
24
25
  ## Install
25
26
 
@@ -44,12 +45,12 @@ export default buildConfig({
44
45
  plugins: [
45
46
  mcpxPlugin({
46
47
  collections: {
47
- pages: { read: true, write: true },
48
- posts: { read: true, write: true },
48
+ pages: { read: true, write: "live" }, // may be published through MCP
49
+ posts: { read: true, write: "draft" }, // drafts only
49
50
  tags: true, // shorthand for { read: true }
50
51
  },
51
52
  globals: {
52
- "site-settings": { read: true, write: true },
53
+ "site-settings": { read: true, write: "draft" },
53
54
  },
54
55
  limits: { maxLimit: 25, maxDepth: 1 },
55
56
  }),
@@ -78,7 +79,10 @@ to anyone who may read the key document (own keys only, by default). Each key:
78
79
  - carries one checkbox per exposed collection and operation, plus one per
79
80
  custom tool. All checkboxes default to off. A key can never enable an
80
81
  operation the plugin config does not expose, and keys created before a
81
- capability existed stay without it.
82
+ capability existed stay without it. The `publish` checkbox only exists where
83
+ a versioned entity is configured `write: "live"`, so a key issued before
84
+ publishing was possible stays closed to it, and it counts only alongside
85
+ `write`: publishing is an extension of writing, not a capability of its own.
82
86
 
83
87
  Keys authenticate only the MCP endpoint. They are deliberately not a Payload
84
88
  auth strategy, so a key can never authenticate the REST or GraphQL API; the
@@ -141,7 +145,7 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
141
145
 
142
146
  ## Tools
143
147
 
144
- The surface is fixed at seven tools plus your custom ones; exposing a global
148
+ The surface is fixed at eight tools plus your custom ones; exposing a global
145
149
  adds an argument, never a tool. `tools/list` reflects the key: write tools
146
150
  disappear for read-only keys, and every `collection` and `global` enum contains
147
151
  only the slugs the key may touch.
@@ -155,6 +159,7 @@ only the slugs the key may touch.
155
159
  | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
156
160
  | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
157
161
  | `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
162
+ | `publishDocument` | Publish the current draft. | `collection` + `id` \| `global`, `expectedUpdatedAt?` |
158
163
 
159
164
  Rules the tools enforce and explain in their own descriptions:
160
165
 
@@ -245,15 +250,40 @@ If `tools/list` omits `global` entirely, no global is exposed to that key; the
245
250
  argument only appears once one is. A deployment that uses no globals sees the
246
251
  tool schemas exactly as they were.
247
252
 
248
- ## Drafts and publish blockers
253
+ ## Drafts and publishing
249
254
 
250
255
  Draft-only writing is enforced on the Payload operation, not in the tool
251
256
  handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
252
257
  from every write carrying the MCP request marker, so custom tools and anything
253
258
  else writing through the same request are covered too. A `beforeChange` hook
254
- refuses any write that would still not land as a draft. Globals expose the same
255
- `beforeOperation` interception point at the same position in the operation, so
256
- they are guarded exactly as strongly as collections, exposed or not.
259
+ refuses any write that would still not land as a draft.
260
+
261
+ The two hooks are not equally load-bearing on both sides. `updateGlobal` reads
262
+ `draft` and the publish arguments off its argument bag _before_ it runs
263
+ `beforeOperation`, and re-reads only `data` afterwards, so for a global the
264
+ correction cannot apply and the `beforeChange` refusal is what actually holds
265
+ the line. Both are installed on every collection and global, exposed or not.
266
+
267
+ `publishDocument` is the one way through, and it opens the door for exactly one
268
+ operation: the tool records an intent naming the entity and id it is about to
269
+ publish, in `AsyncLocalStorage` rather than on the request, and the guard
270
+ consults it. A concurrent call in the same JSON-RPC batch runs in another async
271
+ context and sees nothing, a nested write to a different document meets the
272
+ unguarded rules, and the intent is claimed once so a re-entrant write to the
273
+ same document cannot ride along. It is not a security boundary — a custom tool
274
+ holds the whole `payload` instance — but no ordinary write can widen itself into
275
+ a publish by accident.
276
+
277
+ Publishing covers the whole document, as the admin Publish button does, but
278
+ Payload only validates the locale the publish runs in. A required field left
279
+ empty in another locale therefore goes live empty; that is Payload's behaviour,
280
+ not something this plugin adds. `publishDocument` refuses a document that fails
281
+ validation and reports `validationErrors` with JSON Pointers. It is refused
282
+ while a human holds the document open in the admin panel, and republishing an
283
+ unchanged document is accepted but writes another version.
284
+
285
+ There is no unpublish tool. Reverting a published document to a draft stays a
286
+ human action.
257
287
 
258
288
  Publish blockers are advisory. Payload skips validation on draft saves (unless
259
289
  `versions.drafts.validate` is set), so after every write the plugin re-runs
@@ -308,9 +338,9 @@ Custom tools take the same route as the builtins: one `McpxTool` shape, one
308
338
  registration loop. Anything a builtin does, a custom tool can do.
309
339
 
310
340
  `handler` receives `scope` alongside `args`, `req` and `extra`. The scope
311
- carries what the key may touch (`readable`, `writable`, `readableGlobals`,
312
- `writableGlobals`), the configured locales, the limits in force and the
313
- exposed collections and globals. `req` is shorthand for `scope.req`.
341
+ carries what the key may touch (`readable`, `writable`, `publishable`,
342
+ `readableGlobals`, `writableGlobals`, `publishableGlobals`), the configured
343
+ locales, the limits in force and the exposed collections and globals. `req` is shorthand for `scope.req`.
314
344
 
315
345
  `inputSchema` may be a function of that scope instead of a fixed shape, which
316
346
  is how a tool narrows an enum to what the key may read:
@@ -363,35 +393,48 @@ results shaped like a builtin's.
363
393
 
364
394
  ## Options
365
395
 
366
- | Option | Default | Description |
367
- | ------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
368
- | `collections` | required | Allow-list. `true` means `{ read: true }`. |
369
- | `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
370
- | `collections.<slug>.write` | `false` | Expose `patchDocument`, `createDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
371
- | `collections.<slug>.allowLiveWrites` | `false` | Permit writes to a collection without drafts (they land live). |
372
- | `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
373
- | `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
374
- | `globals.<slug>.write` | `false` | Expose `patchDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
375
- | `globals.<slug>.allowLiveWrites` | `false` | Permit writes to a global without drafts (they land live). |
376
- | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
377
- | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
378
- | `apiKeys.setupGuide` | `true` | Add a "Connect a client" tab to saved keys. Needs the import map. |
379
- | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
380
- | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
381
- | `limits.maxLimit` | `25` | Upper bound for `findDocuments.limit`. |
382
- | `limits.maxDepth` | `1` | Upper bound for `depth` on reads. |
383
- | `tools` | `[]` | Custom tools, defined the same way as the builtins. |
384
- | `auth.resolve` | none | Replace or wrap the default key resolution. |
385
- | `serverInfo` | package name and version | Reported to MCP clients. |
386
-
387
- `allowLiveWrites` is the only way an MCP write reaches live content. Where it is
388
- set, the server instructions and the `patchDocument` and `createDocument`
389
- descriptions name those slugs for the key in question, so a client is never told
390
- its writes are drafts while they are not.
391
-
392
- Misconfiguration (unknown slugs, write on a collection without drafts, upload
393
- collections exposed for write, tool name collisions) fails at startup with
394
- `InvalidConfiguration`. Auth collections cannot be exposed at all, read
396
+ | Option | Default | Description |
397
+ | ---------------------------- | ------------------------------ | ----------------------------------------------------------------- |
398
+ | `collections` | required | Allow-list. `true` means `{ read: true }`. |
399
+ | `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
400
+ | `collections.<slug>.write` | `false` | `"draft"` or `"live"`. See below. |
401
+ | `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
402
+ | `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
403
+ | `globals.<slug>.write` | `false` | `"draft"` or `"live"`. See below. |
404
+ | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
405
+ | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
406
+ | `apiKeys.setupGuide` | `true` | Add a "Connect a client" tab to saved keys. Needs the import map. |
407
+ | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
408
+ | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
409
+ | `limits.maxLimit` | `25` | Upper bound for `findDocuments.limit`. |
410
+ | `limits.maxDepth` | `1` | Upper bound for `depth` on reads. |
411
+ | `tools` | `[]` | Custom tools, defined the same way as the builtins. |
412
+ | `auth.resolve` | none | Replace or wrap the default key resolution. |
413
+ | `serverInfo` | package name and version | Reported to MCP clients. |
414
+
415
+ `write` is one axis: how far MCP writes to this entity reach.
416
+
417
+ | `write` | With `versions.drafts` | Without |
418
+ | --------- | ------------------------------------------------------- | ---------------------------------------------- |
419
+ | `false` | no write tool reaches it | no write tool reaches it |
420
+ | `"draft"` | writes land as drafts, nothing is ever published | refused at startup: there is no draft to write |
421
+ | `"live"` | writes land as drafts, and `publishDocument` is exposed | writes land on the live document |
422
+
423
+ `"live"` is the only way an MCP write reaches live content, whichever of the two
424
+ shapes it takes. Wherever it is set, the server instructions and the
425
+ `patchDocument` and `createDocument` descriptions name those slugs for the key in
426
+ question, so a client is never told its writes are drafts while they are not,
427
+ nor that publishing is out of reach when it is not.
428
+
429
+ Migrating from the previous option shape: `write: true` becomes
430
+ `write: "draft"`, and `write: true` with `allowLiveWrites: true` becomes
431
+ `write: "live"`. A versioned entity moved to `write: "live"` gains a `publish`
432
+ checkbox on every key, unticked, so nothing publishes until someone says so.
433
+
434
+ Misconfiguration (unknown slugs, `write: "draft"` on a collection without
435
+ drafts, upload collections exposed for write, tool name collisions) fails at
436
+ startup with `InvalidConfiguration`. So does `write: "live"` on an entity using
437
+ `versions.drafts.localizeStatus`, which is not supported yet. Auth collections cannot be exposed at all, read
395
438
  included: their documents carry credentials, such as the decrypted Payload API
396
439
  key of every user.
397
440
 
@@ -402,12 +445,16 @@ key of every user.
402
445
  - The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
403
446
  ignored. Keys cannot authenticate REST or GraphQL.
404
447
  - Every operation runs under the linked user with `overrideAccess: false`.
448
+ - Payload has no separate publish permission: at its access layer, anyone who
449
+ may update a document may publish it. The `publish` checkbox is this plugin's
450
+ fence, not Payload's.
405
451
  - Not covered in v1: `delete` (no tool exists and none is generated), uploads.
406
452
  Custom tools are trusted code and can do what the linked user may.
407
453
 
408
454
  ## Non-goals of v1 / roadmap
409
455
 
410
- Deletes, uploads, markdown authoring for rich text, addressing a rich text node
456
+ Unpublishing, `versions.drafts.localizeStatus`, deletes, uploads, markdown
457
+ authoring for rich text, addressing a rich text node
411
458
  by position in a patch (an editor state is written whole), schemas for `upload`
412
459
  node fields, row addressing by id instead of index, cross-locale publish
413
460
  blockers, pagination of `describeSchema` with `expand`, and a handler-level
@@ -1,4 +1,4 @@
1
- import { CAPABILITIES_FIELD } from "../capabilities.mjs";
1
+ import { CAPABILITIES_FIELD, canPublish, canWrite } from "../capabilities.mjs";
2
2
  //#region src/api-keys/fields.ts
3
3
  const encryptKey = ({ req, value }) => typeof value === "string" ? req.payload.encrypt(value) : value;
4
4
  const decryptKey = ({ req, value }) => {
@@ -89,23 +89,36 @@ const SETUP_GUIDE_FIELD = "setupGuide";
89
89
  }]
90
90
  }];
91
91
  };
92
+ const PUBLISH_DESCRIPTION = "Publish the current draft. Changes what the public sees.";
92
93
  /**
93
94
  * One checkbox per exposed operation, grouped per collection, per global and
94
95
  * per custom tool. Only operations the plugin config exposes get a checkbox, so
95
96
  * a key can never enable more than the config allows. Everything defaults to
96
97
  * off, which is why a key issued before a capability existed stays closed to it.
98
+ *
99
+ * An entity without versions gets no `publish` checkbox even under
100
+ * `write: "live"`: there is no draft to promote there, the write itself is the
101
+ * live change, and a second checkbox would only make `write` a dead setting.
97
102
  */ const createCapabilityFields = (options) => {
98
103
  const collectionGroups = options.collections.map((collection) => ({
99
104
  name: collection.fieldName,
100
105
  type: "group",
101
106
  label: collection.slug,
102
- fields: [...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [], ...collection.write ? [checkbox("write", "Create, patch and validate drafts.")] : []]
107
+ fields: [
108
+ ...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [],
109
+ ...canWrite(collection) ? [checkbox("write", "Create, patch and validate drafts.")] : [],
110
+ ...canPublish(collection) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
111
+ ]
103
112
  }));
104
113
  const globalGroups = options.globals.map((global) => ({
105
114
  name: global.fieldName,
106
115
  type: "group",
107
116
  label: global.slug,
108
- fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
117
+ fields: [
118
+ ...global.read ? [checkbox("read", "Describe and read this global.")] : [],
119
+ ...canWrite(global) ? [checkbox("write", "Patch and validate this global's draft.")] : [],
120
+ ...canPublish(global) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
121
+ ]
109
122
  }));
110
123
  const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
111
124
  const groups = [
@@ -1,8 +1,23 @@
1
1
  //#region src/capabilities.ts
2
2
  /** Name of the capability group on the key document. */ const CAPABILITIES_FIELD = "capabilities";
3
+ /** Whether any write tool reaches this entity. */ const canWrite = (entity) => entity.write !== false;
4
+ /**
5
+ * Whether `publishDocument` reaches this entity: the config lets MCP change
6
+ * live content and there is a draft to promote.
7
+ */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
8
+ /**
9
+ * Whether an ordinary write to this entity changes the live document. With no
10
+ * versions there is no draft to land on, so `write: "live"` is what permits the
11
+ * write at all and every write is live.
12
+ */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
3
13
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
14
  const flag = (group, name) => isRecord(group) && group[name] === true;
5
15
  /**
16
+ * Publishing is an extension of writing, never a capability of its own: a key
17
+ * that may publish may also edit the draft it publishes. Both checkboxes are
18
+ * therefore required, on top of the config exposing publishing at all.
19
+ */ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
20
+ /**
6
21
  * Capabilities in force for a key: the plugin config decides what can exist,
7
22
  * the key's checkboxes decide what does. A missing checkbox is `false`, so keys
8
23
  * issued before a capability existed stay closed.
@@ -15,7 +30,8 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
15
30
  const group = isRecord(collectionsGroup) ? collectionsGroup[collection.fieldName] : void 0;
16
31
  collections[collection.slug] = {
17
32
  read: collection.read && flag(group, "read"),
18
- write: collection.write && flag(group, "write")
33
+ write: canWrite(collection) && flag(group, "write"),
34
+ publish: publishFlag(collection, group)
19
35
  };
20
36
  }
21
37
  const globals = {};
@@ -23,7 +39,8 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
23
39
  const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
24
40
  globals[global.slug] = {
25
41
  read: global.read && flag(group, "read"),
26
- write: global.write && flag(group, "write")
42
+ write: canWrite(global) && flag(group, "write"),
43
+ publish: publishFlag(global, group)
27
44
  };
28
45
  }
29
46
  const tools = {};
@@ -37,7 +54,9 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
37
54
  const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
38
55
  const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
39
56
  const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
57
+ const publishableSlugs = (capabilities) => pick(capabilities.collections, "publish");
40
58
  const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
41
59
  const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
60
+ const publishableGlobalSlugs = (capabilities) => pick(capabilities.globals, "publish");
42
61
  //#endregion
43
- export { CAPABILITIES_FIELD, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
62
+ export { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
@@ -1,4 +1,4 @@
1
- import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
1
+ import { publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
2
2
  import { jsonRpcError } from "./errors.mjs";
3
3
  import { resolveApiKeyAuth } from "../auth/resolve.mjs";
4
4
  import { createMcpServer } from "./server.mjs";
@@ -11,8 +11,10 @@ const buildScope = (req, options, capabilities) => {
11
11
  capabilities,
12
12
  readable: readableSlugs(capabilities),
13
13
  writable: writableSlugs(capabilities),
14
+ publishable: publishableSlugs(capabilities),
14
15
  readableGlobals: readableGlobalSlugs(capabilities),
15
16
  writableGlobals: writableGlobalSlugs(capabilities),
17
+ publishableGlobals: publishableGlobalSlugs(capabilities),
16
18
  locales: localization ? localization.localeCodes : null,
17
19
  defaultLocale: localization ? localization.defaultLocale : null,
18
20
  limits: options.limits,
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool } from "./types.mjs";
1
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool } from "./types.mjs";
2
2
  import { mcpxPlugin } from "./plugin.mjs";
3
3
  import { isMcpxRequest } from "./request.mjs";
4
4
  import { errorResult, jsonResult } from "./result.mjs";
5
- export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
5
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
package/dist/options.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BUILTIN_TOOL_NAMES } from "./tools/names.mjs";
2
2
  import "./version.mjs";
3
3
  import { InvalidConfiguration } from "payload";
4
- import { hasDraftsEnabled } from "payload/shared";
4
+ import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
5
5
  //#region src/options.ts
6
6
  const DEFAULT_API_KEYS_SLUG = "mcpx-api-keys";
7
7
  const DEFAULT_ENDPOINT_PATH = "/mcpx";
@@ -24,11 +24,29 @@ const fail = (message) => {
24
24
  if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
25
25
  if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
26
26
  };
27
+ /**
28
+ * The write mode, checked at runtime as well as in the type. JS callers get no
29
+ * type checking, and a typo reading as "no write" would be a silent downgrade.
30
+ */ const normalizeWriteMode = (kind, slug, value) => {
31
+ if (value === void 0 || value === false) return false;
32
+ if (value === "draft" || value === "live") return value;
33
+ return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
34
+ };
35
+ /**
36
+ * `localizeStatus` makes `_status` a localized field, which flips Payload's
37
+ * `publishAllLocales` default to false and turns `_status` into a locale-keyed
38
+ * object. Publishing would then cover one locale while reporting success, and
39
+ * the tool responses model `_status` as a string. Refused until both are
40
+ * handled.
41
+ */ const assertPublishable = (kind, config) => {
42
+ if (hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which write: "live" does not support yet.`);
43
+ };
27
44
  const assertWritable = (collection, options) => {
28
45
  const { slug } = collection;
29
46
  if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
30
47
  if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
31
- if (!options.hasDrafts && !options.allowLiveWrites) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
48
+ if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
49
+ if (options.write === "live") assertPublishable("Collection", collection);
32
50
  };
33
51
  /**
34
52
  * Refuses globals that must never be reachable. Globals cannot be auth or
@@ -41,7 +59,8 @@ const assertWritable = (collection, options) => {
41
59
  * `createdAt`/`updatedAt`, so the concurrency check the collection path guards
42
60
  * for is always available here. Drafts are the only requirement left.
43
61
  */ const assertGlobalWritable = (global, options) => {
44
- if (!options.hasDrafts && !options.allowLiveWrites) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
62
+ if (options.write === "draft" && !options.hasDrafts) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set write: "live".`);
63
+ if (options.write === "live") assertPublishable("Global", global);
45
64
  };
46
65
  const normalizeCollections = (config, options, apiKeysSlug) => {
47
66
  const collections = config.collections ?? [];
@@ -56,12 +75,11 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
56
75
  const normalized = {
57
76
  slug,
58
77
  read: settings.read ?? true,
59
- write: settings.write ?? false,
60
- allowLiveWrites: settings.allowLiveWrites ?? false,
78
+ write: normalizeWriteMode("Collection", slug, settings.write),
61
79
  hasDrafts,
62
80
  fieldName: toCamelCase(slug)
63
81
  };
64
- if (normalized.write) assertWritable(collection, normalized);
82
+ if (normalized.write !== false) assertWritable(collection, normalized);
65
83
  if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
66
84
  fieldNames.add(normalized.fieldName);
67
85
  return [normalized];
@@ -80,12 +98,11 @@ const normalizeGlobals = (config, options) => {
80
98
  const normalized = {
81
99
  slug,
82
100
  read: settings.read ?? true,
83
- write: settings.write ?? false,
84
- allowLiveWrites: settings.allowLiveWrites ?? false,
101
+ write: normalizeWriteMode("Global", slug, settings.write),
85
102
  hasDrafts,
86
103
  fieldName: toCamelCase(slug)
87
104
  };
88
- if (normalized.write) assertGlobalWritable(global, normalized);
105
+ if (normalized.write !== false) assertGlobalWritable(global, normalized);
89
106
  if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
90
107
  fieldNames.add(normalized.fieldName);
91
108
  return [normalized];
@@ -4,6 +4,7 @@ import { findDocuments } from "./find-documents.mjs";
4
4
  import { getDocument } from "./get-document.mjs";
5
5
  import { listCapabilities } from "./list-capabilities.mjs";
6
6
  import { patchDocument } from "./patch-document.mjs";
7
+ import { publishDocument } from "./publish-document.mjs";
7
8
  import { validateDocument } from "./validate-document.mjs";
8
9
  //#region src/tools/builtin.ts
9
10
  /**
@@ -20,7 +21,8 @@ import { validateDocument } from "./validate-document.mjs";
20
21
  getDocument,
21
22
  patchDocument,
22
23
  createDocument,
23
- validateDocument
24
+ validateDocument,
25
+ publishDocument
24
26
  ];
25
27
  //#endregion
26
28
  export { BUILTIN_TOOLS };
@@ -32,6 +32,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
32
32
  ...description === void 0 ? {} : { description },
33
33
  read: capability.read,
34
34
  write: capability.write,
35
+ publish: capability.publish,
35
36
  drafts: entry.hasDrafts,
36
37
  draftValidation: hasDraftValidationEnabled(config),
37
38
  idType: collection.customIDType ?? payload.db.defaultIDType
@@ -48,6 +49,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
48
49
  ...description === void 0 ? {} : { description },
49
50
  read: capability.read,
50
51
  write: capability.write,
52
+ publish: capability.publish,
51
53
  drafts: entry.hasDrafts,
52
54
  draftValidation: hasDraftValidationEnabled(config)
53
55
  }];
@@ -8,7 +8,8 @@
8
8
  "getDocument",
9
9
  "patchDocument",
10
10
  "createDocument",
11
- "validateDocument"
11
+ "validateDocument",
12
+ "publishDocument"
12
13
  ];
13
14
  //#endregion
14
15
  export { BUILTIN_TOOL_NAMES };
@@ -1,5 +1,5 @@
1
1
  import { errorResult, jsonResult } from "../result.mjs";
2
- import { draftSentence, idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
2
+ import { draftSentence, idShape, localeOf, localeShape, readTarget, sameInstant, targetShape } from "./shared.mjs";
3
3
  import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
5
  import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
@@ -16,10 +16,9 @@ ${draftSentence(scope)}
16
16
 
17
17
  Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
18
18
 
19
- Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so a concurrent edit is refused rather than overwritten.
19
+ Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so an edit made since that read is refused rather than overwritten.
20
20
 
21
- A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
22
- const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
21
+ A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but the document cannot be published until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
23
22
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
24
23
  /**
25
24
  * Whether the intended value survived the write. The saved document is
@@ -64,7 +63,7 @@ const patchDocument = defineMcpxTool({
64
63
  description: "Locale the patch applies to. Localized fields write here only."
65
64
  }),
66
65
  patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
67
- expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
66
+ expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. Best effort: the write is refused if the document changed before the check, but not if it changes between the check and the write.")
68
67
  }),
69
68
  handler: async ({ args, scope }) => {
70
69
  const target = resolveTarget(scope, args, "write");
@@ -100,6 +99,7 @@ const patchDocument = defineMcpxTool({
100
99
  });
101
100
  else await payload.updateGlobal({
102
101
  ...write,
102
+ fallbackLocale: false,
103
103
  slug: target.slug
104
104
  });
105
105
  const saved = await readTarget(scope, {
@@ -0,0 +1,81 @@
1
+ import { errorResult, jsonResult } from "../result.mjs";
2
+ import { idShape, localeOf, readTarget, sameInstant, targetShape } from "./shared.mjs";
3
+ import { requireIdFor, resolveTarget } from "./target.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
5
+ import { withTransaction } from "../write/transaction.mjs";
6
+ import { withPublishIntent } from "../write/publish-intent.mjs";
7
+ import { z } from "zod";
8
+ const publishDocument = defineMcpxTool({
9
+ name: "publishDocument",
10
+ description: `Publishes the current draft, which changes what the public sees. This is the only tool that does; every other write lands as a draft. Call validateDocument first: a document that still has publish blockers is refused, and nothing is written.
11
+
12
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
13
+
14
+ The whole document is published, but Payload only validates the locale the publish runs in, so a required field left empty in another locale goes live empty. That is how the admin panel behaves too. Publishing is refused while a human holds the document open in the admin panel, and republishing an unchanged document is accepted but writes another version.
15
+
16
+ There is no unpublish: reverting to a draft stays a human action in the admin panel.`,
17
+ annotations: {
18
+ destructiveHint: true,
19
+ openWorldHint: false
20
+ },
21
+ isEnabled: (scope) => scope.publishable.length + scope.publishableGlobals.length > 0,
22
+ inputSchema: (scope) => ({
23
+ ...targetShape(scope, "publish", {
24
+ collection: "Collection holding the document.",
25
+ global: "Global to publish."
26
+ }),
27
+ ...idShape(scope, "publish"),
28
+ expectedUpdatedAt: z.string().optional().describe("The updatedAt read before publishing. Best effort: the publish is refused if the document has changed since, but a write landing between the check and the publish is not.")
29
+ }),
30
+ handler: async ({ args, scope }) => {
31
+ const target = resolveTarget(scope, args, "publish");
32
+ const id = requireIdFor(target, args.id);
33
+ const { payload } = scope.req;
34
+ const locale = localeOf(scope, void 0);
35
+ return await withTransaction(scope.req, async () => {
36
+ const doc = await readTarget(scope, {
37
+ target,
38
+ id,
39
+ locale
40
+ });
41
+ if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again before publishing.", { updatedAt: doc["updatedAt"] });
42
+ const write = {
43
+ data: {},
44
+ depth: 0,
45
+ draft: false,
46
+ fallbackLocale: false,
47
+ overrideAccess: false,
48
+ req: scope.req,
49
+ ...locale === void 0 ? {} : { locale }
50
+ };
51
+ await withPublishIntent({
52
+ kind: target.kind,
53
+ slug: target.slug,
54
+ id
55
+ }, async () => {
56
+ if (target.kind === "collection") await payload.update({
57
+ ...write,
58
+ collection: target.slug,
59
+ id
60
+ });
61
+ else await payload.updateGlobal({
62
+ ...write,
63
+ slug: target.slug
64
+ });
65
+ });
66
+ const saved = await readTarget(scope, {
67
+ target,
68
+ id,
69
+ locale,
70
+ privileged: true
71
+ });
72
+ return jsonResult({
73
+ ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
74
+ status: saved["_status"],
75
+ updatedAt: saved["updatedAt"]
76
+ });
77
+ });
78
+ }
79
+ });
80
+ //#endregion
81
+ export { publishDocument };
@@ -1,33 +1,63 @@
1
+ import { canPublish, isLiveWrite } from "../capabilities.mjs";
1
2
  import { translateStatic } from "../i18n.mjs";
2
3
  import { NotFound } from "payload";
3
4
  import { z } from "zod";
4
5
  //#region src/tools/shared.ts
5
6
  const slugEnum = (slugs) => z.enum(slugs);
6
7
  const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
7
- /**
8
- * Slugs this key may write whose writes land live rather than as a draft,
9
- * which is what `allowLiveWrites` permits for an entity without versions.
10
- * Empty for every key that can only write drafts.
11
- */ const liveWriteSlugs = (scope) => {
12
- const live = (entities, writable) => entities.filter((entity) => writable.includes(entity.slug) && entity.allowLiveWrites && !entity.hasDrafts).map((entity) => entity.slug);
13
- return [...live(scope.exposure.collections, scope.writable), ...live(scope.exposure.globals, scope.writableGlobals)];
8
+ const slugsWhere = (scope, predicate, allowed) => {
9
+ const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
10
+ return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
14
11
  };
15
12
  /**
16
- * The sentence the write tools and the server instructions end on: what a
17
- * write actually does for this key.
13
+ * Slugs this key may write whose writes land live rather than as a draft. An
14
+ * entity without versions has no draft to land on, so `write: "live"` there
15
+ * makes every write a live one. Empty for every key that can only write drafts.
16
+ */ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
17
+ collections: scope.writable,
18
+ globals: scope.writableGlobals
19
+ });
20
+ /** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
21
+ collections: scope.publishable,
22
+ globals: scope.publishableGlobals
23
+ });
24
+ /**
25
+ * The sentence the write tools and the server instructions end on: what a write
26
+ * actually does for this key, and what it takes to make it public. The three
27
+ * groups are distinct — a live-write slug has no draft and no publish step, a
28
+ * publishable one has both — so a client is never told its writes are drafts
29
+ * while they are not, nor that publishing is out of reach when it is not.
18
30
  */ const draftSentence = (scope) => {
19
31
  const live = liveWriteSlugs(scope);
20
- return live.length === 0 ? "Every write lands as a draft and is never published; publishing stays a human action in the admin panel." : `Writes land as drafts and are never published, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately. Publishing anything else stays a human action in the admin panel.`;
32
+ const publishable = publishableWriteSlugs(scope);
33
+ return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
21
34
  };
22
35
  /**
36
+ * Whether two timestamps name the same instant, which is how
37
+ * `expectedUpdatedAt` is compared: the value a client read back is a string,
38
+ * and what it is compared against may be a Date.
39
+ */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
40
+ /**
23
41
  * Widens one branch to the superset a handler sees. The widening itself is
24
42
  * unchecked — the runtime shape really does vary — so `Branch` checks what it
25
43
  * can around it.
26
44
  */ const widen = (branch) => branch;
27
- const slugsFor = (scope, operation) => ({
28
- collections: operation === "read" ? scope.readable : scope.writable,
29
- globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
30
- });
45
+ const slugsFor = (scope, operation) => {
46
+ switch (operation) {
47
+ case "publish": return {
48
+ collections: scope.publishable,
49
+ globals: scope.publishableGlobals
50
+ };
51
+ case "read": return {
52
+ collections: scope.readable,
53
+ globals: scope.readableGlobals
54
+ };
55
+ case "write": return {
56
+ collections: scope.writable,
57
+ globals: scope.writableGlobals
58
+ };
59
+ }
60
+ };
31
61
  /**
32
62
  * The `collection` and `global` arguments.
33
63
  *
@@ -114,4 +144,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
114
144
  return translateStatic(resolved, i18n) ?? fallback;
115
145
  };
116
146
  //#endregion
117
- export { depthShape, draftSentence, idSchema, idShape, liveWriteSlugs, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
147
+ export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
@@ -1,3 +1,4 @@
1
+ import { slugsFor } from "./shared.mjs";
1
2
  import { APIError, Forbidden } from "payload";
2
3
  //#region src/tools/target.ts
3
4
  const refOf = (target) => ({
@@ -14,12 +15,12 @@ const refOf = (target) => ({
14
15
  * failed call teaches it.
15
16
  */ const resolveTarget = (scope, args, operation) => {
16
17
  const { collection, global } = args;
18
+ const allowedSlugs = slugsFor(scope, operation);
17
19
  if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
18
20
  if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
19
21
  if (collection !== void 0) {
20
- const allowed = operation === "read" ? scope.readable : scope.writable;
21
22
  const found = scope.req.payload.collections[collection];
22
- if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
23
+ if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
23
24
  return {
24
25
  kind: "collection",
25
26
  slug: collection,
@@ -27,9 +28,8 @@ const refOf = (target) => ({
27
28
  };
28
29
  }
29
30
  const slug = global;
30
- const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
31
31
  const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
32
- if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
32
+ if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
33
33
  return {
34
34
  kind: "global",
35
35
  slug,
package/dist/types.d.mts CHANGED
@@ -11,6 +11,17 @@ declare module "payload" {
11
11
  "@abinnovision/payloadcms-mcpx": McpxPluginOptions;
12
12
  }
13
13
  }
14
+ /**
15
+ * How far an exposed entity lets MCP writes reach.
16
+ *
17
+ * - `false`: no write tool touches it.
18
+ * - `"draft"`: writes land as drafts and nothing MCP does changes what the
19
+ * public sees. Requires `versions.drafts`.
20
+ * - `"live"`: MCP may change live content. On an entity with drafts that means
21
+ * `publishDocument` is exposed; on one without, where there is no draft to
22
+ * land on, it means the write itself is permitted and lands live.
23
+ */
24
+ type McpxWriteMode = "draft" | "live" | false;
14
25
  /**
15
26
  * What an exposed collection offers to MCP clients. A key can only enable
16
27
  * what the config exposes here.
@@ -21,15 +32,10 @@ interface McpxCollectionOptions {
21
32
  */
22
33
  read?: boolean;
23
34
  /**
24
- * Expose `patchDocument`, `createDocument` and `validateDocument`. Default
25
- * `false`. Requires `versions.drafts` unless `allowLiveWrites` is set.
26
- */
27
- write?: boolean;
28
- /**
29
- * Permit writes to a collection without drafts. Such writes land on the live
30
- * document because there is no draft to land on. Default `false`.
35
+ * Expose `patchDocument`, `createDocument` and `validateDocument`, and how
36
+ * far those writes reach. Default `false`.
31
37
  */
32
- allowLiveWrites?: boolean;
38
+ write?: McpxWriteMode;
33
39
  }
34
40
  /**
35
41
  * What an exposed global offers to MCP clients. Structurally the same as
@@ -41,15 +47,10 @@ interface McpxGlobalOptions {
41
47
  /** Expose `describeSchema` and `getDocument`. Default `true`. */
42
48
  read?: boolean;
43
49
  /**
44
- * Expose `patchDocument` and `validateDocument`. Default `false`. Requires
45
- * `versions.drafts` unless `allowLiveWrites` is set.
50
+ * Expose `patchDocument` and `validateDocument`, and how far those writes
51
+ * reach. Default `false`.
46
52
  */
47
- write?: boolean;
48
- /**
49
- * Permit writes to a global without drafts. Such writes land on the live
50
- * document because there is no draft to land on. Default `false`.
51
- */
52
- allowLiveWrites?: boolean;
53
+ write?: McpxWriteMode;
53
54
  }
54
55
  type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
55
56
  /**
@@ -59,8 +60,7 @@ type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
59
60
  interface McpxExposedEntity {
60
61
  slug: string;
61
62
  read: boolean;
62
- write: boolean;
63
- allowLiveWrites: boolean;
63
+ write: McpxWriteMode;
64
64
  hasDrafts: boolean;
65
65
  /** Name of the capability group on the key document. */
66
66
  fieldName: string;
@@ -72,12 +72,14 @@ interface McpxExposedEntity {
72
72
  interface McpxToolScope {
73
73
  req: PayloadRequest;
74
74
  capabilities: McpxResolvedCapabilities;
75
- /** Collection slugs the key may read / write. */
75
+ /** Collection slugs the key may read / write / publish. */
76
76
  readable: string[];
77
77
  writable: string[];
78
- /** Global slugs the key may read / write. */
78
+ publishable: string[];
79
+ /** Global slugs the key may read / write / publish. */
79
80
  readableGlobals: string[];
80
81
  writableGlobals: string[];
82
+ publishableGlobals: string[];
81
83
  /** Configured locale codes, or `null` when localization is off. */
82
84
  locales: null | string[];
83
85
  defaultLocale: null | string;
@@ -214,6 +216,11 @@ type McpxPluginOptions = {
214
216
  interface McpxCollectionCapabilities {
215
217
  read: boolean;
216
218
  write: boolean;
219
+ /**
220
+ * Whether the key may publish this entity's draft. Only ever true where the
221
+ * config sets `write: "live"` and the entity has drafts.
222
+ */
223
+ publish: boolean;
217
224
  }
218
225
  /**
219
226
  * Capabilities in force for one request: plugin config AND key checkboxes.
@@ -238,4 +245,4 @@ interface PublishBlocker {
238
245
  path: string;
239
246
  }
240
247
  //#endregion
241
- export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool };
248
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
@@ -1,10 +1,11 @@
1
+ import { claimPublishIntent, isClaimedPublish } from "./publish-intent.mjs";
1
2
  import { isMcpxRequest } from "../request.mjs";
2
3
  import { APIError } from "payload";
3
4
  import { hasDraftsEnabled } from "payload/shared";
4
5
  //#region src/write/draft-guard.ts
5
6
  /**
6
7
  * Operation arguments that widen or redirect a write. Cleared on every MCP
7
- * create and update so a tool cannot smuggle them in.
8
+ * create and update, publishes included, so a tool cannot smuggle them in.
8
9
  */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
9
10
  "where",
10
11
  "publishAllLocales",
@@ -15,75 +16,113 @@ import { hasDraftsEnabled } from "payload/shared";
15
16
  "overwriteExistingFiles"
16
17
  ]);
17
18
  /**
18
- * Forces every MCP write into a draft save.
19
+ * Forces every MCP write into a draft save, unless it is the one write
20
+ * `publishDocument` asked for.
19
21
  *
20
22
  * `draft` alone is not enough: Payload's update path only saves a draft when
21
23
  * `data._status !== "published"`, so `_status` is dropped and left to Payload.
22
24
  * This runs as `beforeOperation`, before Payload reads any of these arguments,
23
25
  * so it holds for every create and update on an MCP request, not only the
24
26
  * builtin tools. Deletes are not guarded in v1; custom tools that delete are
25
- * the integrator's responsibility.
26
- */ const scrubWriteArgs = (args) => {
27
+ * the integrator's responsibility. `restoreVersion` and `duplicate` are outside
28
+ * the operation filter too `restoreVersion` is caught by `refusePublish`
29
+ * because it runs the collection's `beforeChange` hooks, and anything going
30
+ * straight to `payload.db` bypasses all of this.
31
+ *
32
+ * On a claimed publish the argument scrubbing is unchanged — the whole
33
+ * `STRIPPED_ARGS` list still goes, `deletedAt` still goes, autosave, locks and
34
+ * trash are still forced off. Only `draft` and `_status` differ. Writing
35
+ * `_status` here rather than in the tool keeps the tool honest: it asks to
36
+ * publish, and this is the only thing that can grant it.
37
+ */ const scrubWriteArgs = (args, publishing) => {
27
38
  const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
28
39
  if (next["data"] && typeof next["data"] === "object") {
29
40
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
30
- next["data"] = data;
41
+ next["data"] = publishing ? {
42
+ ...data,
43
+ _status: "published"
44
+ } : data;
31
45
  }
32
- next["draft"] = true;
46
+ next["draft"] = !publishing;
33
47
  next["autosave"] = false;
34
48
  next["overrideLock"] = false;
35
49
  next["trash"] = false;
36
50
  return next;
37
51
  };
38
52
  const forceDraftWrite = (hookArgs) => {
39
- const { args, operation, req } = hookArgs;
53
+ const { args, collection, operation, req } = hookArgs;
40
54
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
41
- return scrubWriteArgs(args);
55
+ const publishing = operation === "update" && claimPublishIntent({
56
+ kind: "collection",
57
+ slug: collection.slug,
58
+ id: args.id
59
+ });
60
+ return scrubWriteArgs(args, publishing);
42
61
  };
43
62
  /**
44
- * The global counterpart of {@link forceDraftWrite}. Payload invokes a global's
45
- * `beforeOperation` with the whole argument bag and assigns the result back,
46
- * exactly as the collection path does and before it reads `draft`,
47
- * `publishAllLocales` or `data._status`, so the guard has the same reach here:
48
- * every MCP write to a global, builtin tool or custom.
63
+ * The global counterpart of {@link forceDraftWrite}, with one important
64
+ * difference: Payload's `updateGlobal` destructures `draft`,
65
+ * `publishAllLocales`, `publishSpecificLocale`, `unpublishAllLocales` and
66
+ * `overrideLock` *before* it runs `beforeOperation`, and re-reads only `data`
67
+ * afterwards. Setting those here is a no-op. What still lands is `data`, and
68
+ * that is what the global draft guarantee actually rests on: `_status` is
69
+ * stripped, so a rogue `updateGlobal({ draft: false })` reaches
70
+ * {@link refusePublishGlobal} with no status and is refused there. The alarm,
71
+ * not the correction, is load-bearing for globals.
72
+ *
73
+ * The publish branch matters for the same reason. `publishDocument` passes
74
+ * `draft: false` at the call site because the hook cannot, and this hook must
75
+ * put `_status` back rather than strip it.
49
76
  *
50
77
  * The global operation union has no `create` member because a global always
51
- * exists, so only `update` is intercepted. `STRIPPED_ARGS` covers the three
52
- * publish vectors `updateGlobal` accepts; the rest of the set does not exist on
53
- * that signature and filtering it is a harmless no-op. `slug` survives the
54
- * filter, so the operation still knows what it is updating.
78
+ * exists, so only `update` is intercepted.
55
79
  */ const forceDraftWriteGlobal = (hookArgs) => {
56
- const { operation, req } = hookArgs;
80
+ const { global, operation, req } = hookArgs;
57
81
  const args = hookArgs.args;
58
82
  if (!isMcpxRequest(req) || operation !== "update") return args;
59
- return scrubWriteArgs(args);
83
+ const publishing = claimPublishIntent({
84
+ kind: "global",
85
+ slug: global.slug
86
+ });
87
+ return scrubWriteArgs(args, publishing);
60
88
  };
61
89
  /**
62
- * Refuses an MCP write that would still not land as a draft. An alarm rather
63
- * than the guarantee: `forceDraftWrite` should make it unreachable. It throws
64
- * instead of correcting `_status` because Payload has already chosen the write
65
- * branch by the time a `beforeChange` hook runs.
66
- */ const refuseUnlessDraft = (req, slug, data) => {
90
+ * Refuses an MCP write that would still not land as a draft, and on the one
91
+ * operation that claimed a publish intent refuses anything that would not
92
+ * land as a publish. An alarm rather than the guarantee for collections, where
93
+ * `forceDraftWrite` should make it unreachable; the guarantee itself for
94
+ * globals, per {@link forceDraftWriteGlobal}. It throws instead of correcting
95
+ * `_status` because Payload has already chosen the write branch by the time a
96
+ * `beforeChange` hook runs.
97
+ */ const refuseUnlessExpected = (req, target, data) => {
67
98
  if (!isMcpxRequest(req)) return;
68
99
  const status = data._status;
69
- if (status === "draft") return;
70
- req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a draft (_status: ${String(status)}).`);
71
- throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
100
+ const publishing = isClaimedPublish(target.kind, target.slug);
101
+ const expected = publishing ? "published" : "draft";
102
+ if (status === expected) return;
103
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${target.slug} that would not have been a ${expected} (_status: ${String(status)}).`);
104
+ throw new APIError(publishing ? "This publish was refused because it would not have saved a published document." : "MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
72
105
  };
73
106
  const refusePublish = ({ collection, data, req }) => {
74
- refuseUnlessDraft(req, collection.slug, data);
107
+ refuseUnlessExpected(req, {
108
+ kind: "collection",
109
+ slug: collection.slug
110
+ }, data);
75
111
  return data;
76
112
  };
77
113
  /** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
78
114
  const next = data;
79
- refuseUnlessDraft(req, global.slug, next);
115
+ refuseUnlessExpected(req, {
116
+ kind: "global",
117
+ slug: global.slug
118
+ }, next);
80
119
  return next;
81
120
  };
82
121
  /**
83
122
  * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
84
123
  * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
85
124
  * exist. Applied to the built collection list so nothing can join later
86
- * without being covered.
125
+ * without being covered. Both are appended last, so a user hook cannot win.
87
126
  */ const installDraftGuards = (collections) => collections.map((collection) => ({
88
127
  ...collection,
89
128
  hooks: {
@@ -155,7 +155,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
155
155
  const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
156
156
  if (pointers.includes("")) return ["an empty pointer addresses the whole document. Address a field instead."];
157
157
  const reserved = pointers.find(isReservedPointer);
158
- if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
158
+ if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. This tool only ever writes drafts, and id, _status, createdAt and updatedAt are not writable; use publishDocument to publish.`];
159
159
  const dropped = droppedPointer(operation);
160
160
  if (dropped !== void 0 && !isElementPointer(dropped)) return [`"${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
161
161
  try {
@@ -0,0 +1,39 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/write/publish-intent.ts
3
+ const store = new AsyncLocalStorage();
4
+ /** Runs `fn` with `intent` in force. */ const withPublishIntent = async (intent, fn) => await store.run({
5
+ ...intent,
6
+ claimed: false
7
+ }, fn);
8
+ const activeFor = (target) => {
9
+ const active = store.getStore();
10
+ return active && active.kind === target.kind && active.slug === target.slug && active.id === target.id ? active : void 0;
11
+ };
12
+ /**
13
+ * Claims the intent for one operation, which `beforeOperation` does so that a
14
+ * re-entrant write to the same document — an `afterChange` hook calling
15
+ * `payload.update`, say — cannot ride along on it. Only the first operation to
16
+ * ask gets it.
17
+ */ const claimPublishIntent = (target) => {
18
+ const active = activeFor(target);
19
+ if (!active || active.claimed) return false;
20
+ active.claimed = true;
21
+ return true;
22
+ };
23
+ /**
24
+ * Whether this change belongs to the operation that claimed the intent, which
25
+ * is what lets the `beforeChange` alarm accept a published status.
26
+ *
27
+ * The id is deliberately not compared here. A `beforeChange` hook reads it from
28
+ * the loaded document, where Payload has already coerced it to the collection's
29
+ * id type, while the claim above saw the raw tool argument; comparing the two
30
+ * would refuse a legitimate publish over `1` versus `"1"`. Nothing is lost: a
31
+ * nested write to another document of the same collection during the publish
32
+ * cannot claim the intent, so `forceDraftWrite` has already stripped its
33
+ * `_status` and it fails the alarm on that.
34
+ */ const isClaimedPublish = (kind, slug) => {
35
+ const active = store.getStore();
36
+ return active?.kind === kind && active.slug === slug && active.claimed;
37
+ };
38
+ //#endregion
39
+ export { claimPublishIntent, isClaimedPublish, withPublishIntent };
@@ -2,8 +2,14 @@ import { commitTransaction, initTransaction, killTransaction } from "payload";
2
2
  //#region src/write/transaction.ts
3
3
  /**
4
4
  * Runs `fn` inside one database transaction on `req`, so a read followed by a
5
- * write cannot interleave with another writer. Adapters without transaction
5
+ * write is committed or rolled back together. Adapters without transaction
6
6
  * support, or a request that already owns one, run `fn` as is.
7
+ *
8
+ * Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
9
+ * the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
10
+ * is this safe across the tool calls of one JSON-RPC batch, which share a
11
+ * request: the second caller joins the first's transaction, so one tool's
12
+ * rollback takes the other's work with it.
7
13
  */ const withTransaction = async (req, fn) => {
8
14
  if (!await initTransaction(req)) return await fn();
9
15
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.12",
4
+ "version": "1.0.0-beta.13",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",