@abinnovision/payloadcms-mcpx 1.0.0-beta.12 → 1.0.0-beta.13
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 +96 -49
- package/dist/api-keys/fields.mjs +16 -3
- package/dist/capabilities.mjs +22 -3
- package/dist/endpoint/handler.mjs +3 -1
- package/dist/index.d.mts +2 -2
- package/dist/options.mjs +26 -9
- package/dist/tools/builtin.mjs +3 -1
- package/dist/tools/list-capabilities.mjs +2 -0
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +5 -5
- package/dist/tools/publish-document.mjs +81 -0
- package/dist/tools/shared.mjs +45 -15
- package/dist/tools/target.mjs +4 -4
- package/dist/types.d.mts +28 -21
- package/dist/write/draft-guard.mjs +69 -30
- package/dist/write/patch.mjs +1 -1
- package/dist/write/publish-intent.mjs +39 -0
- package/dist/write/transaction.mjs +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,13 +13,14 @@ resolved server-side against the real config and the real document, so unknown
|
|
|
13
13
|
fields, misplaced blocks and unusable rich text nodes or node fields are refused with the
|
|
14
14
|
valid alternatives listed, never silently dropped.
|
|
15
15
|
|
|
16
|
-
Writes are RFC 6902 patches that land as drafts
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
means no
|
|
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).
|
|
23
24
|
|
|
24
25
|
## Install
|
|
25
26
|
|
|
@@ -44,12 +45,12 @@ export default buildConfig({
|
|
|
44
45
|
plugins: [
|
|
45
46
|
mcpxPlugin({
|
|
46
47
|
collections: {
|
|
47
|
-
pages: { read: true, write:
|
|
48
|
-
posts: { read: true, write:
|
|
48
|
+
pages: { read: true, write: "live" }, // may be published through MCP
|
|
49
|
+
posts: { read: true, write: "draft" }, // drafts only
|
|
49
50
|
tags: true, // shorthand for { read: true }
|
|
50
51
|
},
|
|
51
52
|
globals: {
|
|
52
|
-
"site-settings": { read: true, write:
|
|
53
|
+
"site-settings": { read: true, write: "draft" },
|
|
53
54
|
},
|
|
54
55
|
limits: { maxLimit: 25, maxDepth: 1 },
|
|
55
56
|
}),
|
|
@@ -78,7 +79,10 @@ to anyone who may read the key document (own keys only, by default). Each key:
|
|
|
78
79
|
- carries one checkbox per exposed collection and operation, plus one per
|
|
79
80
|
custom tool. All checkboxes default to off. A key can never enable an
|
|
80
81
|
operation the plugin config does not expose, and keys created before a
|
|
81
|
-
capability existed stay without it.
|
|
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.
|
|
82
86
|
|
|
83
87
|
Keys authenticate only the MCP endpoint. They are deliberately not a Payload
|
|
84
88
|
auth strategy, so a key can never authenticate the REST or GraphQL API; the
|
|
@@ -141,7 +145,7 @@ Claude Desktop (no direct HTTP header support) via `mcp-remote`:
|
|
|
141
145
|
|
|
142
146
|
## Tools
|
|
143
147
|
|
|
144
|
-
The surface is fixed at
|
|
148
|
+
The surface is fixed at eight tools plus your custom ones; exposing a global
|
|
145
149
|
adds an argument, never a tool. `tools/list` reflects the key: write tools
|
|
146
150
|
disappear for read-only keys, and every `collection` and `global` enum contains
|
|
147
151
|
only the slugs the key may touch.
|
|
@@ -155,6 +159,7 @@ only the slugs the key may touch.
|
|
|
155
159
|
| `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
|
|
156
160
|
| `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
|
|
157
161
|
| `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
|
|
162
|
+
| `publishDocument` | Publish the current draft. | `collection` + `id` \| `global`, `expectedUpdatedAt?` |
|
|
158
163
|
|
|
159
164
|
Rules the tools enforce and explain in their own descriptions:
|
|
160
165
|
|
|
@@ -245,15 +250,40 @@ If `tools/list` omits `global` entirely, no global is exposed to that key; the
|
|
|
245
250
|
argument only appears once one is. A deployment that uses no globals sees the
|
|
246
251
|
tool schemas exactly as they were.
|
|
247
252
|
|
|
248
|
-
## Drafts and
|
|
253
|
+
## Drafts and publishing
|
|
249
254
|
|
|
250
255
|
Draft-only writing is enforced on the Payload operation, not in the tool
|
|
251
256
|
handlers: a `beforeOperation` hook forces `draft: true` and strips `_status`
|
|
252
257
|
from every write carrying the MCP request marker, so custom tools and anything
|
|
253
258
|
else writing through the same request are covered too. A `beforeChange` hook
|
|
254
|
-
refuses any write that would still not land as a draft.
|
|
255
|
-
|
|
256
|
-
|
|
259
|
+
refuses any write that would still not land as a draft.
|
|
260
|
+
|
|
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.
|
|
266
|
+
|
|
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.
|
|
284
|
+
|
|
285
|
+
There is no unpublish tool. Reverting a published document to a draft stays a
|
|
286
|
+
human action.
|
|
257
287
|
|
|
258
288
|
Publish blockers are advisory. Payload skips validation on draft saves (unless
|
|
259
289
|
`versions.drafts.validate` is set), so after every write the plugin re-runs
|
|
@@ -308,9 +338,9 @@ Custom tools take the same route as the builtins: one `McpxTool` shape, one
|
|
|
308
338
|
registration loop. Anything a builtin does, a custom tool can do.
|
|
309
339
|
|
|
310
340
|
`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`.
|
|
341
|
+
carries what the key may touch (`readable`, `writable`, `publishable`,
|
|
342
|
+
`readableGlobals`, `writableGlobals`, `publishableGlobals`), the configured
|
|
343
|
+
locales, the limits in force and the exposed collections and globals. `req` is shorthand for `scope.req`.
|
|
314
344
|
|
|
315
345
|
`inputSchema` may be a function of that scope instead of a fixed shape, which
|
|
316
346
|
is how a tool narrows an enum to what the key may read:
|
|
@@ -363,35 +393,48 @@ results shaped like a builtin's.
|
|
|
363
393
|
|
|
364
394
|
## Options
|
|
365
395
|
|
|
366
|
-
| Option
|
|
367
|
-
|
|
|
368
|
-
| `collections`
|
|
369
|
-
| `collections.<slug>.read`
|
|
370
|
-
| `collections.<slug>.write`
|
|
371
|
-
| `
|
|
372
|
-
| `globals`
|
|
373
|
-
| `globals.<slug>.
|
|
374
|
-
| `
|
|
375
|
-
| `
|
|
376
|
-
| `
|
|
377
|
-
| `apiKeys.
|
|
378
|
-
| `
|
|
379
|
-
| `
|
|
380
|
-
| `
|
|
381
|
-
| `
|
|
382
|
-
| `
|
|
383
|
-
| `
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
`
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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. |
|
|
414
|
+
|
|
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.
|
|
433
|
+
|
|
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
|
|
395
438
|
included: their documents carry credentials, such as the decrypted Payload API
|
|
396
439
|
key of every user.
|
|
397
440
|
|
|
@@ -402,12 +445,16 @@ key of every user.
|
|
|
402
445
|
- The endpoint authenticates with Bearer keys only; admin JWTs and cookies are
|
|
403
446
|
ignored. Keys cannot authenticate REST or GraphQL.
|
|
404
447
|
- Every operation runs under the linked user with `overrideAccess: false`.
|
|
448
|
+
- Payload has no separate publish permission: at its access layer, anyone who
|
|
449
|
+
may update a document may publish it. The `publish` checkbox is this plugin's
|
|
450
|
+
fence, not Payload's.
|
|
405
451
|
- Not covered in v1: `delete` (no tool exists and none is generated), uploads.
|
|
406
452
|
Custom tools are trusted code and can do what the linked user may.
|
|
407
453
|
|
|
408
454
|
## Non-goals of v1 / roadmap
|
|
409
455
|
|
|
410
|
-
|
|
456
|
+
Unpublishing, `versions.drafts.localizeStatus`, deletes, uploads, markdown
|
|
457
|
+
authoring for rich text, addressing a rich text node
|
|
411
458
|
by position in a patch (an editor state is written whole), schemas for `upload`
|
|
412
459
|
node fields, row addressing by id instead of index, cross-locale publish
|
|
413
460
|
blockers, pagination of `describeSchema` with `expand`, and a handler-level
|
package/dist/api-keys/fields.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CAPABILITIES_FIELD } from "../capabilities.mjs";
|
|
1
|
+
import { CAPABILITIES_FIELD, 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 }) => {
|
|
@@ -89,23 +89,36 @@ 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.
|
|
97
102
|
*/ const createCapabilityFields = (options) => {
|
|
98
103
|
const collectionGroups = options.collections.map((collection) => ({
|
|
99
104
|
name: collection.fieldName,
|
|
100
105
|
type: "group",
|
|
101
106
|
label: collection.slug,
|
|
102
|
-
fields: [
|
|
107
|
+
fields: [
|
|
108
|
+
...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [],
|
|
109
|
+
...canWrite(collection) ? [checkbox("write", "Create, patch and validate drafts.")] : [],
|
|
110
|
+
...canPublish(collection) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
|
|
111
|
+
]
|
|
103
112
|
}));
|
|
104
113
|
const globalGroups = options.globals.map((global) => ({
|
|
105
114
|
name: global.fieldName,
|
|
106
115
|
type: "group",
|
|
107
116
|
label: global.slug,
|
|
108
|
-
fields: [
|
|
117
|
+
fields: [
|
|
118
|
+
...global.read ? [checkbox("read", "Describe and read this global.")] : [],
|
|
119
|
+
...canWrite(global) ? [checkbox("write", "Patch and validate this global's draft.")] : [],
|
|
120
|
+
...canPublish(global) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
|
|
121
|
+
]
|
|
109
122
|
}));
|
|
110
123
|
const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
|
|
111
124
|
const groups = [
|
package/dist/capabilities.mjs
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
//#region src/capabilities.ts
|
|
2
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;
|
|
4
|
+
/**
|
|
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;
|
|
8
|
+
/**
|
|
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
|
|
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, canPublish, canWrite, isLiveWrite, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
|
|
1
|
+
import { publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
|
|
2
2
|
import { jsonRpcError } from "./errors.mjs";
|
|
3
3
|
import { resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
4
4
|
import { createMcpServer } from "./server.mjs";
|
|
@@ -11,8 +11,10 @@ const buildScope = (req, options, capabilities) => {
|
|
|
11
11
|
capabilities,
|
|
12
12
|
readable: readableSlugs(capabilities),
|
|
13
13
|
writable: writableSlugs(capabilities),
|
|
14
|
+
publishable: publishableSlugs(capabilities),
|
|
14
15
|
readableGlobals: readableGlobalSlugs(capabilities),
|
|
15
16
|
writableGlobals: writableGlobalSlugs(capabilities),
|
|
17
|
+
publishableGlobals: publishableGlobalSlugs(capabilities),
|
|
16
18
|
locales: localization ? localization.localeCodes : null,
|
|
17
19
|
defaultLocale: localization ? localization.defaultLocale : null,
|
|
18
20
|
limits: options.limits,
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool } from "./types.mjs";
|
|
1
|
+
import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool } from "./types.mjs";
|
|
2
2
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
3
3
|
import { isMcpxRequest } from "./request.mjs";
|
|
4
4
|
import { errorResult, jsonResult } from "./result.mjs";
|
|
5
|
-
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
|
5
|
+
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
package/dist/options.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BUILTIN_TOOL_NAMES } from "./tools/names.mjs";
|
|
2
2
|
import "./version.mjs";
|
|
3
3
|
import { InvalidConfiguration } from "payload";
|
|
4
|
-
import { hasDraftsEnabled } from "payload/shared";
|
|
4
|
+
import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
|
|
5
5
|
//#region src/options.ts
|
|
6
6
|
const DEFAULT_API_KEYS_SLUG = "mcpx-api-keys";
|
|
7
7
|
const DEFAULT_ENDPOINT_PATH = "/mcpx";
|
|
@@ -24,11 +24,29 @@ const fail = (message) => {
|
|
|
24
24
|
if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
|
|
25
25
|
if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* The write mode, checked at runtime as well as in the type. JS callers get no
|
|
29
|
+
* type checking, and a typo reading as "no write" would be a silent downgrade.
|
|
30
|
+
*/ const normalizeWriteMode = (kind, slug, value) => {
|
|
31
|
+
if (value === void 0 || value === false) return false;
|
|
32
|
+
if (value === "draft" || value === "live") return value;
|
|
33
|
+
return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* `localizeStatus` makes `_status` a localized field, which flips Payload's
|
|
37
|
+
* `publishAllLocales` default to false and turns `_status` into a locale-keyed
|
|
38
|
+
* object. Publishing would then cover one locale while reporting success, and
|
|
39
|
+
* the tool responses model `_status` as a string. Refused until both are
|
|
40
|
+
* handled.
|
|
41
|
+
*/ const assertPublishable = (kind, config) => {
|
|
42
|
+
if (hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which write: "live" does not support yet.`);
|
|
43
|
+
};
|
|
27
44
|
const assertWritable = (collection, options) => {
|
|
28
45
|
const { slug } = collection;
|
|
29
46
|
if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
|
|
30
47
|
if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
|
|
31
|
-
if (
|
|
48
|
+
if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
|
|
49
|
+
if (options.write === "live") assertPublishable("Collection", collection);
|
|
32
50
|
};
|
|
33
51
|
/**
|
|
34
52
|
* Refuses globals that must never be reachable. Globals cannot be auth or
|
|
@@ -41,7 +59,8 @@ const assertWritable = (collection, options) => {
|
|
|
41
59
|
* `createdAt`/`updatedAt`, so the concurrency check the collection path guards
|
|
42
60
|
* for is always available here. Drafts are the only requirement left.
|
|
43
61
|
*/ const assertGlobalWritable = (global, options) => {
|
|
44
|
-
if (
|
|
62
|
+
if (options.write === "draft" && !options.hasDrafts) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set write: "live".`);
|
|
63
|
+
if (options.write === "live") assertPublishable("Global", global);
|
|
45
64
|
};
|
|
46
65
|
const normalizeCollections = (config, options, apiKeysSlug) => {
|
|
47
66
|
const collections = config.collections ?? [];
|
|
@@ -56,12 +75,11 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
|
|
|
56
75
|
const normalized = {
|
|
57
76
|
slug,
|
|
58
77
|
read: settings.read ?? true,
|
|
59
|
-
write: settings.write
|
|
60
|
-
allowLiveWrites: settings.allowLiveWrites ?? false,
|
|
78
|
+
write: normalizeWriteMode("Collection", slug, settings.write),
|
|
61
79
|
hasDrafts,
|
|
62
80
|
fieldName: toCamelCase(slug)
|
|
63
81
|
};
|
|
64
|
-
if (normalized.write) assertWritable(collection, normalized);
|
|
82
|
+
if (normalized.write !== false) assertWritable(collection, normalized);
|
|
65
83
|
if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
|
|
66
84
|
fieldNames.add(normalized.fieldName);
|
|
67
85
|
return [normalized];
|
|
@@ -80,12 +98,11 @@ const normalizeGlobals = (config, options) => {
|
|
|
80
98
|
const normalized = {
|
|
81
99
|
slug,
|
|
82
100
|
read: settings.read ?? true,
|
|
83
|
-
write: settings.write
|
|
84
|
-
allowLiveWrites: settings.allowLiveWrites ?? false,
|
|
101
|
+
write: normalizeWriteMode("Global", slug, settings.write),
|
|
85
102
|
hasDrafts,
|
|
86
103
|
fieldName: toCamelCase(slug)
|
|
87
104
|
};
|
|
88
|
-
if (normalized.write) assertGlobalWritable(global, normalized);
|
|
105
|
+
if (normalized.write !== false) assertGlobalWritable(global, normalized);
|
|
89
106
|
if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
|
|
90
107
|
fieldNames.add(normalized.fieldName);
|
|
91
108
|
return [normalized];
|
package/dist/tools/builtin.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { findDocuments } from "./find-documents.mjs";
|
|
|
4
4
|
import { getDocument } from "./get-document.mjs";
|
|
5
5
|
import { listCapabilities } from "./list-capabilities.mjs";
|
|
6
6
|
import { patchDocument } from "./patch-document.mjs";
|
|
7
|
+
import { publishDocument } from "./publish-document.mjs";
|
|
7
8
|
import { validateDocument } from "./validate-document.mjs";
|
|
8
9
|
//#region src/tools/builtin.ts
|
|
9
10
|
/**
|
|
@@ -20,7 +21,8 @@ import { validateDocument } from "./validate-document.mjs";
|
|
|
20
21
|
getDocument,
|
|
21
22
|
patchDocument,
|
|
22
23
|
createDocument,
|
|
23
|
-
validateDocument
|
|
24
|
+
validateDocument,
|
|
25
|
+
publishDocument
|
|
24
26
|
];
|
|
25
27
|
//#endregion
|
|
26
28
|
export { BUILTIN_TOOLS };
|
|
@@ -32,6 +32,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
32
32
|
...description === void 0 ? {} : { description },
|
|
33
33
|
read: capability.read,
|
|
34
34
|
write: capability.write,
|
|
35
|
+
publish: capability.publish,
|
|
35
36
|
drafts: entry.hasDrafts,
|
|
36
37
|
draftValidation: hasDraftValidationEnabled(config),
|
|
37
38
|
idType: collection.customIDType ?? payload.db.defaultIDType
|
|
@@ -48,6 +49,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
48
49
|
...description === void 0 ? {} : { description },
|
|
49
50
|
read: capability.read,
|
|
50
51
|
write: capability.write,
|
|
52
|
+
publish: capability.publish,
|
|
51
53
|
drafts: entry.hasDrafts,
|
|
52
54
|
draftValidation: hasDraftValidationEnabled(config)
|
|
53
55
|
}];
|
package/dist/tools/names.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
-
import { draftSentence, idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
2
|
+
import { draftSentence, idShape, localeOf, localeShape, readTarget, sameInstant, targetShape } from "./shared.mjs";
|
|
3
3
|
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
|
|
@@ -16,10 +16,9 @@ ${draftSentence(scope)}
|
|
|
16
16
|
|
|
17
17
|
Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
|
|
18
18
|
|
|
19
|
-
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so
|
|
19
|
+
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so an edit made since that read is refused rather than overwritten.
|
|
20
20
|
|
|
21
|
-
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but
|
|
22
|
-
const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
21
|
+
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but the document cannot be published until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
|
|
23
22
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
23
|
/**
|
|
25
24
|
* Whether the intended value survived the write. The saved document is
|
|
@@ -64,7 +63,7 @@ const patchDocument = defineMcpxTool({
|
|
|
64
63
|
description: "Locale the patch applies to. Localized fields write here only."
|
|
65
64
|
}),
|
|
66
65
|
patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
|
|
67
|
-
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching.
|
|
66
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. Best effort: the write is refused if the document changed before the check, but not if it changes between the check and the write.")
|
|
68
67
|
}),
|
|
69
68
|
handler: async ({ args, scope }) => {
|
|
70
69
|
const target = resolveTarget(scope, args, "write");
|
|
@@ -100,6 +99,7 @@ const patchDocument = defineMcpxTool({
|
|
|
100
99
|
});
|
|
101
100
|
else await payload.updateGlobal({
|
|
102
101
|
...write,
|
|
102
|
+
fallbackLocale: false,
|
|
103
103
|
slug: target.slug
|
|
104
104
|
});
|
|
105
105
|
const saved = await readTarget(scope, {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { idShape, localeOf, readTarget, sameInstant, targetShape } from "./shared.mjs";
|
|
3
|
+
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
|
+
import { defineMcpxTool } from "../types.mjs";
|
|
5
|
+
import { withTransaction } from "../write/transaction.mjs";
|
|
6
|
+
import { withPublishIntent } from "../write/publish-intent.mjs";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const publishDocument = defineMcpxTool({
|
|
9
|
+
name: "publishDocument",
|
|
10
|
+
description: `Publishes the current draft, which changes what the public sees. This is the only tool that does; every other write lands as a draft. Call validateDocument first: a document that still has publish blockers is refused, and nothing is written.
|
|
11
|
+
|
|
12
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
13
|
+
|
|
14
|
+
The whole document is published, but Payload only validates the locale the publish runs in, so a required field left empty in another locale goes live empty. That is how the admin panel behaves too. Publishing is refused while a human holds the document open in the admin panel, and republishing an unchanged document is accepted but writes another version.
|
|
15
|
+
|
|
16
|
+
There is no unpublish: reverting to a draft stays a human action in the admin panel.`,
|
|
17
|
+
annotations: {
|
|
18
|
+
destructiveHint: true,
|
|
19
|
+
openWorldHint: false
|
|
20
|
+
},
|
|
21
|
+
isEnabled: (scope) => scope.publishable.length + scope.publishableGlobals.length > 0,
|
|
22
|
+
inputSchema: (scope) => ({
|
|
23
|
+
...targetShape(scope, "publish", {
|
|
24
|
+
collection: "Collection holding the document.",
|
|
25
|
+
global: "Global to publish."
|
|
26
|
+
}),
|
|
27
|
+
...idShape(scope, "publish"),
|
|
28
|
+
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before publishing. Best effort: the publish is refused if the document has changed since, but a write landing between the check and the publish is not.")
|
|
29
|
+
}),
|
|
30
|
+
handler: async ({ args, scope }) => {
|
|
31
|
+
const target = resolveTarget(scope, args, "publish");
|
|
32
|
+
const id = requireIdFor(target, args.id);
|
|
33
|
+
const { payload } = scope.req;
|
|
34
|
+
const locale = localeOf(scope, void 0);
|
|
35
|
+
return await withTransaction(scope.req, async () => {
|
|
36
|
+
const doc = await readTarget(scope, {
|
|
37
|
+
target,
|
|
38
|
+
id,
|
|
39
|
+
locale
|
|
40
|
+
});
|
|
41
|
+
if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again before publishing.", { updatedAt: doc["updatedAt"] });
|
|
42
|
+
const write = {
|
|
43
|
+
data: {},
|
|
44
|
+
depth: 0,
|
|
45
|
+
draft: false,
|
|
46
|
+
fallbackLocale: false,
|
|
47
|
+
overrideAccess: false,
|
|
48
|
+
req: scope.req,
|
|
49
|
+
...locale === void 0 ? {} : { locale }
|
|
50
|
+
};
|
|
51
|
+
await withPublishIntent({
|
|
52
|
+
kind: target.kind,
|
|
53
|
+
slug: target.slug,
|
|
54
|
+
id
|
|
55
|
+
}, async () => {
|
|
56
|
+
if (target.kind === "collection") await payload.update({
|
|
57
|
+
...write,
|
|
58
|
+
collection: target.slug,
|
|
59
|
+
id
|
|
60
|
+
});
|
|
61
|
+
else await payload.updateGlobal({
|
|
62
|
+
...write,
|
|
63
|
+
slug: target.slug
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
const saved = await readTarget(scope, {
|
|
67
|
+
target,
|
|
68
|
+
id,
|
|
69
|
+
locale,
|
|
70
|
+
privileged: true
|
|
71
|
+
});
|
|
72
|
+
return jsonResult({
|
|
73
|
+
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
74
|
+
status: saved["_status"],
|
|
75
|
+
updatedAt: saved["updatedAt"]
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
//#endregion
|
|
81
|
+
export { publishDocument };
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -1,33 +1,63 @@
|
|
|
1
|
+
import { canPublish, isLiveWrite } from "../capabilities.mjs";
|
|
1
2
|
import { translateStatic } from "../i18n.mjs";
|
|
2
3
|
import { NotFound } from "payload";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
//#region src/tools/shared.ts
|
|
5
6
|
const slugEnum = (slugs) => z.enum(slugs);
|
|
6
7
|
const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* Empty for every key that can only write drafts.
|
|
11
|
-
*/ const liveWriteSlugs = (scope) => {
|
|
12
|
-
const live = (entities, writable) => entities.filter((entity) => writable.includes(entity.slug) && entity.allowLiveWrites && !entity.hasDrafts).map((entity) => entity.slug);
|
|
13
|
-
return [...live(scope.exposure.collections, scope.writable), ...live(scope.exposure.globals, scope.writableGlobals)];
|
|
8
|
+
const slugsWhere = (scope, predicate, allowed) => {
|
|
9
|
+
const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
|
|
10
|
+
return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
|
|
14
11
|
};
|
|
15
12
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Slugs this key may write whose writes land live rather than as a draft. An
|
|
14
|
+
* entity without versions has no draft to land on, so `write: "live"` there
|
|
15
|
+
* makes every write a live one. Empty for every key that can only write drafts.
|
|
16
|
+
*/ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
|
|
17
|
+
collections: scope.writable,
|
|
18
|
+
globals: scope.writableGlobals
|
|
19
|
+
});
|
|
20
|
+
/** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
|
|
21
|
+
collections: scope.publishable,
|
|
22
|
+
globals: scope.publishableGlobals
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* The sentence the write tools and the server instructions end on: what a write
|
|
26
|
+
* actually does for this key, and what it takes to make it public. The three
|
|
27
|
+
* groups are distinct — a live-write slug has no draft and no publish step, a
|
|
28
|
+
* publishable one has both — so a client is never told its writes are drafts
|
|
29
|
+
* while they are not, nor that publishing is out of reach when it is not.
|
|
18
30
|
*/ const draftSentence = (scope) => {
|
|
19
31
|
const live = liveWriteSlugs(scope);
|
|
20
|
-
|
|
32
|
+
const publishable = publishableWriteSlugs(scope);
|
|
33
|
+
return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
|
|
21
34
|
};
|
|
22
35
|
/**
|
|
36
|
+
* Whether two timestamps name the same instant, which is how
|
|
37
|
+
* `expectedUpdatedAt` is compared: the value a client read back is a string,
|
|
38
|
+
* and what it is compared against may be a Date.
|
|
39
|
+
*/ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
40
|
+
/**
|
|
23
41
|
* Widens one branch to the superset a handler sees. The widening itself is
|
|
24
42
|
* unchecked — the runtime shape really does vary — so `Branch` checks what it
|
|
25
43
|
* can around it.
|
|
26
44
|
*/ const widen = (branch) => branch;
|
|
27
|
-
const slugsFor = (scope, operation) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
const slugsFor = (scope, operation) => {
|
|
46
|
+
switch (operation) {
|
|
47
|
+
case "publish": return {
|
|
48
|
+
collections: scope.publishable,
|
|
49
|
+
globals: scope.publishableGlobals
|
|
50
|
+
};
|
|
51
|
+
case "read": return {
|
|
52
|
+
collections: scope.readable,
|
|
53
|
+
globals: scope.readableGlobals
|
|
54
|
+
};
|
|
55
|
+
case "write": return {
|
|
56
|
+
collections: scope.writable,
|
|
57
|
+
globals: scope.writableGlobals
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
31
61
|
/**
|
|
32
62
|
* The `collection` and `global` arguments.
|
|
33
63
|
*
|
|
@@ -114,4 +144,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
|
|
|
114
144
|
return translateStatic(resolved, i18n) ?? fallback;
|
|
115
145
|
};
|
|
116
146
|
//#endregion
|
|
117
|
-
export { depthShape, draftSentence, idSchema, idShape,
|
|
147
|
+
export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
|
package/dist/tools/target.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { slugsFor } from "./shared.mjs";
|
|
1
2
|
import { APIError, Forbidden } from "payload";
|
|
2
3
|
//#region src/tools/target.ts
|
|
3
4
|
const refOf = (target) => ({
|
|
@@ -14,12 +15,12 @@ const refOf = (target) => ({
|
|
|
14
15
|
* failed call teaches it.
|
|
15
16
|
*/ const resolveTarget = (scope, args, operation) => {
|
|
16
17
|
const { collection, global } = args;
|
|
18
|
+
const allowedSlugs = slugsFor(scope, operation);
|
|
17
19
|
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
18
20
|
if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
|
|
19
21
|
if (collection !== void 0) {
|
|
20
|
-
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
21
22
|
const found = scope.req.payload.collections[collection];
|
|
22
|
-
if (!
|
|
23
|
+
if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
23
24
|
return {
|
|
24
25
|
kind: "collection",
|
|
25
26
|
slug: collection,
|
|
@@ -27,9 +28,8 @@ const refOf = (target) => ({
|
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
const slug = global;
|
|
30
|
-
const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
|
|
31
31
|
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
32
|
-
if (!
|
|
32
|
+
if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
33
33
|
return {
|
|
34
34
|
kind: "global",
|
|
35
35
|
slug,
|
package/dist/types.d.mts
CHANGED
|
@@ -11,6 +11,17 @@ declare module "payload" {
|
|
|
11
11
|
"@abinnovision/payloadcms-mcpx": McpxPluginOptions;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* How far an exposed entity lets MCP writes reach.
|
|
16
|
+
*
|
|
17
|
+
* - `false`: no write tool touches it.
|
|
18
|
+
* - `"draft"`: writes land as drafts and nothing MCP does changes what the
|
|
19
|
+
* public sees. Requires `versions.drafts`.
|
|
20
|
+
* - `"live"`: MCP may change live content. On an entity with drafts that means
|
|
21
|
+
* `publishDocument` is exposed; on one without, where there is no draft to
|
|
22
|
+
* land on, it means the write itself is permitted and lands live.
|
|
23
|
+
*/
|
|
24
|
+
type McpxWriteMode = "draft" | "live" | false;
|
|
14
25
|
/**
|
|
15
26
|
* What an exposed collection offers to MCP clients. A key can only enable
|
|
16
27
|
* what the config exposes here.
|
|
@@ -21,15 +32,10 @@ interface McpxCollectionOptions {
|
|
|
21
32
|
*/
|
|
22
33
|
read?: boolean;
|
|
23
34
|
/**
|
|
24
|
-
* Expose `patchDocument`, `createDocument` and `validateDocument
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
write?: boolean;
|
|
28
|
-
/**
|
|
29
|
-
* Permit writes to a collection without drafts. Such writes land on the live
|
|
30
|
-
* document because there is no draft to land on. Default `false`.
|
|
35
|
+
* Expose `patchDocument`, `createDocument` and `validateDocument`, and how
|
|
36
|
+
* far those writes reach. Default `false`.
|
|
31
37
|
*/
|
|
32
|
-
|
|
38
|
+
write?: McpxWriteMode;
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
41
|
* What an exposed global offers to MCP clients. Structurally the same as
|
|
@@ -41,15 +47,10 @@ interface McpxGlobalOptions {
|
|
|
41
47
|
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
42
48
|
read?: boolean;
|
|
43
49
|
/**
|
|
44
|
-
* Expose `patchDocument` and `validateDocument
|
|
45
|
-
*
|
|
50
|
+
* Expose `patchDocument` and `validateDocument`, and how far those writes
|
|
51
|
+
* reach. Default `false`.
|
|
46
52
|
*/
|
|
47
|
-
write?:
|
|
48
|
-
/**
|
|
49
|
-
* Permit writes to a global without drafts. Such writes land on the live
|
|
50
|
-
* document because there is no draft to land on. Default `false`.
|
|
51
|
-
*/
|
|
52
|
-
allowLiveWrites?: boolean;
|
|
53
|
+
write?: McpxWriteMode;
|
|
53
54
|
}
|
|
54
55
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
55
56
|
/**
|
|
@@ -59,8 +60,7 @@ type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
|
59
60
|
interface McpxExposedEntity {
|
|
60
61
|
slug: string;
|
|
61
62
|
read: boolean;
|
|
62
|
-
write:
|
|
63
|
-
allowLiveWrites: boolean;
|
|
63
|
+
write: McpxWriteMode;
|
|
64
64
|
hasDrafts: boolean;
|
|
65
65
|
/** Name of the capability group on the key document. */
|
|
66
66
|
fieldName: string;
|
|
@@ -72,12 +72,14 @@ interface McpxExposedEntity {
|
|
|
72
72
|
interface McpxToolScope {
|
|
73
73
|
req: PayloadRequest;
|
|
74
74
|
capabilities: McpxResolvedCapabilities;
|
|
75
|
-
/** Collection slugs the key may read / write. */
|
|
75
|
+
/** Collection slugs the key may read / write / publish. */
|
|
76
76
|
readable: string[];
|
|
77
77
|
writable: string[];
|
|
78
|
-
|
|
78
|
+
publishable: string[];
|
|
79
|
+
/** Global slugs the key may read / write / publish. */
|
|
79
80
|
readableGlobals: string[];
|
|
80
81
|
writableGlobals: string[];
|
|
82
|
+
publishableGlobals: string[];
|
|
81
83
|
/** Configured locale codes, or `null` when localization is off. */
|
|
82
84
|
locales: null | string[];
|
|
83
85
|
defaultLocale: null | string;
|
|
@@ -214,6 +216,11 @@ type McpxPluginOptions = {
|
|
|
214
216
|
interface McpxCollectionCapabilities {
|
|
215
217
|
read: boolean;
|
|
216
218
|
write: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* Whether the key may publish this entity's draft. Only ever true where the
|
|
221
|
+
* config sets `write: "live"` and the entity has drafts.
|
|
222
|
+
*/
|
|
223
|
+
publish: boolean;
|
|
217
224
|
}
|
|
218
225
|
/**
|
|
219
226
|
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
@@ -238,4 +245,4 @@ interface PublishBlocker {
|
|
|
238
245
|
path: string;
|
|
239
246
|
}
|
|
240
247
|
//#endregion
|
|
241
|
-
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool };
|
|
248
|
+
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { claimPublishIntent, isClaimedPublish } from "./publish-intent.mjs";
|
|
1
2
|
import { isMcpxRequest } from "../request.mjs";
|
|
2
3
|
import { APIError } from "payload";
|
|
3
4
|
import { hasDraftsEnabled } from "payload/shared";
|
|
4
5
|
//#region src/write/draft-guard.ts
|
|
5
6
|
/**
|
|
6
7
|
* Operation arguments that widen or redirect a write. Cleared on every MCP
|
|
7
|
-
* create and update so a tool cannot smuggle them in.
|
|
8
|
+
* create and update, publishes included, so a tool cannot smuggle them in.
|
|
8
9
|
*/ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
9
10
|
"where",
|
|
10
11
|
"publishAllLocales",
|
|
@@ -15,75 +16,113 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
15
16
|
"overwriteExistingFiles"
|
|
16
17
|
]);
|
|
17
18
|
/**
|
|
18
|
-
* Forces every MCP write into a draft save
|
|
19
|
+
* Forces every MCP write into a draft save, unless it is the one write
|
|
20
|
+
* `publishDocument` asked for.
|
|
19
21
|
*
|
|
20
22
|
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
21
23
|
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
22
24
|
* This runs as `beforeOperation`, before Payload reads any of these arguments,
|
|
23
25
|
* so it holds for every create and update on an MCP request, not only the
|
|
24
26
|
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
25
|
-
* the integrator's responsibility.
|
|
26
|
-
|
|
27
|
+
* the integrator's responsibility. `restoreVersion` and `duplicate` are outside
|
|
28
|
+
* the operation filter too — `restoreVersion` is caught by `refusePublish`
|
|
29
|
+
* because it runs the collection's `beforeChange` hooks, and anything going
|
|
30
|
+
* straight to `payload.db` bypasses all of this.
|
|
31
|
+
*
|
|
32
|
+
* On a claimed publish the argument scrubbing is unchanged — the whole
|
|
33
|
+
* `STRIPPED_ARGS` list still goes, `deletedAt` still goes, autosave, locks and
|
|
34
|
+
* trash are still forced off. Only `draft` and `_status` differ. Writing
|
|
35
|
+
* `_status` here rather than in the tool keeps the tool honest: it asks to
|
|
36
|
+
* publish, and this is the only thing that can grant it.
|
|
37
|
+
*/ const scrubWriteArgs = (args, publishing) => {
|
|
27
38
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
28
39
|
if (next["data"] && typeof next["data"] === "object") {
|
|
29
40
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
30
|
-
next["data"] =
|
|
41
|
+
next["data"] = publishing ? {
|
|
42
|
+
...data,
|
|
43
|
+
_status: "published"
|
|
44
|
+
} : data;
|
|
31
45
|
}
|
|
32
|
-
next["draft"] =
|
|
46
|
+
next["draft"] = !publishing;
|
|
33
47
|
next["autosave"] = false;
|
|
34
48
|
next["overrideLock"] = false;
|
|
35
49
|
next["trash"] = false;
|
|
36
50
|
return next;
|
|
37
51
|
};
|
|
38
52
|
const forceDraftWrite = (hookArgs) => {
|
|
39
|
-
const { args, operation, req } = hookArgs;
|
|
53
|
+
const { args, collection, operation, req } = hookArgs;
|
|
40
54
|
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
41
|
-
|
|
55
|
+
const publishing = operation === "update" && claimPublishIntent({
|
|
56
|
+
kind: "collection",
|
|
57
|
+
slug: collection.slug,
|
|
58
|
+
id: args.id
|
|
59
|
+
});
|
|
60
|
+
return scrubWriteArgs(args, publishing);
|
|
42
61
|
};
|
|
43
62
|
/**
|
|
44
|
-
* The global counterpart of {@link forceDraftWrite}
|
|
45
|
-
* `
|
|
46
|
-
*
|
|
47
|
-
* `
|
|
48
|
-
*
|
|
63
|
+
* The global counterpart of {@link forceDraftWrite}, with one important
|
|
64
|
+
* difference: Payload's `updateGlobal` destructures `draft`,
|
|
65
|
+
* `publishAllLocales`, `publishSpecificLocale`, `unpublishAllLocales` and
|
|
66
|
+
* `overrideLock` *before* it runs `beforeOperation`, and re-reads only `data`
|
|
67
|
+
* afterwards. Setting those here is a no-op. What still lands is `data`, and
|
|
68
|
+
* that is what the global draft guarantee actually rests on: `_status` is
|
|
69
|
+
* stripped, so a rogue `updateGlobal({ draft: false })` reaches
|
|
70
|
+
* {@link refusePublishGlobal} with no status and is refused there. The alarm,
|
|
71
|
+
* not the correction, is load-bearing for globals.
|
|
72
|
+
*
|
|
73
|
+
* The publish branch matters for the same reason. `publishDocument` passes
|
|
74
|
+
* `draft: false` at the call site because the hook cannot, and this hook must
|
|
75
|
+
* put `_status` back rather than strip it.
|
|
49
76
|
*
|
|
50
77
|
* The global operation union has no `create` member because a global always
|
|
51
|
-
* exists, so only `update` is intercepted.
|
|
52
|
-
* publish vectors `updateGlobal` accepts; the rest of the set does not exist on
|
|
53
|
-
* that signature and filtering it is a harmless no-op. `slug` survives the
|
|
54
|
-
* filter, so the operation still knows what it is updating.
|
|
78
|
+
* exists, so only `update` is intercepted.
|
|
55
79
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
56
|
-
const { operation, req } = hookArgs;
|
|
80
|
+
const { global, operation, req } = hookArgs;
|
|
57
81
|
const args = hookArgs.args;
|
|
58
82
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
59
|
-
|
|
83
|
+
const publishing = claimPublishIntent({
|
|
84
|
+
kind: "global",
|
|
85
|
+
slug: global.slug
|
|
86
|
+
});
|
|
87
|
+
return scrubWriteArgs(args, publishing);
|
|
60
88
|
};
|
|
61
89
|
/**
|
|
62
|
-
* Refuses an MCP write that would still not land as a draft
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
|
|
90
|
+
* Refuses an MCP write that would still not land as a draft, and — on the one
|
|
91
|
+
* operation that claimed a publish intent — refuses anything that would not
|
|
92
|
+
* land as a publish. An alarm rather than the guarantee for collections, where
|
|
93
|
+
* `forceDraftWrite` should make it unreachable; the guarantee itself for
|
|
94
|
+
* globals, per {@link forceDraftWriteGlobal}. It throws instead of correcting
|
|
95
|
+
* `_status` because Payload has already chosen the write branch by the time a
|
|
96
|
+
* `beforeChange` hook runs.
|
|
97
|
+
*/ const refuseUnlessExpected = (req, target, data) => {
|
|
67
98
|
if (!isMcpxRequest(req)) return;
|
|
68
99
|
const status = data._status;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
100
|
+
const publishing = isClaimedPublish(target.kind, target.slug);
|
|
101
|
+
const expected = publishing ? "published" : "draft";
|
|
102
|
+
if (status === expected) return;
|
|
103
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${target.slug} that would not have been a ${expected} (_status: ${String(status)}).`);
|
|
104
|
+
throw new APIError(publishing ? "This publish was refused because it would not have saved a published document." : "MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
|
|
72
105
|
};
|
|
73
106
|
const refusePublish = ({ collection, data, req }) => {
|
|
74
|
-
|
|
107
|
+
refuseUnlessExpected(req, {
|
|
108
|
+
kind: "collection",
|
|
109
|
+
slug: collection.slug
|
|
110
|
+
}, data);
|
|
75
111
|
return data;
|
|
76
112
|
};
|
|
77
113
|
/** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
|
|
78
114
|
const next = data;
|
|
79
|
-
|
|
115
|
+
refuseUnlessExpected(req, {
|
|
116
|
+
kind: "global",
|
|
117
|
+
slug: global.slug
|
|
118
|
+
}, next);
|
|
80
119
|
return next;
|
|
81
120
|
};
|
|
82
121
|
/**
|
|
83
122
|
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
84
123
|
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
85
124
|
* exist. Applied to the built collection list so nothing can join later
|
|
86
|
-
* without being covered.
|
|
125
|
+
* without being covered. Both are appended last, so a user hook cannot win.
|
|
87
126
|
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
88
127
|
...collection,
|
|
89
128
|
hooks: {
|
package/dist/write/patch.mjs
CHANGED
|
@@ -155,7 +155,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
155
155
|
const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
|
|
156
156
|
if (pointers.includes("")) return ["an empty pointer addresses the whole document. Address a field instead."];
|
|
157
157
|
const reserved = pointers.find(isReservedPointer);
|
|
158
|
-
if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains.
|
|
158
|
+
if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. This tool only ever writes drafts, and id, _status, createdAt and updatedAt are not writable; use publishDocument to publish.`];
|
|
159
159
|
const dropped = droppedPointer(operation);
|
|
160
160
|
if (dropped !== void 0 && !isElementPointer(dropped)) return [`"${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
|
|
161
161
|
try {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
//#region src/write/publish-intent.ts
|
|
3
|
+
const store = new AsyncLocalStorage();
|
|
4
|
+
/** Runs `fn` with `intent` in force. */ const withPublishIntent = async (intent, fn) => await store.run({
|
|
5
|
+
...intent,
|
|
6
|
+
claimed: false
|
|
7
|
+
}, fn);
|
|
8
|
+
const activeFor = (target) => {
|
|
9
|
+
const active = store.getStore();
|
|
10
|
+
return active && active.kind === target.kind && active.slug === target.slug && active.id === target.id ? active : void 0;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Claims the intent for one operation, which `beforeOperation` does so that a
|
|
14
|
+
* re-entrant write to the same document — an `afterChange` hook calling
|
|
15
|
+
* `payload.update`, say — cannot ride along on it. Only the first operation to
|
|
16
|
+
* ask gets it.
|
|
17
|
+
*/ const claimPublishIntent = (target) => {
|
|
18
|
+
const active = activeFor(target);
|
|
19
|
+
if (!active || active.claimed) return false;
|
|
20
|
+
active.claimed = true;
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Whether this change belongs to the operation that claimed the intent, which
|
|
25
|
+
* is what lets the `beforeChange` alarm accept a published status.
|
|
26
|
+
*
|
|
27
|
+
* The id is deliberately not compared here. A `beforeChange` hook reads it from
|
|
28
|
+
* the loaded document, where Payload has already coerced it to the collection's
|
|
29
|
+
* id type, while the claim above saw the raw tool argument; comparing the two
|
|
30
|
+
* would refuse a legitimate publish over `1` versus `"1"`. Nothing is lost: a
|
|
31
|
+
* nested write to another document of the same collection during the publish
|
|
32
|
+
* cannot claim the intent, so `forceDraftWrite` has already stripped its
|
|
33
|
+
* `_status` and it fails the alarm on that.
|
|
34
|
+
*/ const isClaimedPublish = (kind, slug) => {
|
|
35
|
+
const active = store.getStore();
|
|
36
|
+
return active?.kind === kind && active.slug === slug && active.claimed;
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { claimPublishIntent, isClaimedPublish, withPublishIntent };
|
|
@@ -2,8 +2,14 @@ import { commitTransaction, initTransaction, killTransaction } from "payload";
|
|
|
2
2
|
//#region src/write/transaction.ts
|
|
3
3
|
/**
|
|
4
4
|
* Runs `fn` inside one database transaction on `req`, so a read followed by a
|
|
5
|
-
* write
|
|
5
|
+
* write is committed or rolled back together. Adapters without transaction
|
|
6
6
|
* support, or a request that already owns one, run `fn` as is.
|
|
7
|
+
*
|
|
8
|
+
* Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
|
|
9
|
+
* the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
|
|
10
|
+
* is this safe across the tool calls of one JSON-RPC batch, which share a
|
|
11
|
+
* request: the second caller joins the first's transaction, so one tool's
|
|
12
|
+
* rollback takes the other's work with it.
|
|
7
13
|
*/ const withTransaction = async (req, fn) => {
|
|
8
14
|
if (!await initTransaction(req)) return await fn();
|
|
9
15
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@abinnovision/payloadcms-mcpx",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.13",
|
|
5
5
|
"description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"payload",
|