@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 +229 -166
- package/dist/api-keys/fields.mjs +7 -3
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +1 -3
- package/dist/capabilities.mjs +8 -8
- package/dist/endpoint/errors.mjs +0 -2
- package/dist/endpoint/server.mjs +6 -20
- package/dist/i18n.mjs +4 -15
- package/dist/options.mjs +7 -20
- package/dist/result.d.mts +3 -5
- package/dist/result.mjs +3 -5
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/lexical.mjs +160 -27
- package/dist/schema/pointer.mjs +5 -11
- package/dist/schema/shape.mjs +21 -19
- package/dist/schema/walk.mjs +36 -54
- package/dist/tools/builtin.mjs +4 -6
- package/dist/tools/create-document.mjs +19 -6
- package/dist/tools/describe-schema.mjs +9 -1
- package/dist/tools/find-documents.mjs +8 -1
- package/dist/tools/get-document.mjs +5 -1
- package/dist/tools/list-capabilities.mjs +10 -2
- package/dist/tools/patch-document.mjs +7 -1
- package/dist/tools/publish-document.mjs +13 -15
- package/dist/tools/shared.mjs +39 -37
- package/dist/tools/target.mjs +3 -7
- package/dist/tools/validate-document.mjs +9 -1
- package/dist/types.d.mts +38 -77
- package/dist/write/draft-guard.mjs +33 -65
- package/dist/write/patch.mjs +22 -60
- package/dist/write/publish-blockers.mjs +6 -12
- package/dist/write/publish-intent.mjs +13 -35
- package/dist/write/transaction.mjs +2 -3
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
valid alternatives listed
|
|
15
|
-
|
|
16
|
-
Writes are RFC 6902 patches that land as drafts
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
| ------------------ |
|
|
155
|
-
| `listCapabilities` | What this key may do
|
|
156
|
-
| `describeSchema` | Field shape of one node; `next` lists the drill-down paths.
|
|
157
|
-
| `findDocuments` | Query documents.
|
|
158
|
-
| `getDocument` | Read one document or a subtree of it.
|
|
159
|
-
| `patchDocument` | Apply RFC 6902 operations to the current draft.
|
|
160
|
-
| `createDocument` | Create a draft from a minimal seed.
|
|
161
|
-
| `validateDocument` | Publish blockers without saving anything.
|
|
162
|
-
| `publishDocument` | Publish the current draft.
|
|
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
|
|
244
|
-
`timestamps: false` for globals
|
|
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
|
-
##
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
292
|
-
checklist of what remains.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
##
|
|
466
|
+
## How it is enforced
|
|
395
467
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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),
|
|
452
|
-
Custom tools are trusted code and can
|
|
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,
|
|
457
|
-
authoring for rich text, addressing a
|
|
458
|
-
by position in a patch (an editor state is written whole),
|
|
459
|
-
node fields, row addressing by id instead of index,
|
|
460
|
-
blockers, pagination of `describeSchema` with `expand`,
|
|
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
|
|
package/dist/api-keys/fields.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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);
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -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;
|
package/dist/capabilities.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
//#region src/capabilities.ts
|
|
2
|
-
/**
|
|
3
|
-
/**
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*/ const
|
|
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
|
-
*
|
|
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 };
|
package/dist/endpoint/errors.mjs
CHANGED
|
@@ -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
|