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