@abinnovision/payloadcms-mcpx 1.0.0-beta.13 → 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,28 +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. 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).
24
-
25
- ## 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
26
38
 
27
39
  ```bash
28
40
  yarn add @abinnovision/payloadcms-mcpx
@@ -34,7 +46,8 @@ yarn add @abinnovision/payloadcms-mcpx
34
46
  `apiKeys.setupGuide: false`.
35
47
  - The package is published as ESM only, matching Payload itself.
36
48
 
37
- ## Usage
49
+ Add the plugin and name the collections and globals it may reach. Nothing is
50
+ exposed that is not listed here:
38
51
 
39
52
  ```ts
40
53
  import { mcpxPlugin } from "@abinnovision/payloadcms-mcpx";
@@ -67,49 +80,12 @@ The plugin adds:
67
80
  - a draft guard on every collection and global, so any write carrying the MCP
68
81
  request marker lands as a draft, including writes made by custom tools.
69
82
 
70
- ## API keys
71
-
72
- Keys are created in the admin panel under MCP > API Keys. The plaintext key is
73
- generated on create, stored encrypted with an HMAC index for lookup, and shown
74
- to anyone who may read the key document (own keys only, by default). Each key:
75
-
76
- - is bound to the user who created it and acts as that user: every operation
77
- runs with `req.user` set to the linked user and `overrideAccess: false`, so
78
- your collection access control applies unchanged;
79
- - carries one checkbox per exposed collection and operation, plus one per
80
- custom tool. All checkboxes default to off. A key can never enable an
81
- operation the plugin config does not expose, and keys created before a
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.
86
-
87
- Keys authenticate only the MCP endpoint. They are deliberately not a Payload
88
- auth strategy, so a key can never authenticate the REST or GraphQL API; the
89
- reverse also holds: an admin session or JWT is ignored by the MCP endpoint.
90
-
91
- Use `apiKeys.overrideCollection` to widen access (for example, admins manage
92
- all keys) or add fields.
93
-
94
- ## Connecting a client
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.
95
87
 
96
- Saved keys carry a **Connect a client** tab in the admin holding these same
97
- instructions with their own URL and key filled in, each block behind a copy
98
- button. The tab only exists once the key does, so the create form stays free of
99
- it. Turn it off with `apiKeys.setupGuide: false`, which also drops the tabs and
100
- restores the flat form.
101
-
102
- The tab renders an admin component, so it has to be in the import map:
103
-
104
- ```bash
105
- payload generate:importmap
106
- ```
107
-
108
- Without that entry Payload logs a missing-component error and renders nothing
109
- else; the rest of the plugin is unaffected. The URL comes from `serverURL` when
110
- the config sets one and from the browser's origin otherwise.
111
-
112
- The endpoint speaks streamable HTTP with `Authorization: Bearer <key>`:
88
+ Then point a client at the endpoint, passing the key as a bearer token:
113
89
 
114
90
  ```bash
115
91
  npx @modelcontextprotocol/inspector
@@ -143,6 +119,62 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
143
119
  }
144
120
  ```
145
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
+
146
178
  ## Tools
147
179
 
148
180
  The surface is fixed at eight tools plus your custom ones; exposing a global
@@ -150,16 +182,16 @@ adds an argument, never a tool. `tools/list` reflects the key: write tools
150
182
  disappear for read-only keys, and every `collection` and `global` enum contains
151
183
  only the slugs the key may touch.
152
184
 
153
- | Tool | Purpose | Key arguments |
154
- | ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
155
- | `listCapabilities` | What this key may do; call first to orient. | none |
156
- | `describeSchema` | Field shape of one node; `next` lists the drill-down paths. | `collection` \| `global`, `paths?`, `expand?` |
157
- | `findDocuments` | Query documents. | `collection`, `where?`, `sort?`, `limit?`, `page?`, `depth?`, `select?`, `locale?`, `draft?` |
158
- | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
159
- | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
160
- | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
161
- | `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
162
- | `publishDocument` | Publish the current draft. | `collection` + `id` \| `global`, `expectedUpdatedAt?` |
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?` |
163
195
 
164
196
  Rules the tools enforce and explain in their own descriptions:
165
197
 
@@ -176,6 +208,10 @@ Rules the tools enforce and explain in their own descriptions:
176
208
  guessed. Any feature declaring `getSubFields` is picked up, custom ones
177
209
  included. `upload` nodes are the exception: their fields depend on the
178
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.
179
215
  - Constraints a field declares travel with it: `minRows`/`maxRows` on arrays
180
216
  and blocks fields, `maxLength`/`minLength` on text, `min`/`max` on numbers.
181
217
  An array is described in its own right, so the `*` in `/items/*/title` has
@@ -240,8 +276,8 @@ camelCase name with a collection. Keys issued before a global was exposed have
240
276
  no such group, and an absent checkbox reads as `false`, so they stay closed to
241
277
  every global until one is ticked.
242
278
 
243
- Globals always carry `updatedAt` Payload appends it and there is no
244
- `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
245
281
  collections. The one exception is a global that has never been saved: it has no
246
282
  `updatedAt` to compare against, so the first write must omit
247
283
  `expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
@@ -250,55 +286,78 @@ If `tools/list` omits `global` entirely, no global is exposed to that key; the
250
286
  argument only appears once one is. A deployment that uses no globals sees the
251
287
  tool schemas exactly as they were.
252
288
 
253
- ## Drafts and publishing
289
+ ## API keys
290
+
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:
254
294
 
255
- Draft-only writing is enforced on the Payload operation, not in the tool
256
- handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
257
- from every write carrying the MCP request marker, so custom tools and anything
258
- else writing through the same request are covered too. A `beforeChange` hook
259
- refuses any write that would still not land as a draft.
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.
260
305
 
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.
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.
266
320
 
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.
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.
284
346
 
285
347
  There is no unpublish tool. Reverting a published document to a draft stays a
286
348
  human action.
287
349
 
288
350
  Publish blockers are advisory. Payload skips validation on draft saves (unless
289
351
  `versions.drafts.validate` is set), so after every write the plugin re-runs
290
- Payload's own field validation over the saved draft and returns the failures
291
- as `publishBlockers` with paths and labels. The write stands; the client gets a
292
- checklist of what remains. Three limits: only the written locale is
293
- validated; field `beforeChange` hooks run again during the check, so they must
294
- be pure; and the check runs privileged, so blocker paths and messages may name
295
- fields the key's user cannot read (values are never included).
296
- Collections with `versions.drafts.validate: true` refuse invalid drafts
297
- outright; those failures come back as `validationErrors`. Both carry pointers,
298
- restated from the dotted paths Payload reports internally.
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`
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`
302
361
  runs the same traversal without saving anything, so it is not free of side
303
362
  effects: field `beforeValidate` and `beforeChange` hooks run, and it carries no
304
363
  `readOnlyHint` for that reason.
@@ -391,52 +450,39 @@ argument is rejected by name rather than stripped before the handler runs.
391
450
  `jsonResult` and `errorResult` are exported so a custom tool can return
392
451
  results shaped like a builtin's.
393
452
 
394
- ## Options
453
+ ## How it is enforced
395
454
 
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. |
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.
414
460
 
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.
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.
433
466
 
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
438
- included: their documents carry credentials, such as the decrypted Payload API
439
- key of every user.
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.
440
486
 
441
487
  ## Security notes
442
488
 
@@ -448,17 +494,21 @@ key of every user.
448
494
  - Payload has no separate publish permission: at its access layer, anyone who
449
495
  may update a document may publish it. The `publish` checkbox is this plugin's
450
496
  fence, not Payload's.
451
- - Not covered in v1: `delete` (no tool exists and none is generated), uploads.
452
- Custom tools are trusted code and can do what the linked user may.
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).
453
503
 
454
504
  ## Non-goals of v1 / roadmap
455
505
 
456
- Unpublishing, `versions.drafts.localizeStatus`, deletes, uploads, markdown
457
- authoring for rich text, addressing a rich text node
458
- by position in a patch (an editor state is written whole), schemas for `upload`
459
- node fields, row addressing by id instead of index, cross-locale publish
460
- blockers, pagination of `describeSchema` with `expand`, and a handler-level
461
- 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.
462
512
 
463
513
  ## License
464
514
 
@@ -1,4 +1,4 @@
1
- import { CAPABILITIES_FIELD, canPublish, canWrite } 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
@@ -99,6 +99,10 @@ const PUBLISH_DESCRIPTION = "Publish the current draft. Changes what the public
99
99
  * An entity without versions gets no `publish` checkbox even under
100
100
  * `write: "live"`: there is no draft to promote there, the write itself is the
101
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.
102
106
  */ const createCapabilityFields = (options) => {
103
107
  const collectionGroups = options.collections.map((collection) => ({
104
108
  name: collection.fieldName,
@@ -106,7 +110,7 @@ const PUBLISH_DESCRIPTION = "Publish the current draft. Changes what the public
106
110
  label: collection.slug,
107
111
  fields: [
108
112
  ...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [],
109
- ...canWrite(collection) ? [checkbox("write", "Create, patch and validate drafts.")] : [],
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.")] : [],
110
114
  ...canPublish(collection) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
111
115
  ]
112
116
  }));
@@ -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,13 +1,13 @@
1
1
  //#region src/capabilities.ts
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;
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;
4
5
  /**
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;
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;
8
9
  /**
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
10
+ * With no versions there is no draft to land on, so `write: "live"` permits the
11
11
  * write at all and every write is live.
12
12
  */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
13
13
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -59,4 +59,4 @@ const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read")
59
59
  const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
60
60
  const publishableGlobalSlugs = (capabilities) => pick(capabilities.globals, "publish");
61
61
  //#endregion
62
- export { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
62
+ export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
@@ -22,8 +22,6 @@ import { APIError, ValidationError } from "payload";
22
22
  });
23
23
  };
24
24
  /**
25
- * Maps an exception thrown by a tool to a result the client can read.
26
- *
27
25
  * Payload's public errors keep their message and status; a `ValidationError`
28
26
  * also surfaces its per-field detail, with each field's path restated as a
29
27
  * JSON Pointer so it reads like every other path this plugin reports. Anything