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

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
@@ -1,27 +1,40 @@
1
1
  # @abinnovision/payloadcms-mcpx
2
2
 
3
3
  A Payload CMS plugin that mounts an MCP (Model Context Protocol) server whose
4
- tool surface stays small and accurate regardless of the size of the
5
- content model.
4
+ tool surface stays small and accurate regardless of the size of the content
5
+ model.
6
6
 
7
7
  Instead of generating one tool per collection with the full document schema
8
- inlined, the plugin types its surface in three layers. The tool signatures are
9
- small and static: collection slugs, locales and operations as enums, everything
10
- else scalars. The field shapes are pulled on demand through `describeSchema`,
11
- one node at a time, stopping at every blocks boundary. And every write is
12
- resolved server-side against the real config and the real document, so unknown
13
- fields, misplaced blocks and unusable rich text nodes or node fields are refused with the
14
- valid alternatives listed, never silently dropped.
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).
23
-
24
- ## Install
8
+ inlined, the plugin types its surface in three layers. Tool signatures are small
9
+ and static: collection slugs, locales and operations as enums, everything else
10
+ scalars. Field shapes are pulled on demand through `describeSchema`, one node at
11
+ a time, stopping at every blocks boundary. And every write is resolved
12
+ server-side against the real config and the real document, so an unknown field,
13
+ a misplaced block or an unusable rich text node comes back refused, with the
14
+ valid alternatives listed rather than quietly dropped.
15
+
16
+ Writes are RFC 6902 patches that land as drafts, and every write returns the
17
+ publish blockers still standing between that draft and a publish. One config
18
+ axis decides how far a write reaches. `write: "draft"` never changes live
19
+ content. `write: "live"` does, by exposing `publishDocument` where versions
20
+ exist and by permitting the write at all where they do not. Capabilities are
21
+ declared twice: the plugin config decides what can exist, a checkbox on each API
22
+ key decides what does, and a missing checkbox reads as no (fail-closed).
23
+
24
+ ## Contents
25
+
26
+ - [Quick start](#quick-start)
27
+ - [Configuration](#configuration)
28
+ - [Tools](#tools)
29
+ - [Globals](#globals)
30
+ - [API keys](#api-keys)
31
+ - [Drafts and publishing](#drafts-and-publishing)
32
+ - [Custom tools](#custom-tools)
33
+ - [How it is enforced](#how-it-is-enforced)
34
+ - [Security notes](#security-notes)
35
+ - [Non-goals of v1 / roadmap](#non-goals-of-v1--roadmap)
36
+
37
+ ## Quick start
25
38
 
26
39
  ```bash
27
40
  yarn add @abinnovision/payloadcms-mcpx
@@ -33,7 +46,8 @@ yarn add @abinnovision/payloadcms-mcpx
33
46
  `apiKeys.setupGuide: false`.
34
47
  - The package is published as ESM only, matching Payload itself.
35
48
 
36
- ## Usage
49
+ Add the plugin and name the collections and globals it may reach. Nothing is
50
+ exposed that is not listed here:
37
51
 
38
52
  ```ts
39
53
  import { mcpxPlugin } from "@abinnovision/payloadcms-mcpx";
@@ -44,12 +58,12 @@ export default buildConfig({
44
58
  plugins: [
45
59
  mcpxPlugin({
46
60
  collections: {
47
- pages: { read: true, write: true },
48
- posts: { read: true, write: true },
61
+ pages: { read: true, write: "live" }, // may be published through MCP
62
+ posts: { read: true, write: "draft" }, // drafts only
49
63
  tags: true, // shorthand for { read: true }
50
64
  },
51
65
  globals: {
52
- "site-settings": { read: true, write: true },
66
+ "site-settings": { read: true, write: "draft" },
53
67
  },
54
68
  limits: { maxLimit: 25, maxDepth: 1 },
55
69
  }),
@@ -66,46 +80,12 @@ The plugin adds:
66
80
  - a draft guard on every collection and global, so any write carrying the MCP
67
81
  request marker lands as a draft, including writes made by custom tools.
68
82
 
69
- ## API keys
70
-
71
- Keys are created in the admin panel under MCP > API Keys. The plaintext key is
72
- generated on create, stored encrypted with an HMAC index for lookup, and shown
73
- to anyone who may read the key document (own keys only, by default). Each key:
74
-
75
- - is bound to the user who created it and acts as that user: every operation
76
- runs with `req.user` set to the linked user and `overrideAccess: false`, so
77
- your collection access control applies unchanged;
78
- - carries one checkbox per exposed collection and operation, plus one per
79
- custom tool. All checkboxes default to off. A key can never enable an
80
- operation the plugin config does not expose, and keys created before a
81
- capability existed stay without it.
82
-
83
- Keys authenticate only the MCP endpoint. They are deliberately not a Payload
84
- auth strategy, so a key can never authenticate the REST or GraphQL API; the
85
- reverse also holds: an admin session or JWT is ignored by the MCP endpoint.
86
-
87
- Use `apiKeys.overrideCollection` to widen access (for example, admins manage
88
- all keys) or add fields.
89
-
90
- ## Connecting a client
91
-
92
- Saved keys carry a **Connect a client** tab in the admin holding these same
93
- instructions with their own URL and key filled in, each block behind a copy
94
- button. The tab only exists once the key does, so the create form stays free of
95
- it. Turn it off with `apiKeys.setupGuide: false`, which also drops the tabs and
96
- restores the flat form.
97
-
98
- The tab renders an admin component, so it has to be in the import map:
99
-
100
- ```bash
101
- payload generate:importmap
102
- ```
83
+ Then create a key in the admin panel under MCP > API Keys, tick the capabilities
84
+ it should have, and copy the plaintext key shown after saving. Checkboxes
85
+ default to off, so a fresh key can do nothing until you say otherwise. See
86
+ [API keys](#api-keys) for what a key is and is not.
103
87
 
104
- Without that entry Payload logs a missing-component error and renders nothing
105
- else; the rest of the plugin is unaffected. The URL comes from `serverURL` when
106
- the config sets one and from the browser's origin otherwise.
107
-
108
- The endpoint speaks streamable HTTP with `Authorization: Bearer <key>`:
88
+ Then point a client at the endpoint, passing the key as a bearer token:
109
89
 
110
90
  ```bash
111
91
  npx @modelcontextprotocol/inspector
@@ -139,22 +119,79 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
139
119
  }
140
120
  ```
141
121
 
122
+ ## Configuration
123
+
124
+ | Option | Default | Description |
125
+ | ---------------------------- | ------------------------------ | ----------------------------------------------------------------- |
126
+ | `collections` | required | Allow-list. `true` means `{ read: true }`. |
127
+ | `collections.<slug>.read` | `true` | Expose `describeSchema`, `findDocuments`, `getDocument`. |
128
+ | `collections.<slug>.write` | `false` | `"draft"` or `"live"`. See below. |
129
+ | `globals` | `{}` | Allow-list of globals. `true` means `{ read: true }`. |
130
+ | `globals.<slug>.read` | `true` | Expose `describeSchema`, `getDocument`. |
131
+ | `globals.<slug>.write` | `false` | `"draft"` or `"live"`. See below. |
132
+ | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
133
+ | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
134
+ | `apiKeys.setupGuide` | `true` | Add a "Connect a client" tab to saved keys. Needs the import map. |
135
+ | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
136
+ | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
137
+ | `limits.maxLimit` | `25` | Upper bound for `findDocuments.limit`. |
138
+ | `limits.maxDepth` | `1` | Upper bound for `depth` on reads. |
139
+ | `tools` | `[]` | Custom tools, defined the same way as the builtins. |
140
+ | `auth.resolve` | none | Replace or wrap the default key resolution. |
141
+ | `serverInfo` | package name and version | Reported to MCP clients. |
142
+
143
+ `write` is one axis: how far MCP writes to this entity reach.
144
+
145
+ | `write` | With `versions.drafts` | Without |
146
+ | --------- | ------------------------------------------------------- | ---------------------------------------------- |
147
+ | `false` | no write tool reaches it | no write tool reaches it |
148
+ | `"draft"` | writes land as drafts, nothing is ever published | refused at startup: there is no draft to write |
149
+ | `"live"` | writes land as drafts, and `publishDocument` is exposed | writes land on the live document |
150
+
151
+ `"live"` is the only way an MCP write reaches live content, whichever of the two
152
+ shapes it takes. Wherever it is set, the server instructions and the
153
+ `patchDocument` and `createDocument` descriptions name those slugs for the key in
154
+ question, so a client is never told its writes are drafts while they are not,
155
+ nor that publishing is out of reach when it is not.
156
+
157
+ Migrating from the previous option shape: `write: true` becomes
158
+ `write: "draft"`, and `write: true` with `allowLiveWrites: true` becomes
159
+ `write: "live"`. A versioned entity moved to `write: "live"` gains a `publish`
160
+ checkbox on every key, unticked, so nothing publishes until someone says so.
161
+
162
+ An upload collection may be exposed for write. `patchDocument` and
163
+ `validateDocument` reach it, and `publishDocument` under the same `write:
164
+ "live"` rule as anywhere else, so an agent can edit the fields the collection
165
+ declares itself, such as `alt` or a credit. Its base fields (`filename`, `url`,
166
+ `filesize`, `sizes`, the focal point) are neither described nor writable, and
167
+ `createDocument` leaves the slug out of its `collection` enum and says why in
168
+ its description: a create there would have to carry the file, and no tool does.
169
+ Upload the file in the admin panel first.
170
+
171
+ Misconfiguration (unknown slugs, `write: "draft"` on a collection without
172
+ drafts, tool name collisions) fails at startup with `InvalidConfiguration`. So
173
+ does `write: "live"` on an entity using `versions.drafts.localizeStatus`, which
174
+ is not supported yet. Auth collections cannot be exposed at all, read included:
175
+ their documents carry credentials, such as the decrypted Payload API key of
176
+ every user.
177
+
142
178
  ## Tools
143
179
 
144
- The surface is fixed at seven tools plus your custom ones; exposing a global
180
+ The surface is fixed at eight tools plus your custom ones; exposing a global
145
181
  adds an argument, never a tool. `tools/list` reflects the key: write tools
146
182
  disappear for read-only keys, and every `collection` and `global` enum contains
147
183
  only the slugs the key may touch.
148
184
 
149
- | Tool | Purpose | Key arguments |
150
- | ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
151
- | `listCapabilities` | What this key may do; call first to orient. | none |
152
- | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
153
- | `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
154
- | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
155
- | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
156
- | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
157
- | `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
185
+ | Tool | Purpose | Key arguments |
186
+ | ------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
187
+ | `listCapabilities` | What this key may do, `create` apart from `write`; call first to orient. | none |
188
+ | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
189
+ | `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
190
+ | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
191
+ | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
192
+ | `createDocument` | Create a draft from a minimal seed. Not for upload collections. | `collection`, `locale`, `data` |
193
+ | `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
194
+ | `publishDocument` | Publish the current draft. | `collection` + `id` \| `global`, `expectedUpdatedAt?` |
158
195
 
159
196
  Rules the tools enforce and explain in their own descriptions:
160
197
 
@@ -171,6 +208,10 @@ Rules the tools enforce and explain in their own descriptions:
171
208
  guessed. Any feature declaring `getSubFields` is picked up, custom ones
172
209
  included. `upload` nodes are the exception: their fields depend on the
173
210
  collection the node points at, so they are not addressable.
211
+ - A field marked `admin.hidden` is not described and cannot be written. Payload
212
+ keeps such a field out of the admin panel only, and this is where the plugin
213
+ parts from it: kept from an editor means kept from a client. It is also what
214
+ keeps the base fields of an upload collection off the surface.
174
215
  - Constraints a field declares travel with it: `minRows`/`maxRows` on arrays
175
216
  and blocks fields, `maxLength`/`minLength` on text, `min`/`max` on numbers.
176
217
  An array is described in its own right, so the `*` in `/items/*/title` has
@@ -235,8 +276,8 @@ camelCase name with a collection. Keys issued before a global was exposed have
235
276
  no such group, and an absent checkbox reads as `false`, so they stay closed to
236
277
  every global until one is ticked.
237
278
 
238
- Globals always carry `updatedAt` Payload appends it and there is no
239
- `timestamps: false` for globals so `expectedUpdatedAt` behaves as it does for
279
+ Globals always carry `updatedAt`, because Payload appends it and there is no
280
+ `timestamps: false` for globals, so `expectedUpdatedAt` behaves as it does for
240
281
  collections. The one exception is a global that has never been saved: it has no
241
282
  `updatedAt` to compare against, so the first write must omit
242
283
  `expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
@@ -245,30 +286,78 @@ If `tools/list` omits `global` entirely, no global is exposed to that key; the
245
286
  argument only appears once one is. A deployment that uses no globals sees the
246
287
  tool schemas exactly as they were.
247
288
 
248
- ## Drafts and publish blockers
289
+ ## API keys
249
290
 
250
- Draft-only writing is enforced on the Payload operation, not in the tool
251
- handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
252
- from every write carrying the MCP request marker, so custom tools and anything
253
- 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.
291
+ Keys are created in the admin panel under MCP > API Keys. The plaintext key is
292
+ generated on create, stored encrypted with an HMAC index for lookup, and shown
293
+ to anyone who may read the key document (own keys only, by default). Each key:
294
+
295
+ - is bound to the user who created it and acts as that user: every operation
296
+ runs with `req.user` set to the linked user and `overrideAccess: false`, so
297
+ your collection access control applies unchanged;
298
+ - carries one checkbox per exposed collection and operation, plus one per
299
+ custom tool. All checkboxes default to off. A key can never enable an
300
+ operation the plugin config does not expose, and keys created before a
301
+ capability existed stay without it. The `publish` checkbox only exists where
302
+ a versioned entity is configured `write: "live"`, so a key issued before
303
+ publishing was possible stays closed to it, and it counts only alongside
304
+ `write`: publishing is an extension of writing, not a capability of its own.
305
+
306
+ Keys authenticate only the MCP endpoint. They are deliberately not a Payload
307
+ auth strategy, so a key can never authenticate the REST or GraphQL API; the
308
+ reverse also holds: an admin session or JWT is ignored by the MCP endpoint.
309
+
310
+ Use `apiKeys.overrideCollection` to widen access (for example, admins manage
311
+ all keys) or add fields.
312
+
313
+ ### The "Connect a client" tab
314
+
315
+ Saved keys carry a **Connect a client** tab in the admin holding the client
316
+ snippets from [Quick start](#quick-start) with their own URL and key filled in,
317
+ each block behind a copy button. The tab only exists once the key does, so the create form stays free of
318
+ it. Turn it off with `apiKeys.setupGuide: false`, which also drops the tabs and
319
+ restores the flat form.
320
+
321
+ The tab renders an admin component, so it has to be in the import map:
322
+
323
+ ```bash
324
+ payload generate:importmap
325
+ ```
326
+
327
+ Without that entry Payload logs a missing-component error and renders nothing
328
+ else; the rest of the plugin is unaffected. The URL comes from `serverURL` when
329
+ the config sets one and from the browser's origin otherwise.
330
+
331
+ ## Drafts and publishing
332
+
333
+ Every MCP write lands as a draft. That is enforced on the Payload operation
334
+ rather than in the tool handlers, so a custom tool writing through the same
335
+ request is covered as well; see [How it is enforced](#how-it-is-enforced) for
336
+ the mechanism.
337
+
338
+ `publishDocument` is the one way through. Publishing covers the whole document,
339
+ as the admin Publish button does, but Payload only validates the locale the
340
+ publish runs in. A required field left empty in another locale therefore goes
341
+ live empty. That is Payload's behaviour, not something this plugin adds.
342
+ `publishDocument` refuses a document that fails validation and reports
343
+ `validationErrors` with JSON Pointers. It is refused while a human holds the
344
+ document open in the admin panel, and republishing an unchanged document is
345
+ accepted but writes another version.
346
+
347
+ There is no unpublish tool. Reverting a published document to a draft stays a
348
+ human action.
257
349
 
258
350
  Publish blockers are advisory. Payload skips validation on draft saves (unless
259
351
  `versions.drafts.validate` is set), so after every write the plugin re-runs
260
- Payload's own field validation over the saved draft and returns the failures
261
- as `publishBlockers` with paths and labels. The write stands; the client gets a
262
- checklist of what remains. Three limits: only the written locale is
263
- validated; field `beforeChange` hooks run again during the check, so they must
264
- be pure; and the check runs privileged, so blocker paths and messages may name
265
- fields the key's user cannot read (values are never included).
266
- Collections with `versions.drafts.validate: true` refuse invalid drafts
267
- outright; those failures come back as `validationErrors`. Both carry pointers,
268
- restated from the dotted paths Payload reports internally.
269
-
270
- `publishBlockersUnavailable` marks a check that could not complete, which is
271
- not the same answer as a document with nothing wrong with it. `validateDocument`
352
+ Payload's own field validation over the saved draft and returns the failures as
353
+ `publishBlockers` with paths and labels. The write stands; the client gets a
354
+ checklist of what remains. Collections with `versions.drafts.validate: true`
355
+ refuse invalid drafts outright, and those failures come back as
356
+ `validationErrors` instead. Both carry pointers, restated from the dotted paths
357
+ Payload reports internally.
358
+
359
+ `publishBlockersUnavailable` marks a check that could not complete, which is not
360
+ the same answer as a document with nothing wrong with it. `validateDocument`
272
361
  runs the same traversal without saving anything, so it is not free of side
273
362
  effects: field `beforeValidate` and `beforeChange` hooks run, and it carries no
274
363
  `readOnlyHint` for that reason.
@@ -308,9 +397,9 @@ Custom tools take the same route as the builtins: one `McpxTool` shape, one
308
397
  registration loop. Anything a builtin does, a custom tool can do.
309
398
 
310
399
  `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`.
400
+ carries what the key may touch (`readable`, `writable`, `publishable`,
401
+ `readableGlobals`, `writableGlobals`, `publishableGlobals`), the configured
402
+ locales, the limits in force and the exposed collections and globals. `req` is shorthand for `scope.req`.
314
403
 
315
404
  `inputSchema` may be a function of that scope instead of a fixed shape, which
316
405
  is how a tool narrows an enum to what the key may read:
@@ -361,39 +450,39 @@ argument is rejected by name rather than stripped before the handler runs.
361
450
  `jsonResult` and `errorResult` are exported so a custom tool can return
362
451
  results shaped like a builtin's.
363
452
 
364
- ## Options
365
-
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
395
- included: their documents carry credentials, such as the decrypted Payload API
396
- key of every user.
453
+ ## How it is enforced
454
+
455
+ Draft-only writing sits on the Payload operation, not in the tool handlers. A
456
+ `beforeOperation` hook forces `draft: true` and strips `_status` from every
457
+ write carrying the MCP request marker, so custom tools and anything else writing
458
+ through the same request are covered too. A `beforeChange` hook then refuses any
459
+ write that would still not land as a draft.
460
+
461
+ The two hooks are not equally load-bearing on both sides. `updateGlobal` reads
462
+ `draft` and the publish arguments off its argument bag _before_ it runs
463
+ `beforeOperation`, and re-reads only `data` afterwards, so for a global the
464
+ correction cannot apply and the `beforeChange` refusal is what actually holds
465
+ the line. Both are installed on every collection and global, exposed or not.
466
+
467
+ `publishDocument` opens the door for exactly one write: the tool marks that
468
+ write's own `data` object, and the guard grants the publish only to a write
469
+ carrying the mark. Nothing is scoped to a slug or an id because nothing else can
470
+ reach it. A concurrent call in the same JSON-RPC batch has its own `data`, and
471
+ so does a nested write from a hook during the publish.
472
+
473
+ That distinction matters. The endpoint hands one `PayloadRequest` to every tool,
474
+ and the transport dispatches the messages of a batch without awaiting each one,
475
+ so an intent kept on the request would be reachable by a sibling `patchDocument`
476
+ and would publish it instead. The mark is a string key holding a token minted
477
+ per process, because Payload's copy of the write data keeps string keys and
478
+ drops symbols, and a token cannot be forged by a client writing a field of the
479
+ same name. None of this is a security boundary, since a custom tool holds the
480
+ whole `payload` instance, but no ordinary write can widen itself into a publish.
481
+
482
+ The publish-blocker check has three limits worth knowing. Only the written
483
+ locale is validated. Field `beforeChange` hooks run again during the check, so
484
+ they must be pure. And the check runs privileged, so blocker paths and messages
485
+ may name fields the key's user cannot read, though values are never included.
397
486
 
398
487
  ## Security notes
399
488
 
@@ -402,16 +491,24 @@ key of every user.
402
491
  - The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
403
492
  ignored. Keys cannot authenticate REST or GraphQL.
404
493
  - Every operation runs under the linked user with `overrideAccess: false`.
405
- - Not covered in v1: `delete` (no tool exists and none is generated), uploads.
406
- Custom tools are trusted code and can do what the linked user may.
494
+ - Payload has no separate publish permission: at its access layer, anyone who
495
+ may update a document may publish it. The `publish` checkbox is this plugin's
496
+ fence, not Payload's.
497
+ - Not covered in v1: `delete` (no tool exists and none is generated), creating
498
+ upload documents and writing any file. Custom tools are trusted code and can
499
+ do what the linked user may.
500
+
501
+ How the draft and publish guarantees are enforced, and where they stop, is in
502
+ [How it is enforced](#how-it-is-enforced).
407
503
 
408
504
  ## Non-goals of v1 / roadmap
409
505
 
410
- Deletes, uploads, markdown authoring for rich text, addressing a rich text node
411
- by position in a patch (an editor state is written whole), schemas for `upload`
412
- node fields, row addressing by id instead of index, cross-locale publish
413
- blockers, pagination of `describeSchema` with `expand`, and a handler-level
414
- timeout are all deliberate omissions for now.
506
+ Unpublishing, `versions.drafts.localizeStatus`, deletes, creating upload
507
+ documents and any file handling, markdown authoring for rich text, addressing a
508
+ rich text node by position in a patch (an editor state is written whole),
509
+ schemas for `upload` node fields, row addressing by id instead of index,
510
+ cross-locale publish blockers, pagination of `describeSchema` with `expand`,
511
+ and a handler-level timeout are all deliberate omissions for now.
415
512
 
416
513
  ## License
417
514
 
@@ -1,4 +1,4 @@
1
- import { CAPABILITIES_FIELD } from "../capabilities.mjs";
1
+ import { CAPABILITIES_FIELD, canCreate, 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 }) => {
@@ -15,7 +15,7 @@ const checkbox = (name, description) => ({
15
15
  defaultValue: false,
16
16
  admin: { description }
17
17
  });
18
- const SETUP_GUIDE_FIELD = "setupGuide";
18
+ /** Name of the `ui` field the "Connect a client" tab renders. */ const SETUP_GUIDE_FIELD = "setupGuide";
19
19
  /**
20
20
  * Fields every key carries. Key generation and the HMAC index live in the
21
21
  * collection-level `beforeChange` hook (see `collection.ts`), because sibling
@@ -89,23 +89,40 @@ 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.
102
+ *
103
+ * An upload collection gets the same checkboxes as any other, only worded for
104
+ * what `write` reaches there: a document's own fields, never `createDocument`,
105
+ * because the file comes from the admin panel.
97
106
  */ const createCapabilityFields = (options) => {
98
107
  const collectionGroups = options.collections.map((collection) => ({
99
108
  name: collection.fieldName,
100
109
  type: "group",
101
110
  label: collection.slug,
102
- fields: [...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [], ...collection.write ? [checkbox("write", "Create, patch and validate drafts.")] : []]
111
+ fields: [
112
+ ...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [],
113
+ ...canWrite(collection) ? [checkbox("write", canCreate(collection) ? "Create, patch and validate drafts." : "Patch and validate drafts. The file itself is uploaded in the admin panel.")] : [],
114
+ ...canPublish(collection) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
115
+ ]
103
116
  }));
104
117
  const globalGroups = options.globals.map((global) => ({
105
118
  name: global.fieldName,
106
119
  type: "group",
107
120
  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.")] : []]
121
+ fields: [
122
+ ...global.read ? [checkbox("read", "Describe and read this global.")] : [],
123
+ ...canWrite(global) ? [checkbox("write", "Patch and validate this global's draft.")] : [],
124
+ ...canPublish(global) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
125
+ ]
109
126
  }));
110
127
  const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
111
128
  const groups = [
@@ -1,5 +1,8 @@
1
1
  //#region src/api-keys/setup-guide.ts
2
- const KEY_PLACEHOLDER = "<your-key>";
2
+ /**
3
+ * Stands in for the key in the snippets whenever the real one is unavailable,
4
+ * so the instructions still render and say what is missing.
5
+ */ const KEY_PLACEHOLDER = "<your-key>";
3
6
  /**
4
7
  * Server name for the client config. MCP clients key their config by this, so
5
8
  * it has to survive labels with spaces or punctuation.
@@ -8,9 +11,8 @@ const KEY_PLACEHOLDER = "<your-key>";
8
11
  return slug === "" ? "payload" : slug;
9
12
  };
10
13
  /**
11
- * The connection instructions for one key, split into independently copyable
12
- * blocks. Kept a pure builder so the admin component holds only rendering and
13
- * the snippets stay unit-testable.
14
+ * A pure builder, so the admin component holds only rendering and the snippets
15
+ * stay unit-testable.
14
16
  */ const buildSetupGuide = (input) => {
15
17
  const key = typeof input.apiKey === "string" ? input.apiKey : KEY_PLACEHOLDER;
16
18
  const name = toServerName(input.label);
@@ -5,9 +5,7 @@ const relationId = (value) => {
5
5
  if (typeof value === "string" || typeof value === "number") return value;
6
6
  if (typeof value === "object" && value !== null && "id" in value) return value.id;
7
7
  };
8
- /**
9
- * The bearer token of an `Authorization` header, or `null`.
10
- */ const parseBearer = (headers) => {
8
+ const parseBearer = (headers) => {
11
9
  const header = headers.get("authorization");
12
10
  if (!header) return null;
13
11
  return BEARER.exec(header.trim())?.[1] ?? null;
@@ -1,8 +1,23 @@
1
1
  //#region src/capabilities.ts
2
- /** Name of the capability group on the key document. */ const CAPABILITIES_FIELD = "capabilities";
2
+ /** Group field holding the capability checkboxes on an API key document. */ const CAPABILITIES_FIELD = "capabilities";
3
+ /** Whatever the write lands on; {@link isLiveWrite} tells the two apart. */ const canWrite = (entity) => entity.write !== false;
4
+ /** The config lets MCP change live content and there is a draft to promote. */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
5
+ /**
6
+ * An upload document is a file, and no tool here carries one. Its own fields
7
+ * stay patchable; the first version is made in the admin panel.
8
+ */ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
9
+ /**
10
+ * With no versions there is no draft to land on, so `write: "live"` 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, canCreate, canPublish, canWrite, isLiveWrite, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };