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

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
@@ -186,6 +222,19 @@ Rules the tools enforce and explain in their own descriptions:
186
222
  answers `{ "heading": { "tag": ["h4"] } }`, and a write carrying any other
187
223
  heading tag is refused. Lexical stores whatever tag it is given, so this is
188
224
  the only place the restriction is checked.
225
+ - A Lexical node must be written the way Lexical serializes it, and carry the
226
+ values Lexical would have written. The admin editor rehydrates nodes through
227
+ their classes, so a list item whose `indent` is absent, `null` or `"0"` throws
228
+ when the document is opened, and a heading whose `tag` is `3` comes back
229
+ untagged, none of which Payload notices on write. A write breaking either is
230
+ refused, and the message names the property and what belongs there.
231
+ - Where Payload states the shape itself, that statement is what is enforced: its
232
+ `outputSchema` declares `version` required on every node, and gives the root
233
+ exactly `children`, `direction`, `format`, `indent`, `type` and `version`, so
234
+ an unknown property on the root is refused too. Payload declares nothing per
235
+ node type, so the rest is measured against the node classes
236
+ `@payloadcms/richtext-lexical` ships. Both halves are pinned by
237
+ `src/schema/lexical.spec.ts` rather than assumed.
189
238
  - Field and collection `admin.description` values are included in
190
239
  `describeSchema` and `listCapabilities`, so intent written for the admin
191
240
  panel reaches the client. A locale-keyed record is resolved to one string for
@@ -240,8 +289,8 @@ camelCase name with a collection. Keys issued before a global was exposed have
240
289
  no such group, and an absent checkbox reads as `false`, so they stay closed to
241
290
  every global until one is ticked.
242
291
 
243
- Globals always carry `updatedAt` Payload appends it and there is no
244
- `timestamps: false` for globals so `expectedUpdatedAt` behaves as it does for
292
+ Globals always carry `updatedAt`, because Payload appends it and there is no
293
+ `timestamps: false` for globals, so `expectedUpdatedAt` behaves as it does for
245
294
  collections. The one exception is a global that has never been saved: it has no
246
295
  `updatedAt` to compare against, so the first write must omit
247
296
  `expectedUpdatedAt`, and supplying one is refused as a concurrency failure.
@@ -250,55 +299,78 @@ If `tools/list` omits `global` entirely, no global is exposed to that key; the
250
299
  argument only appears once one is. A deployment that uses no globals sees the
251
300
  tool schemas exactly as they were.
252
301
 
253
- ## Drafts and publishing
302
+ ## API keys
303
+
304
+ Keys are created in the admin panel under MCP > API Keys. The plaintext key is
305
+ generated on create, stored encrypted with an HMAC index for lookup, and shown
306
+ to anyone who may read the key document (own keys only, by default). Each key:
254
307
 
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.
308
+ - is bound to the user who created it and acts as that user: every operation
309
+ runs with `req.user` set to the linked user and `overrideAccess: false`, so
310
+ your collection access control applies unchanged;
311
+ - carries one checkbox per exposed collection and operation, plus one per
312
+ custom tool. All checkboxes default to off. A key can never enable an
313
+ operation the plugin config does not expose, and keys created before a
314
+ capability existed stay without it. The `publish` checkbox only exists where
315
+ a versioned entity is configured `write: "live"`, so a key issued before
316
+ publishing was possible stays closed to it, and it counts only alongside
317
+ `write`: publishing is an extension of writing, not a capability of its own.
260
318
 
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.
319
+ Keys authenticate only the MCP endpoint. They are deliberately not a Payload
320
+ auth strategy, so a key can never authenticate the REST or GraphQL API; the
321
+ reverse also holds: an admin session or JWT is ignored by the MCP endpoint.
322
+
323
+ Use `apiKeys.overrideCollection` to widen access (for example, admins manage
324
+ all keys) or add fields.
325
+
326
+ ### The "Connect a client" tab
327
+
328
+ Saved keys carry a **Connect a client** tab in the admin holding the client
329
+ snippets from [Quick start](#quick-start) with their own URL and key filled in,
330
+ each block behind a copy button. The tab only exists once the key does, so the create form stays free of
331
+ it. Turn it off with `apiKeys.setupGuide: false`, which also drops the tabs and
332
+ restores the flat form.
266
333
 
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.
334
+ The tab renders an admin component, so it has to be in the import map:
335
+
336
+ ```bash
337
+ payload generate:importmap
338
+ ```
339
+
340
+ Without that entry Payload logs a missing-component error and renders nothing
341
+ else; the rest of the plugin is unaffected. The URL comes from `serverURL` when
342
+ the config sets one and from the browser's origin otherwise.
343
+
344
+ ## Drafts and publishing
345
+
346
+ Every MCP write lands as a draft. That is enforced on the Payload operation
347
+ rather than in the tool handlers, so a custom tool writing through the same
348
+ request is covered as well; see [How it is enforced](#how-it-is-enforced) for
349
+ the mechanism.
350
+
351
+ `publishDocument` is the one way through. Publishing covers the whole document,
352
+ as the admin Publish button does, but Payload only validates the locale the
353
+ publish runs in. A required field left empty in another locale therefore goes
354
+ live empty. That is Payload's behaviour, not something this plugin adds.
355
+ `publishDocument` refuses a document that fails validation and reports
356
+ `validationErrors` with JSON Pointers. It is refused while a human holds the
357
+ document open in the admin panel, and republishing an unchanged document is
358
+ accepted but writes another version.
284
359
 
285
360
  There is no unpublish tool. Reverting a published document to a draft stays a
286
361
  human action.
287
362
 
288
363
  Publish blockers are advisory. Payload skips validation on draft saves (unless
289
364
  `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`
365
+ Payload's own field validation over the saved draft and returns the failures as
366
+ `publishBlockers` with paths and labels. The write stands; the client gets a
367
+ checklist of what remains. Collections with `versions.drafts.validate: true`
368
+ refuse invalid drafts outright, and those failures come back as
369
+ `validationErrors` instead. Both carry pointers, restated from the dotted paths
370
+ Payload reports internally.
371
+
372
+ `publishBlockersUnavailable` marks a check that could not complete, which is not
373
+ the same answer as a document with nothing wrong with it. `validateDocument`
302
374
  runs the same traversal without saving anything, so it is not free of side
303
375
  effects: field `beforeValidate` and `beforeChange` hooks run, and it carries no
304
376
  `readOnlyHint` for that reason.
@@ -391,52 +463,39 @@ argument is rejected by name rather than stripped before the handler runs.
391
463
  `jsonResult` and `errorResult` are exported so a custom tool can return
392
464
  results shaped like a builtin's.
393
465
 
394
- ## Options
466
+ ## How it is enforced
395
467
 
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. |
468
+ Draft-only writing sits on the Payload operation, not in the tool handlers. A
469
+ `beforeOperation` hook forces `draft: true` and strips `_status` from every
470
+ write carrying the MCP request marker, so custom tools and anything else writing
471
+ through the same request are covered too. A `beforeChange` hook then refuses any
472
+ write that would still not land as a draft.
414
473
 
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.
474
+ The two hooks are not equally load-bearing on both sides. `updateGlobal` reads
475
+ `draft` and the publish arguments off its argument bag _before_ it runs
476
+ `beforeOperation`, and re-reads only `data` afterwards, so for a global the
477
+ correction cannot apply and the `beforeChange` refusal is what actually holds
478
+ the line. Both are installed on every collection and global, exposed or not.
433
479
 
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.
480
+ `publishDocument` opens the door for exactly one write: the tool marks that
481
+ write's own `data` object, and the guard grants the publish only to a write
482
+ carrying the mark. Nothing is scoped to a slug or an id because nothing else can
483
+ reach it. A concurrent call in the same JSON-RPC batch has its own `data`, and
484
+ so does a nested write from a hook during the publish.
485
+
486
+ That distinction matters. The endpoint hands one `PayloadRequest` to every tool,
487
+ and the transport dispatches the messages of a batch without awaiting each one,
488
+ so an intent kept on the request would be reachable by a sibling `patchDocument`
489
+ and would publish it instead. The mark is a string key holding a token minted
490
+ per process, because Payload's copy of the write data keeps string keys and
491
+ drops symbols, and a token cannot be forged by a client writing a field of the
492
+ same name. None of this is a security boundary, since a custom tool holds the
493
+ whole `payload` instance, but no ordinary write can widen itself into a publish.
494
+
495
+ The publish-blocker check has three limits worth knowing. Only the written
496
+ locale is validated. Field `beforeChange` hooks run again during the check, so
497
+ they must be pure. And the check runs privileged, so blocker paths and messages
498
+ may name fields the key's user cannot read, though values are never included.
440
499
 
441
500
  ## Security notes
442
501
 
@@ -448,17 +507,21 @@ key of every user.
448
507
  - Payload has no separate publish permission: at its access layer, anyone who
449
508
  may update a document may publish it. The `publish` checkbox is this plugin's
450
509
  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.
510
+ - Not covered in v1: `delete` (no tool exists and none is generated), creating
511
+ upload documents and writing any file. Custom tools are trusted code and can
512
+ do what the linked user may.
513
+
514
+ How the draft and publish guarantees are enforced, and where they stop, is in
515
+ [How it is enforced](#how-it-is-enforced).
453
516
 
454
517
  ## Non-goals of v1 / roadmap
455
518
 
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.
519
+ Unpublishing, `versions.drafts.localizeStatus`, deletes, creating upload
520
+ documents and any file handling, markdown authoring for rich text, addressing a
521
+ rich text node by position in a patch (an editor state is written whole),
522
+ schemas for `upload` node fields, row addressing by id instead of index,
523
+ cross-locale publish blockers, pagination of `describeSchema` with `expand`,
524
+ and a handler-level timeout are all deliberate omissions for now.
462
525
 
463
526
  ## License
464
527
 
@@ -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