@abinnovision/payloadcms-mcpx 1.0.0-beta.13 → 1.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -166
- package/dist/api-keys/fields.mjs +7 -3
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +1 -3
- package/dist/capabilities.mjs +8 -8
- package/dist/endpoint/errors.mjs +0 -2
- package/dist/endpoint/server.mjs +6 -20
- package/dist/i18n.mjs +4 -15
- package/dist/options.mjs +7 -20
- package/dist/result.d.mts +3 -5
- package/dist/result.mjs +3 -5
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/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 +4 -6
- 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 +10 -2
- package/dist/tools/patch-document.mjs +7 -1
- package/dist/tools/publish-document.mjs +13 -15
- package/dist/tools/shared.mjs +39 -37
- package/dist/tools/target.mjs +3 -7
- package/dist/tools/validate-document.mjs +9 -1
- package/dist/types.d.mts +38 -77
- package/dist/write/draft-guard.mjs +33 -65
- package/dist/write/patch.mjs +22 -60
- package/dist/write/publish-blockers.mjs +6 -12
- package/dist/write/publish-intent.mjs +13 -35
- package/dist/write/transaction.mjs +2 -3
- package/package.json +1 -1
|
@@ -6,7 +6,11 @@ import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
|
6
6
|
import { defineMcpxTool } from "../types.mjs";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { Pointer } from "rfc6902";
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* With `path` the handler returns the subtree plus the `id`, `_status` and
|
|
11
|
+
* `updatedAt` a client needs to write back, so a caller reading one branch
|
|
12
|
+
* still gets the timestamp `expectedUpdatedAt` wants without a second call.
|
|
13
|
+
*/ const getDocument = defineMcpxTool({
|
|
10
14
|
name: "getDocument",
|
|
11
15
|
description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
|
|
12
16
|
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import { canCreate } from "../capabilities.mjs";
|
|
1
2
|
import { jsonResult } from "../result.mjs";
|
|
2
3
|
import { translatorFor } from "../i18n.mjs";
|
|
3
4
|
import { translateLabel } from "./shared.mjs";
|
|
4
5
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
6
|
import { hasDraftValidationEnabled } from "payload/shared";
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Registered for every key, including one with no capabilities ticked, so a
|
|
9
|
+
* client always has something to call and gets an empty surface described
|
|
10
|
+
* rather than an empty tool list. The response is assembled from the request
|
|
11
|
+
* scope and the sanitized config, never from the content model, so it stays the
|
|
12
|
+
* same size as a deployment grows.
|
|
13
|
+
*/ const listCapabilities = defineMcpxTool({
|
|
7
14
|
name: "listCapabilities",
|
|
8
|
-
description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
|
|
15
|
+
description: `Lists what this key may do: the collections and globals it can read or write, whether a collection can also be created in, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
|
|
9
16
|
|
|
10
17
|
A global is a singleton: it has no id, is not listed by findDocuments and cannot be created. Address one with the "global" argument where a collection document would take "collection" and "id".`,
|
|
11
18
|
annotations: {
|
|
@@ -32,6 +39,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
|
|
|
32
39
|
...description === void 0 ? {} : { description },
|
|
33
40
|
read: capability.read,
|
|
34
41
|
write: capability.write,
|
|
42
|
+
create: capability.write && canCreate(entry),
|
|
35
43
|
publish: capability.publish,
|
|
36
44
|
drafts: entry.hasDrafts,
|
|
37
45
|
draftValidation: hasDraftValidationEnabled(config),
|
|
@@ -42,7 +42,13 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
42
42
|
const actual = pointer.get(saved);
|
|
43
43
|
return survives(expected, actual) ? [] : [operation.path];
|
|
44
44
|
});
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The handler validates the whole batch against the schema and the current
|
|
47
|
+
* document before it writes anything, runs the write in a transaction, then
|
|
48
|
+
* re-reads the saved document to report which pointers survived and what still
|
|
49
|
+
* blocks publishing. Nothing here decides where the write lands: the draft
|
|
50
|
+
* guard does that on the Payload operation.
|
|
51
|
+
*/ const patchDocument = defineMcpxTool({
|
|
46
52
|
name: "patchDocument",
|
|
47
53
|
description: DESCRIPTION,
|
|
48
54
|
annotations: {
|
|
@@ -5,7 +5,11 @@ import { defineMcpxTool } from "../types.mjs";
|
|
|
5
5
|
import { withTransaction } from "../write/transaction.mjs";
|
|
6
6
|
import { withPublishIntent } from "../write/publish-intent.mjs";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The only tool that changes live content, available where the config sets
|
|
10
|
+
* `write: "live"` on a versioned entity and the key has both the `write` and
|
|
11
|
+
* `publish` checkboxes.
|
|
12
|
+
*/ const publishDocument = defineMcpxTool({
|
|
9
13
|
name: "publishDocument",
|
|
10
14
|
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
15
|
|
|
@@ -40,7 +44,7 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
|
|
|
40
44
|
});
|
|
41
45
|
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
46
|
const write = {
|
|
43
|
-
data: {},
|
|
47
|
+
data: withPublishIntent({}),
|
|
44
48
|
depth: 0,
|
|
45
49
|
draft: false,
|
|
46
50
|
fallbackLocale: false,
|
|
@@ -48,20 +52,14 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
|
|
|
48
52
|
req: scope.req,
|
|
49
53
|
...locale === void 0 ? {} : { locale }
|
|
50
54
|
};
|
|
51
|
-
await
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
if (target.kind === "collection") await payload.update({
|
|
56
|
+
...write,
|
|
57
|
+
collection: target.slug,
|
|
54
58
|
id
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
id
|
|
60
|
-
});
|
|
61
|
-
else await payload.updateGlobal({
|
|
62
|
-
...write,
|
|
63
|
-
slug: target.slug
|
|
64
|
-
});
|
|
59
|
+
});
|
|
60
|
+
else await payload.updateGlobal({
|
|
61
|
+
...write,
|
|
62
|
+
slug: target.slug
|
|
65
63
|
});
|
|
66
64
|
const saved = await readTarget(scope, {
|
|
67
65
|
target,
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { canPublish, isLiveWrite } from "../capabilities.mjs";
|
|
1
|
+
import { canCreate, canPublish, isLiveWrite } from "../capabilities.mjs";
|
|
2
2
|
import { translateStatic } from "../i18n.mjs";
|
|
3
3
|
import { NotFound } from "payload";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
//#region src/tools/shared.ts
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* An out-of-scope slug fails schema validation before a handler runs, so a
|
|
8
|
+
* client only ever sees what its key may touch.
|
|
9
|
+
*/ const slugEnum = (slugs) => z.enum(slugs);
|
|
10
|
+
/** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
8
11
|
const slugsWhere = (scope, predicate, allowed) => {
|
|
9
12
|
const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
|
|
10
13
|
return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
|
|
@@ -17,33 +20,38 @@ const slugsWhere = (scope, predicate, allowed) => {
|
|
|
17
20
|
collections: scope.writable,
|
|
18
21
|
globals: scope.writableGlobals
|
|
19
22
|
});
|
|
23
|
+
/**
|
|
24
|
+
* Slugs this key may write but never create in, because their documents are
|
|
25
|
+
* files. Collection-only, since nothing creates a global either way.
|
|
26
|
+
*/ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
|
|
27
|
+
collections: scope.writable,
|
|
28
|
+
globals: []
|
|
29
|
+
});
|
|
20
30
|
/** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
|
|
21
31
|
collections: scope.publishable,
|
|
22
32
|
globals: scope.publishableGlobals
|
|
23
33
|
});
|
|
24
34
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* while they are not, nor that publishing is out of reach when it is not.
|
|
35
|
+
* What a write actually does for this key, and what it takes to make it public.
|
|
36
|
+
* A live-write slug has no draft and no publish step; a publishable one has
|
|
37
|
+
* both. Stated per key so a client is never told its writes are drafts while
|
|
38
|
+
* they are not, nor that publishing is out of reach when it is not.
|
|
30
39
|
*/ const draftSentence = (scope) => {
|
|
31
40
|
const live = liveWriteSlugs(scope);
|
|
32
41
|
const publishable = publishableWriteSlugs(scope);
|
|
33
42
|
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.`}`;
|
|
34
43
|
};
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
41
|
-
* Widens one branch to the superset a handler sees. The widening itself is
|
|
42
|
-
* unchecked — the runtime shape really does vary — so `Branch` checks what it
|
|
43
|
-
* can around it.
|
|
44
|
-
*/ const widen = (branch) => branch;
|
|
45
|
-
const slugsFor = (scope, operation) => {
|
|
44
|
+
/** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
45
|
+
/** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
|
|
46
|
+
/** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
|
|
46
47
|
switch (operation) {
|
|
48
|
+
case "create": return {
|
|
49
|
+
collections: slugsWhere(scope, canCreate, {
|
|
50
|
+
collections: scope.writable,
|
|
51
|
+
globals: []
|
|
52
|
+
}),
|
|
53
|
+
globals: []
|
|
54
|
+
};
|
|
47
55
|
case "publish": return {
|
|
48
56
|
collections: scope.publishable,
|
|
49
57
|
globals: scope.publishableGlobals
|
|
@@ -59,13 +67,9 @@ const slugsFor = (scope, operation) => {
|
|
|
59
67
|
}
|
|
60
68
|
};
|
|
61
69
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* and `collection` stays required, mirroring how {@link localeShape} omits
|
|
66
|
-
* `locale` when localization is off. A deployment without globals therefore
|
|
67
|
-
* sees exactly the schema it saw before. Only the mixed case makes either
|
|
68
|
-
* argument optional, and the handler enforces the exclusivity there.
|
|
70
|
+
* With no reachable global, `global` is left out and `collection` stays
|
|
71
|
+
* required, so a deployment without globals sees an unchanged schema. Only the
|
|
72
|
+
* mixed case makes either optional, and the handler enforces exclusivity there.
|
|
69
73
|
*/ const targetShape = (scope, operation, descriptions) => {
|
|
70
74
|
const { collections, globals } = slugsFor(scope, operation);
|
|
71
75
|
if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
|
|
@@ -76,23 +80,23 @@ const slugsFor = (scope, operation) => {
|
|
|
76
80
|
});
|
|
77
81
|
};
|
|
78
82
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* in between, where `requireIdFor` enforces the dependency.
|
|
83
|
+
* Only a collection document has one. Optional in the mixed case, where
|
|
84
|
+
* `requireIdFor` enforces the dependency.
|
|
82
85
|
*/ const idShape = (scope, operation) => {
|
|
83
86
|
const { collections, globals } = slugsFor(scope, operation);
|
|
84
87
|
if (collections.length === 0) return widen({});
|
|
85
88
|
if (globals.length === 0) return widen({ id: idSchema });
|
|
86
89
|
return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
|
|
87
90
|
};
|
|
88
|
-
|
|
89
|
-
* The `locale` argument, present only when localization is configured.
|
|
90
|
-
*/ const localeShape = (scope, options) => {
|
|
91
|
+
const localeShape = (scope, options) => {
|
|
91
92
|
if (!scope.locales) return widen({});
|
|
92
93
|
const locale = z.enum(scope.locales);
|
|
93
94
|
return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
|
|
94
95
|
};
|
|
95
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Defaults to 0 rather than Payload's own default: a client usually wants ids
|
|
98
|
+
* it can write back, and populating a relation costs a query.
|
|
99
|
+
*/ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
|
|
96
100
|
/**
|
|
97
101
|
* The locale to operate on: the explicit argument, else the request's, else
|
|
98
102
|
* the default. `undefined` when localization is off.
|
|
@@ -133,9 +137,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
|
|
|
133
137
|
slug: args.target.slug
|
|
134
138
|
});
|
|
135
139
|
};
|
|
136
|
-
|
|
137
|
-
* Resolves a collection label for the request's language.
|
|
138
|
-
*/ const translateLabel = (scope, label, fallback) => {
|
|
140
|
+
const translateLabel = (scope, label, fallback) => {
|
|
139
141
|
const { i18n, t } = scope.req;
|
|
140
142
|
const resolved = typeof label === "function" ? label({
|
|
141
143
|
i18n,
|
|
@@ -144,4 +146,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
|
|
|
144
146
|
return translateStatic(resolved, i18n) ?? fallback;
|
|
145
147
|
};
|
|
146
148
|
//#endregion
|
|
147
|
-
export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
|
|
149
|
+
export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
|
package/dist/tools/target.mjs
CHANGED
|
@@ -6,13 +6,9 @@ const refOf = (target) => ({
|
|
|
6
6
|
slug: target.slug
|
|
7
7
|
});
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* A tool's `inputSchema` returns a raw shape, which leaves no top-level
|
|
13
|
-
* `.refine` to express "exactly one of collection and global". The rule is
|
|
14
|
-
* enforced here instead, with a message naming the offending arguments so one
|
|
15
|
-
* failed call teaches it.
|
|
9
|
+
* A raw input shape leaves no top-level `.refine` to express "exactly one of
|
|
10
|
+
* collection and global", so the rule is enforced here, with a message naming
|
|
11
|
+
* the offending arguments.
|
|
16
12
|
*/ const resolveTarget = (scope, args, operation) => {
|
|
17
13
|
const { collection, global } = args;
|
|
18
14
|
const allowedSlugs = slugsFor(scope, operation);
|
|
@@ -3,7 +3,15 @@ import { idShape, localeOf, localeShape, readTarget, targetShape } from "./share
|
|
|
3
3
|
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Gated on write rather than read, because publish blockers only mean
|
|
8
|
+
* something to a caller who can act on them.
|
|
9
|
+
*
|
|
10
|
+
* It reads the document twice on purpose: once under the key's own access to
|
|
11
|
+
* refuse a caller who may not see it, then privileged, so the check runs over
|
|
12
|
+
* every field rather than the subset the user can read. It carries no
|
|
13
|
+
* `readOnlyHint`, because the traversal fires field hooks.
|
|
14
|
+
*/ const validateDocument = defineMcpxTool({
|
|
7
15
|
name: "validateDocument",
|
|
8
16
|
description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
|
|
9
17
|
|
package/dist/types.d.mts
CHANGED
|
@@ -22,27 +22,18 @@ declare module "payload" {
|
|
|
22
22
|
* land on, it means the write itself is permitted and lands live.
|
|
23
23
|
*/
|
|
24
24
|
type McpxWriteMode = "draft" | "live" | false;
|
|
25
|
-
/**
|
|
26
|
-
* What an exposed collection offers to MCP clients. A key can only enable
|
|
27
|
-
* what the config exposes here.
|
|
28
|
-
*/
|
|
25
|
+
/** A key can only enable what the config exposes here. */
|
|
29
26
|
interface McpxCollectionOptions {
|
|
30
|
-
/**
|
|
31
|
-
* Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`.
|
|
32
|
-
*/
|
|
27
|
+
/** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
|
|
33
28
|
read?: boolean;
|
|
34
29
|
/**
|
|
35
|
-
* Expose `patchDocument`, `
|
|
36
|
-
* far those writes reach. Default
|
|
30
|
+
* Expose `patchDocument`, `validateDocument` and, unless this is an upload
|
|
31
|
+
* collection, `createDocument`, and how far those writes reach. Default
|
|
32
|
+
* `false`.
|
|
37
33
|
*/
|
|
38
34
|
write?: McpxWriteMode;
|
|
39
35
|
}
|
|
40
|
-
/**
|
|
41
|
-
* What an exposed global offers to MCP clients. Structurally the same as
|
|
42
|
-
* {@link McpxCollectionOptions}, kept separate because the tools it names
|
|
43
|
-
* differ: a global is a singleton, so neither `findDocuments` nor
|
|
44
|
-
* `createDocument` reaches one.
|
|
45
|
-
*/
|
|
36
|
+
/** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
|
|
46
37
|
interface McpxGlobalOptions {
|
|
47
38
|
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
48
39
|
read?: boolean;
|
|
@@ -53,77 +44,60 @@ interface McpxGlobalOptions {
|
|
|
53
44
|
write?: McpxWriteMode;
|
|
54
45
|
}
|
|
55
46
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
56
|
-
/**
|
|
57
|
-
* A collection or global the plugin config exposes, before an API key's
|
|
58
|
-
* checkboxes narrow it further.
|
|
59
|
-
*/
|
|
47
|
+
/** What the config exposes, before an API key's checkboxes narrow it. */
|
|
60
48
|
interface McpxExposedEntity {
|
|
61
49
|
slug: string;
|
|
62
50
|
read: boolean;
|
|
63
51
|
write: McpxWriteMode;
|
|
64
52
|
hasDrafts: boolean;
|
|
53
|
+
/** An upload document is a file, and no tool here can supply one. */
|
|
54
|
+
isUpload: boolean;
|
|
65
55
|
/** Name of the capability group on the key document. */
|
|
66
56
|
fieldName: string;
|
|
67
57
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Everything a tool knows about the current request: the authenticated
|
|
70
|
-
* request, what this key may touch and the limits in force.
|
|
71
|
-
*/
|
|
58
|
+
/** What a tool knows about the current request. */
|
|
72
59
|
interface McpxToolScope {
|
|
73
60
|
req: PayloadRequest;
|
|
74
61
|
capabilities: McpxResolvedCapabilities;
|
|
75
|
-
/** Collection slugs the key may read / write / publish. */
|
|
76
62
|
readable: string[];
|
|
77
63
|
writable: string[];
|
|
78
64
|
publishable: string[];
|
|
79
|
-
/** Global slugs the key may read / write / publish. */
|
|
80
65
|
readableGlobals: string[];
|
|
81
66
|
writableGlobals: string[];
|
|
82
67
|
publishableGlobals: string[];
|
|
83
|
-
/**
|
|
68
|
+
/** `null` when localization is off. */
|
|
84
69
|
locales: null | string[];
|
|
85
70
|
defaultLocale: null | string;
|
|
86
71
|
limits: {
|
|
87
72
|
maxLimit: number;
|
|
88
73
|
maxDepth: number;
|
|
89
74
|
};
|
|
90
|
-
/** What the plugin config exposes, before the key's checkboxes apply. */
|
|
91
75
|
exposure: {
|
|
92
76
|
collections: McpxExposedEntity[];
|
|
93
77
|
globals: McpxExposedEntity[];
|
|
94
78
|
};
|
|
95
79
|
}
|
|
96
80
|
/**
|
|
97
|
-
* A tool
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* `Args` only needs stating when `inputSchema` is built per request, which
|
|
102
|
-
* leaves no static shape to infer from; a tool with a fixed shape gets its
|
|
103
|
-
* argument type from that shape.
|
|
81
|
+
* A tool, builtin or custom. Runs with `req.user` resolved from the key and
|
|
82
|
+
* `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
|
|
83
|
+
* per request, leaving no static shape to infer from.
|
|
104
84
|
*/
|
|
105
85
|
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
106
86
|
/** camelCase, unique, not one of the builtin tool names. */
|
|
107
87
|
name: string;
|
|
108
|
-
/**
|
|
109
|
-
* Fixed text, or text built per request so it can state what this key's
|
|
110
|
-
* writes actually do.
|
|
111
|
-
*/
|
|
88
|
+
/** Built per request so it can state what this key's writes actually do. */
|
|
112
89
|
description: string | ((scope: McpxToolScope) => string);
|
|
113
90
|
annotations?: ToolAnnotations;
|
|
114
91
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* collection and global capabilities. Defining it replaces that checkbox
|
|
119
|
-
* check rather than adding to it.
|
|
92
|
+
* A tool that is not enabled never appears in `tools/list`. Defaults to the
|
|
93
|
+
* tool's own checkbox on the API key; defining it replaces that check rather
|
|
94
|
+
* than adding to it.
|
|
120
95
|
*/
|
|
121
96
|
isEnabled?: (scope: McpxToolScope) => boolean;
|
|
122
97
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* it had not been passed.
|
|
98
|
+
* Built per request so enums can be narrowed to what the key may touch.
|
|
99
|
+
* Registered strictly either way: an unknown argument is rejected by name
|
|
100
|
+
* rather than stripped.
|
|
127
101
|
*/
|
|
128
102
|
inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
|
|
129
103
|
handler(ctx: {
|
|
@@ -134,44 +108,36 @@ interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z
|
|
|
134
108
|
extra: McpxToolExtra;
|
|
135
109
|
}): CallToolResult | Promise<CallToolResult>;
|
|
136
110
|
}
|
|
137
|
-
/**
|
|
138
|
-
* A tool with its argument type erased, which is how a registry holds tools of
|
|
139
|
-
* differing input shapes. Each tool validates its own arguments through its
|
|
140
|
-
* input schema.
|
|
141
|
-
*/
|
|
111
|
+
/** Argument type erased, so a registry can hold tools of differing shapes. */
|
|
142
112
|
type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
143
|
-
/**
|
|
144
|
-
* Defines a tool with a fixed input shape. The handler's arguments are
|
|
145
|
-
* inferred from that shape.
|
|
146
|
-
*/
|
|
113
|
+
/** Fixed shape; arguments inferred from it. */
|
|
147
114
|
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
148
115
|
inputSchema?: Shape;
|
|
149
116
|
}): McpxTool<Shape>;
|
|
150
|
-
/**
|
|
151
|
-
* Defines a tool whose input shape is built per request and returned as an
|
|
152
|
-
* object literal. The handler's arguments are inferred from that literal, so
|
|
153
|
-
* a scope-narrowed enum still types as the value it produces.
|
|
154
|
-
*/
|
|
117
|
+
/** Per-request shape returned as an object literal; arguments inferred from it. */
|
|
155
118
|
declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
156
119
|
inputSchema: (scope: McpxToolScope) => Shape;
|
|
157
120
|
}): McpxAnyTool;
|
|
158
121
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* handler's arguments are stated instead: `defineMcpxTool<Args>({ ... })`.
|
|
122
|
+
* Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
|
|
123
|
+
* builtins do. Nothing to infer from, so state the arguments instead.
|
|
162
124
|
*/
|
|
163
125
|
declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
|
|
164
126
|
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
165
127
|
}): McpxAnyTool;
|
|
166
|
-
/**
|
|
167
|
-
* Outcome of resolving an API key. `user` must carry `collection`.
|
|
168
|
-
*/
|
|
169
128
|
interface McpxAuthResult {
|
|
129
|
+
/** Must carry `collection`. */
|
|
170
130
|
user: TypedUser;
|
|
171
131
|
apiKeyId: number | string;
|
|
172
132
|
/** The `capabilities` group as stored on the key document. */
|
|
173
133
|
capabilities: unknown;
|
|
174
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Everything the plugin accepts. `collections` is the only required option.
|
|
137
|
+
*
|
|
138
|
+
* A type alias rather than an interface: `definePlugin` constrains its options
|
|
139
|
+
* to `Record<string, unknown>`, which interfaces do not satisfy.
|
|
140
|
+
*/
|
|
175
141
|
type McpxPluginOptions = {
|
|
176
142
|
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
177
143
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
@@ -213,30 +179,25 @@ type McpxPluginOptions = {
|
|
|
213
179
|
version?: string;
|
|
214
180
|
};
|
|
215
181
|
};
|
|
182
|
+
/** What a key may do with one entity. Globals reuse this shape. */
|
|
216
183
|
interface McpxCollectionCapabilities {
|
|
217
184
|
read: boolean;
|
|
218
185
|
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
|
-
*/
|
|
186
|
+
/** Only ever true where the config sets `write: "live"` and drafts exist. */
|
|
223
187
|
publish: boolean;
|
|
224
188
|
}
|
|
225
|
-
/**
|
|
226
|
-
* Capabilities in force for one request: plugin config AND key checkboxes.
|
|
227
|
-
*/
|
|
189
|
+
/** In force for one request: plugin config AND key checkboxes. */
|
|
228
190
|
interface McpxResolvedCapabilities {
|
|
229
191
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
230
192
|
globals: Record<string, McpxCollectionCapabilities>;
|
|
231
193
|
tools: Record<string, boolean>;
|
|
232
194
|
}
|
|
195
|
+
/** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
|
|
233
196
|
interface McpxRequestContext {
|
|
234
197
|
apiKeyId: number | string;
|
|
235
198
|
capabilities: McpxResolvedCapabilities;
|
|
236
199
|
}
|
|
237
|
-
/**
|
|
238
|
-
* One reason a human could not publish the draft as it stands.
|
|
239
|
-
*/
|
|
200
|
+
/** One reason a human could not publish the draft as it stands. */
|
|
240
201
|
interface PublishBlocker {
|
|
241
202
|
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
242
203
|
field?: string;
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
|
|
2
2
|
import { isMcpxRequest } from "../request.mjs";
|
|
3
3
|
import { APIError } from "payload";
|
|
4
4
|
import { hasDraftsEnabled } from "payload/shared";
|
|
5
5
|
//#region src/write/draft-guard.ts
|
|
6
|
-
/**
|
|
7
|
-
* Operation arguments that widen or redirect a write. Cleared on every MCP
|
|
8
|
-
* create and update, publishes included, so a tool cannot smuggle them in.
|
|
9
|
-
*/ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
6
|
+
/** Cleared on every MCP write, publishes included, so none can be smuggled in. */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
10
7
|
"where",
|
|
11
8
|
"publishAllLocales",
|
|
12
9
|
"publishSpecificLocale",
|
|
@@ -21,19 +18,13 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
21
18
|
*
|
|
22
19
|
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
23
20
|
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
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.
|
|
21
|
+
* Writing it here rather than in the tool keeps the tool honest, since this is
|
|
22
|
+
* the only thing that can grant a publish.
|
|
31
23
|
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* publish, and this is the only thing that can grant it.
|
|
24
|
+
* Not covered: deletes, `duplicate`, files (the local API lifts `file` and
|
|
25
|
+
* `filePath` onto `req` before this runs), and anything going straight to
|
|
26
|
+
* `payload.db`. `restoreVersion` is caught by {@link refusePublish} instead,
|
|
27
|
+
* because it runs the collection's `beforeChange` hooks.
|
|
37
28
|
*/ const scrubWriteArgs = (args, publishing) => {
|
|
38
29
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
39
30
|
if (next["data"] && typeof next["data"] === "object") {
|
|
@@ -50,72 +41,49 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
50
41
|
return next;
|
|
51
42
|
};
|
|
52
43
|
const forceDraftWrite = (hookArgs) => {
|
|
53
|
-
const { args,
|
|
44
|
+
const { args, operation, req } = hookArgs;
|
|
54
45
|
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
55
|
-
const publishing = operation === "update" &&
|
|
56
|
-
kind: "collection",
|
|
57
|
-
slug: collection.slug,
|
|
58
|
-
id: args.id
|
|
59
|
-
});
|
|
46
|
+
const publishing = operation === "update" && hasPublishIntent(args.data);
|
|
60
47
|
return scrubWriteArgs(args, publishing);
|
|
61
48
|
};
|
|
62
49
|
/**
|
|
63
|
-
* The global counterpart of {@link forceDraftWrite}, with one
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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.
|
|
76
|
-
*
|
|
77
|
-
* The global operation union has no `create` member because a global always
|
|
78
|
-
* exists, so only `update` is intercepted.
|
|
50
|
+
* The global counterpart of {@link forceDraftWrite}, with one difference that
|
|
51
|
+
* decides where the guarantee lives: `updateGlobal` destructures `draft` and
|
|
52
|
+
* the publish arguments *before* it runs `beforeOperation` and re-reads only
|
|
53
|
+
* `data` afterwards, so setting them here is a no-op. What lands is `data` with
|
|
54
|
+
* `_status` stripped, which makes {@link refusePublishGlobal} the alarm that
|
|
55
|
+
* actually holds the line. `publishDocument` therefore passes `draft: false` at
|
|
56
|
+
* the call site, and this hook puts `_status` back rather than stripping it.
|
|
79
57
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
80
|
-
const {
|
|
58
|
+
const { operation, req } = hookArgs;
|
|
81
59
|
const args = hookArgs.args;
|
|
82
60
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
83
|
-
|
|
84
|
-
kind: "global",
|
|
85
|
-
slug: global.slug
|
|
86
|
-
});
|
|
87
|
-
return scrubWriteArgs(args, publishing);
|
|
61
|
+
return scrubWriteArgs(args, hasPublishIntent(args["data"]));
|
|
88
62
|
};
|
|
89
63
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* `beforeChange` hook runs.
|
|
97
|
-
*/ const refuseUnlessExpected = (req, target, data) => {
|
|
64
|
+
* Throws instead of correcting `_status`, because Payload has already chosen
|
|
65
|
+
* the write branch by the time a `beforeChange` hook runs. Unreachable for a
|
|
66
|
+
* collection if {@link forceDraftWrite} did its job; the guarantee itself for a
|
|
67
|
+
* global. Last hook that needs the marker, so it takes it off.
|
|
68
|
+
*/ const refuseUnlessExpected = (req, slug, data) => {
|
|
69
|
+
const publishing = takePublishIntent(data);
|
|
98
70
|
if (!isMcpxRequest(req)) return;
|
|
99
71
|
const status = data._status;
|
|
100
|
-
const publishing = isClaimedPublish(target.kind, target.slug);
|
|
101
72
|
const expected = publishing ? "published" : "draft";
|
|
102
73
|
if (status === expected) return;
|
|
103
|
-
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${
|
|
74
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
|
|
104
75
|
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);
|
|
105
76
|
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Installs {@link refuseUnlessExpected} on every collection write. Returns
|
|
79
|
+
* `data` unchanged when the write is allowed; the hook exists for its throw.
|
|
80
|
+
*/ const refusePublish = ({ collection, data, req }) => {
|
|
81
|
+
refuseUnlessExpected(req, collection.slug, data);
|
|
111
82
|
return data;
|
|
112
83
|
};
|
|
113
|
-
|
|
84
|
+
const refusePublishGlobal = ({ data, global, req }) => {
|
|
114
85
|
const next = data;
|
|
115
|
-
refuseUnlessExpected(req,
|
|
116
|
-
kind: "global",
|
|
117
|
-
slug: global.slug
|
|
118
|
-
}, next);
|
|
86
|
+
refuseUnlessExpected(req, global.slug, next);
|
|
119
87
|
return next;
|
|
120
88
|
};
|
|
121
89
|
/**
|