@abinnovision/payloadcms-mcpx 1.0.0-beta.11 → 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.
Files changed (42) hide show
  1. package/README.md +103 -44
  2. package/dist/api-keys/fields.mjs +17 -4
  3. package/dist/capabilities.mjs +22 -3
  4. package/dist/endpoint/{result.mjs → errors.mjs} +4 -21
  5. package/dist/endpoint/handler.mjs +4 -2
  6. package/dist/endpoint/index.mjs +4 -0
  7. package/dist/endpoint/server.mjs +10 -5
  8. package/dist/index.d.mts +4 -5
  9. package/dist/index.mjs +2 -2
  10. package/dist/options.mjs +26 -9
  11. package/dist/plugin.mjs +1 -0
  12. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  13. package/dist/request.mjs +8 -0
  14. package/dist/{endpoint/result.d.mts → result.d.mts} +1 -2
  15. package/dist/result.mjs +22 -0
  16. package/dist/schema/index.mjs +6 -0
  17. package/dist/schema/pointer.mjs +1 -1
  18. package/dist/schema/walk.mjs +11 -15
  19. package/dist/tools/{index.mjs → builtin.mjs} +4 -2
  20. package/dist/tools/create-document.mjs +14 -9
  21. package/dist/tools/describe-schema.mjs +3 -3
  22. package/dist/tools/find-documents.mjs +1 -2
  23. package/dist/tools/get-document.mjs +2 -2
  24. package/dist/tools/list-capabilities.mjs +3 -2
  25. package/dist/tools/names.mjs +2 -1
  26. package/dist/tools/patch-document.mjs +14 -15
  27. package/dist/tools/publish-document.mjs +81 -0
  28. package/dist/tools/shared.mjs +50 -5
  29. package/dist/tools/target.mjs +4 -4
  30. package/dist/tools/validate-document.mjs +8 -9
  31. package/dist/types.d.mts +44 -23
  32. package/dist/write/draft-guard.mjs +71 -36
  33. package/dist/write/patch.mjs +131 -57
  34. package/dist/write/publish-blockers.mjs +10 -3
  35. package/dist/write/publish-intent.mjs +39 -0
  36. package/dist/write/transaction.mjs +7 -1
  37. package/package.json +1 -1
  38. package/dist/i18n.d.mts +0 -1
  39. package/dist/schema/lexical.d.mts +0 -1
  40. package/dist/schema/walk.d.mts +0 -3
  41. package/dist/tools/target.d.mts +0 -3
  42. package/dist/write/publish-blockers.d.mts +0 -15
package/README.md CHANGED
@@ -13,12 +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 always land as drafts; publishing stays a
17
- human action in the admin panel. Every write returns the publish
18
- blockers: the validation failures that still prevent a human from publishing
19
- the draft. Capabilities are declared twice: the plugin config decides what
20
- can exist, a checkbox on each API key decides what does, and a missing checkbox
21
- 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).
22
24
 
23
25
  ## Install
24
26
 
@@ -43,12 +45,12 @@ export default buildConfig({
43
45
  plugins: [
44
46
  mcpxPlugin({
45
47
  collections: {
46
- pages: { read: true, write: true },
47
- posts: { read: true, write: true },
48
+ pages: { read: true, write: "live" }, // may be published through MCP
49
+ posts: { read: true, write: "draft" }, // drafts only
48
50
  tags: true, // shorthand for { read: true }
49
51
  },
50
52
  globals: {
51
- "site-settings": { read: true, write: true },
53
+ "site-settings": { read: true, write: "draft" },
52
54
  },
53
55
  limits: { maxLimit: 25, maxDepth: 1 },
54
56
  }),
@@ -77,7 +79,10 @@ to anyone who may read the key document (own keys only, by default). Each key:
77
79
  - carries one checkbox per exposed collection and operation, plus one per
78
80
  custom tool. All checkboxes default to off. A key can never enable an
79
81
  operation the plugin config does not expose, and keys created before a
80
- 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.
81
86
 
82
87
  Keys authenticate only the MCP endpoint. They are deliberately not a Payload
83
88
  auth strategy, so a key can never authenticate the REST or GraphQL API; the
@@ -140,7 +145,7 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
140
145
 
141
146
  ## Tools
142
147
 
143
- 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
144
149
  adds an argument, never a tool. `tools/list` reflects the key: write tools
145
150
  disappear for read-only keys, and every `collection` and `global` enum contains
146
151
  only the slugs the key may touch.
@@ -153,7 +158,8 @@ only the slugs the key may touch.
153
158
  | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
154
159
  | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
155
160
  | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
156
- | `validateDocument` | Publish blockers without writing. | `collection` + `id` \| `global`, `locale` |
161
+ | `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
162
+ | `publishDocument` | Publish the current draft. | `collection` + `id` \| `global`, `expectedUpdatedAt?` |
157
163
 
158
164
  Rules the tools enforce and explain in their own descriptions:
159
165
 
@@ -244,15 +250,40 @@ If `tools/list` omits `global` entirely, no global is exposed to that key; the
244
250
  argument only appears once one is. A deployment that uses no globals sees the
245
251
  tool schemas exactly as they were.
246
252
 
247
- ## Drafts and publish blockers
253
+ ## Drafts and publishing
248
254
 
249
255
  Draft-only writing is enforced on the Payload operation, not in the tool
250
256
  handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
251
257
  from every write carrying the MCP request marker, so custom tools and anything
252
258
  else writing through the same request are covered too. A `beforeChange` hook
253
- refuses any write that would still not land as a draft. Globals expose the same
254
- `beforeOperation` interception point at the same position in the operation, so
255
- 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.
256
287
 
257
288
  Publish blockers are advisory. Payload skips validation on draft saves (unless
258
289
  `versions.drafts.validate` is set), so after every write the plugin re-runs
@@ -266,6 +297,12 @@ Collections with `versions.drafts.validate: true` refuse invalid drafts
266
297
  outright; those failures come back as `validationErrors`. Both carry pointers,
267
298
  restated from the dotted paths Payload reports internally.
268
299
 
300
+ `publishBlockersUnavailable` marks a check that could not complete, which is
301
+ not the same answer as a document with nothing wrong with it. `validateDocument`
302
+ runs the same traversal without saving anything, so it is not free of side
303
+ effects: field `beforeValidate` and `beforeChange` hooks run, and it carries no
304
+ `readOnlyHint` for that reason.
305
+
269
306
  Writes also report `notApplied`: pointers whose value Payload kept unchanged,
270
307
  which happens when field-level access denies the update.
271
308
 
@@ -301,9 +338,9 @@ Custom tools take the same route as the builtins: one `McpxTool` shape, one
301
338
  registration loop. Anything a builtin does, a custom tool can do.
302
339
 
303
340
  `handler` receives `scope` alongside `args`, `req` and `extra`. The scope
304
- carries what the key may touch (`readable`, `writable`, `readableGlobals`,
305
- `writableGlobals`), the configured locales, the limits in force and the
306
- 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`.
307
344
 
308
345
  `inputSchema` may be a function of that scope instead of a fixed shape, which
309
346
  is how a tool narrows an enum to what the key may read:
@@ -356,30 +393,48 @@ results shaped like a builtin's.
356
393
 
357
394
  ## Options
358
395
 
359
- | Option | Default | Description |
360
- | ------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
361
- | `collections` | required | Allow-list. `true` means `{ read: true }`. |
362
- | `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
363
- | `collections.<slug>.write` | `false` | Expose `patchDocument`, `createDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
364
- | `collections.<slug>.allowLiveWrites` | `false` | Permit writes to a collection without drafts (they land live). |
365
- | `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
366
- | `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
367
- | `globals.<slug>.write` | `false` | Expose `patchDocument`, `validateDocument`. Requires `versions.drafts` unless `allowLiveWrites`. |
368
- | `globals.<slug>.allowLiveWrites` | `false` | Permit writes to a global without drafts (they land live). |
369
- | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
370
- | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
371
- | `apiKeys.setupGuide` | `true` | Add a "Connect a client" tab to saved keys. Needs the import map. |
372
- | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
373
- | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
374
- | `limits.maxLimit` | `25` | Upper bound for `findDocuments.limit`. |
375
- | `limits.maxDepth` | `1` | Upper bound for `depth` on reads. |
376
- | `tools` | `[]` | Custom tools, defined the same way as the builtins. |
377
- | `auth.resolve` | none | Replace or wrap the default key resolution. |
378
- | `serverInfo` | package name and version | Reported to MCP clients. |
379
-
380
- Misconfiguration (unknown slugs, write on a collection without drafts, upload
381
- collections exposed for write, tool name collisions) fails at startup with
382
- `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
383
438
  included: their documents carry credentials, such as the decrypted Payload API
384
439
  key of every user.
385
440
 
@@ -390,12 +445,16 @@ key of every user.
390
445
  - The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
391
446
  ignored. Keys cannot authenticate REST or GraphQL.
392
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.
393
451
  - Not covered in v1: `delete` (no tool exists and none is generated), uploads.
394
452
  Custom tools are trusted code and can do what the linked user may.
395
453
 
396
454
  ## Non-goals of v1 / roadmap
397
455
 
398
- 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
399
458
  by position in a patch (an editor state is written whole), schemas for `upload`
400
459
  node fields, row addressing by id instead of index, cross-locale publish
401
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,25 +89,38 @@ 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
- const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
123
+ const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
111
124
  const groups = [
112
125
  ...collectionGroups.length > 0 ? [{
113
126
  name: "collections",
@@ -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,6 +1,8 @@
1
+ import { errorResult } from "../result.mjs";
1
2
  import { pointerFromPayloadPath } from "../schema/walk.mjs";
3
+ import "../schema/index.mjs";
2
4
  import { APIError, ValidationError } from "payload";
3
- //#region src/endpoint/result.ts
5
+ //#region src/endpoint/errors.ts
4
6
  /**
5
7
  * A JSON-RPC error response for failures that happen before the MCP server
6
8
  * is involved (auth, method, body parsing).
@@ -20,25 +22,6 @@ import { APIError, ValidationError } from "payload";
20
22
  });
21
23
  };
22
24
  /**
23
- * A successful tool result carrying `value` as JSON text.
24
- */ const jsonResult = (value) => ({ content: [{
25
- type: "text",
26
- text: JSON.stringify(value)
27
- }] });
28
- /**
29
- * A failed tool result. `extras` travel alongside the message so the client
30
- * can act on them (problems, validation errors, the current `updatedAt`).
31
- */ const errorResult = (message, extras = {}) => ({
32
- content: [{
33
- type: "text",
34
- text: JSON.stringify({
35
- error: message,
36
- ...extras
37
- })
38
- }],
39
- isError: true
40
- });
41
- /**
42
25
  * Maps an exception thrown by a tool to a result the client can read.
43
26
  *
44
27
  * Payload's public errors keep their message and status; a `ValidationError`
@@ -62,4 +45,4 @@ import { APIError, ValidationError } from "payload";
62
45
  return errorResult("Internal error");
63
46
  };
64
47
  //#endregion
65
- export { errorResult, jsonResult, jsonRpcError, toToolError };
48
+ export { jsonRpcError, toToolError };
@@ -1,5 +1,5 @@
1
- import { jsonRpcError } from "./result.mjs";
2
- import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
1
+ import { publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
2
+ import { jsonRpcError } from "./errors.mjs";
3
3
  import { resolveApiKeyAuth } from "../auth/resolve.mjs";
4
4
  import { createMcpServer } from "./server.mjs";
5
5
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -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,
@@ -0,0 +1,4 @@
1
+ import { jsonRpcError, toToolError } from "./errors.mjs";
2
+ import { createMcpServer, isToolEnabled, toolDescription, toolInputSchema } from "./server.mjs";
3
+ import { createMcpxHandler, methodNotAllowed } from "./handler.mjs";
4
+ export { createMcpServer, createMcpxHandler, isToolEnabled, jsonRpcError, methodNotAllowed, toToolError, toolDescription, toolInputSchema };
@@ -1,5 +1,6 @@
1
- import { toToolError } from "./result.mjs";
2
- import { BUILTIN_TOOLS } from "../tools/index.mjs";
1
+ import { toToolError } from "./errors.mjs";
2
+ import { draftSentence } from "../tools/shared.mjs";
3
+ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { z } from "zod";
5
6
  //#region src/endpoint/server.ts
@@ -10,6 +11,10 @@ import { z } from "zod";
10
11
  * scope to narrow enums to what the key may touch.
11
12
  */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
12
13
  /**
14
+ * A tool's description, which may be built from the scope so it can name the
15
+ * targets this key writes live.
16
+ */ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
17
+ /**
13
18
  * Whether the key may call the tool. A tool that does not decide for itself is
14
19
  * gated by its own checkbox on the key, which is how the tools from
15
20
  * `options.tools` work; the builtins derive it from the key's collection and
@@ -26,7 +31,7 @@ import { z } from "zod";
26
31
  const server = new McpServer({
27
32
  name: options.serverInfo.name,
28
33
  version: options.serverInfo.version
29
- }, { instructions: "Start with listCapabilities, then describeSchema for the collection or global you work on. Writes always land as drafts; a human publishes." });
34
+ }, { instructions: `Start with listCapabilities, then describeSchema for the collection or global you work on. ${draftSentence(scope)}` });
30
35
  const guarded = (run) => async () => {
31
36
  try {
32
37
  return await run();
@@ -37,7 +42,7 @@ import { z } from "zod";
37
42
  for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
38
43
  if (!isToolEnabled(tool, scope)) continue;
39
44
  server.registerTool(tool.name, {
40
- description: tool.description,
45
+ description: toolDescription(tool, scope),
41
46
  inputSchema: toolInputSchema(tool, scope),
42
47
  ...tool.annotations ? { annotations: tool.annotations } : {}
43
48
  }, (args, extra) => guarded(() => tool.handler({
@@ -50,4 +55,4 @@ import { z } from "zod";
50
55
  return server;
51
56
  };
52
57
  //#endregion
53
- export { createMcpServer, isToolEnabled, toolInputSchema };
58
+ export { createMcpServer, isToolEnabled, toolDescription, toolInputSchema };
package/dist/index.d.mts CHANGED
@@ -1,6 +1,5 @@
1
- import { errorResult, jsonResult } from "./endpoint/result.mjs";
2
- import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool } from "./types.mjs";
1
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool } from "./types.mjs";
3
2
  import { mcpxPlugin } from "./plugin.mjs";
4
- import { isMcpxRequest } from "./write/draft-guard.mjs";
5
- import { PublishBlocker } from "./write/publish-blockers.mjs";
6
- export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type McpxToolScope, type PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
3
+ import { isMcpxRequest } from "./request.mjs";
4
+ import { errorResult, jsonResult } from "./result.mjs";
5
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { errorResult, jsonResult } from "./endpoint/result.mjs";
1
+ import { errorResult, jsonResult } from "./result.mjs";
2
2
  import { defineMcpxTool } from "./types.mjs";
3
- import { isMcpxRequest } from "./write/draft-guard.mjs";
3
+ import { isMcpxRequest } from "./request.mjs";
4
4
  import { mcpxPlugin } from "./plugin.mjs";
5
5
  export { 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];
package/dist/plugin.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
+ import "./endpoint/index.mjs";
3
4
  import { normalizeOptions } from "./options.mjs";
4
5
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
6
  import { definePlugin } from "payload";
@@ -1,5 +1,5 @@
1
- import { CollectionConfig, PayloadRequest } from "payload";
2
- //#region src/write/draft-guard.d.ts
1
+ import { PayloadRequest } from "payload";
2
+ //#region src/request.d.ts
3
3
  /**
4
4
  * Whether a request originated from the MCP endpoint. The endpoint stamps
5
5
  * `req.context.mcpx`, which travels into every local API call made with the
@@ -0,0 +1,8 @@
1
+ //#region src/request.ts
2
+ /**
3
+ * Whether a request originated from the MCP endpoint. The endpoint stamps
4
+ * `req.context.mcpx`, which travels into every local API call made with the
5
+ * same `req`, including those made by custom tools.
6
+ */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
7
+ //#endregion
8
+ export { isMcpxRequest };
@@ -1,6 +1,5 @@
1
- import "payload";
2
1
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
- //#region src/endpoint/result.d.ts
2
+ //#region src/result.d.ts
4
3
  /**
5
4
  * A successful tool result carrying `value` as JSON text.
6
5
  */
@@ -0,0 +1,22 @@
1
+ //#region src/result.ts
2
+ /**
3
+ * A successful tool result carrying `value` as JSON text.
4
+ */ const jsonResult = (value) => ({ content: [{
5
+ type: "text",
6
+ text: JSON.stringify(value)
7
+ }] });
8
+ /**
9
+ * A failed tool result. `extras` travel alongside the message so the client
10
+ * can act on them (problems, validation errors, the current `updatedAt`).
11
+ */ const errorResult = (message, extras = {}) => ({
12
+ content: [{
13
+ type: "text",
14
+ text: JSON.stringify({
15
+ error: message,
16
+ ...extras
17
+ })
18
+ }],
19
+ isError: true
20
+ });
21
+ //#endregion
22
+ export { errorResult, jsonResult };
@@ -0,0 +1,6 @@
1
+ import { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
3
+ import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
4
+ import { resolveDataPointer } from "./pointer.mjs";
5
+ import { validateWriteValue } from "./shape.mjs";
6
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, allowedNodeTypes, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };