@abinnovision/payloadcms-mcpx 1.0.0-beta.4 → 1.0.0-beta.5
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 +65 -16
- package/dist/api-keys/fields.mjs +27 -12
- package/dist/capabilities.mjs +16 -3
- package/dist/endpoint/handler.mjs +3 -1
- package/dist/endpoint/server.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/options.d.mts +2 -0
- package/dist/options.mjs +38 -0
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +3 -2
- package/dist/schema/describe.mjs +11 -10
- package/dist/schema/pointer.mjs +2 -2
- package/dist/schema/walk.d.mts +1 -0
- package/dist/schema/walk.mjs +4 -4
- package/dist/tools/create-document.mjs +9 -8
- package/dist/tools/describe-schema.mjs +12 -6
- package/dist/tools/find-documents.mjs +4 -3
- package/dist/tools/get-document.mjs +24 -11
- package/dist/tools/list-capabilities.mjs +19 -1
- package/dist/tools/patch-document.mjs +34 -20
- package/dist/tools/shared.mjs +54 -21
- package/dist/tools/target.d.mts +3 -0
- package/dist/tools/target.mjs +49 -0
- package/dist/tools/types.d.mts +5 -0
- package/dist/tools/validate-document.mjs +22 -15
- package/dist/types.d.mts +25 -2
- package/dist/write/draft-guard.mjs +51 -8
- package/dist/write/patch.mjs +4 -4
- package/dist/write/publish-blockers.d.mts +1 -0
- package/dist/write/publish-blockers.mjs +8 -7
- package/package.json +1 -1
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
|
|
2
2
|
import { errorResult, jsonResult } from "../endpoint/result.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
|
|
4
|
+
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { Pointer } from "rfc6902";
|
|
6
7
|
//#region src/tools/get-document.ts
|
|
7
8
|
const getDocument = {
|
|
8
9
|
name: "getDocument",
|
|
9
|
-
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
|
|
10
|
+
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.
|
|
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.`,
|
|
10
13
|
annotations: {
|
|
11
14
|
readOnlyHint: true,
|
|
12
15
|
openWorldHint: false
|
|
13
16
|
},
|
|
14
|
-
isEnabled: (scope) => scope.readable.length > 0,
|
|
17
|
+
isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
|
|
15
18
|
inputSchema: (scope) => ({
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
...targetShape(scope, "read", {
|
|
20
|
+
collection: "Collection holding the document.",
|
|
21
|
+
global: "Global to read."
|
|
22
|
+
}),
|
|
23
|
+
...idShape(scope, "read"),
|
|
18
24
|
path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("JSON pointer to return only a subtree, e.g. \"/layout/sections/0\"."),
|
|
19
25
|
...depthShape(scope),
|
|
20
26
|
...localeShape(scope, {
|
|
@@ -24,17 +30,24 @@ const getDocument = {
|
|
|
24
30
|
draft: z.boolean().optional().describe("Return the latest draft. Default true.")
|
|
25
31
|
}),
|
|
26
32
|
handler: async (args, scope) => {
|
|
27
|
-
|
|
33
|
+
const target = resolveTarget(scope, args, "read");
|
|
34
|
+
const id = requireIdFor(target, args.id);
|
|
28
35
|
const locale = localeOf(scope, args.locale);
|
|
29
|
-
const
|
|
30
|
-
collection: args.collection,
|
|
31
|
-
id: args.id,
|
|
36
|
+
const shared = {
|
|
32
37
|
depth: args.depth ?? 0,
|
|
33
38
|
draft: args.draft ?? true,
|
|
34
39
|
overrideAccess: false,
|
|
35
40
|
req: scope.req,
|
|
36
41
|
...locale === void 0 ? {} : { locale }
|
|
37
|
-
}
|
|
42
|
+
};
|
|
43
|
+
const doc = await (target.kind === "collection" ? scope.req.payload.findByID({
|
|
44
|
+
...shared,
|
|
45
|
+
collection: target.slug,
|
|
46
|
+
id
|
|
47
|
+
}) : scope.req.payload.findGlobal({
|
|
48
|
+
...shared,
|
|
49
|
+
slug: target.slug
|
|
50
|
+
}));
|
|
38
51
|
if (args.path === void 0 || args.path === "") return jsonResult(doc);
|
|
39
52
|
let value;
|
|
40
53
|
try {
|
|
@@ -43,7 +56,7 @@ const getDocument = {
|
|
|
43
56
|
return errorResult(`"${args.path}" is not a valid JSON pointer.`);
|
|
44
57
|
}
|
|
45
58
|
return jsonResult({
|
|
46
|
-
id: doc["id"],
|
|
59
|
+
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
47
60
|
status: doc["_status"],
|
|
48
61
|
updatedAt: doc["updatedAt"],
|
|
49
62
|
path: args.path,
|
|
@@ -5,7 +5,9 @@ import { hasDraftValidationEnabled } from "payload/shared";
|
|
|
5
5
|
//#region src/tools/list-capabilities.ts
|
|
6
6
|
const listCapabilities = {
|
|
7
7
|
name: "listCapabilities",
|
|
8
|
-
description: `Lists what this key may do: the collections 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
|
|
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.
|
|
9
|
+
|
|
10
|
+
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".`,
|
|
9
11
|
annotations: {
|
|
10
12
|
readOnlyHint: true,
|
|
11
13
|
openWorldHint: false
|
|
@@ -34,8 +36,24 @@ const listCapabilities = {
|
|
|
34
36
|
idType: collection.customIDType ?? payload.db.defaultIDType
|
|
35
37
|
}];
|
|
36
38
|
});
|
|
39
|
+
const globals = scope.options.globals.flatMap((entry) => {
|
|
40
|
+
const capability = scope.capabilities.globals[entry.slug];
|
|
41
|
+
const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
|
|
42
|
+
if (!capability || !config || !(capability.read || capability.write)) return [];
|
|
43
|
+
const description = staticDescription(config.admin.description);
|
|
44
|
+
return [{
|
|
45
|
+
slug: entry.slug,
|
|
46
|
+
label: translateLabel(scope, config.label, entry.slug),
|
|
47
|
+
...description === void 0 ? {} : { description },
|
|
48
|
+
read: capability.read,
|
|
49
|
+
write: capability.write,
|
|
50
|
+
drafts: entry.hasDrafts,
|
|
51
|
+
draftValidation: hasDraftValidationEnabled(config)
|
|
52
|
+
}];
|
|
53
|
+
});
|
|
37
54
|
return Promise.resolve(jsonResult({
|
|
38
55
|
collections,
|
|
56
|
+
...globals.length > 0 ? { globals } : {},
|
|
39
57
|
locales: scope.locales ? {
|
|
40
58
|
codes: scope.locales,
|
|
41
59
|
default: scope.defaultLocale
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errorResult, jsonResult } from "../endpoint/result.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
|
+
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
3
4
|
import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
|
|
4
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
5
6
|
import { withTransaction } from "../write/transaction.mjs";
|
|
@@ -8,6 +9,8 @@ import { Pointer } from "rfc6902";
|
|
|
8
9
|
//#region src/tools/patch-document.ts
|
|
9
10
|
const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
|
|
10
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
|
+
|
|
11
14
|
The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
|
|
12
15
|
|
|
13
16
|
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.
|
|
@@ -48,10 +51,13 @@ const patchDocument = {
|
|
|
48
51
|
idempotentHint: false,
|
|
49
52
|
openWorldHint: false
|
|
50
53
|
},
|
|
51
|
-
isEnabled: (scope) => scope.writable.length > 0,
|
|
54
|
+
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
52
55
|
inputSchema: (scope) => ({
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
...targetShape(scope, "write", {
|
|
57
|
+
collection: "Collection holding the document.",
|
|
58
|
+
global: "Global to patch."
|
|
59
|
+
}),
|
|
60
|
+
...idShape(scope, "write"),
|
|
55
61
|
...localeShape(scope, {
|
|
56
62
|
required: true,
|
|
57
63
|
description: "Locale the patch applies to. Localized fields write here only."
|
|
@@ -60,47 +66,55 @@ const patchDocument = {
|
|
|
60
66
|
expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
|
|
61
67
|
}),
|
|
62
68
|
handler: async (args, scope) => {
|
|
63
|
-
const
|
|
69
|
+
const target = resolveTarget(scope, args, "write");
|
|
70
|
+
const id = requireIdFor(target, args.id);
|
|
64
71
|
const { payload } = scope.req;
|
|
65
72
|
const locale = localeOf(scope, args.locale);
|
|
66
73
|
return await withTransaction(scope.req, async () => {
|
|
67
|
-
const doc = await
|
|
68
|
-
|
|
69
|
-
id
|
|
74
|
+
const doc = await readTarget(scope, {
|
|
75
|
+
target,
|
|
76
|
+
id,
|
|
70
77
|
locale
|
|
71
78
|
});
|
|
72
79
|
if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again and re-apply the patch.", { updatedAt: doc["updatedAt"] });
|
|
73
80
|
const problems = findPatchProblems(payload.config, {
|
|
74
|
-
collection: args.collection,
|
|
75
81
|
doc,
|
|
76
|
-
patches: args.patches
|
|
82
|
+
patches: args.patches,
|
|
83
|
+
ref: refOf(target)
|
|
77
84
|
});
|
|
78
85
|
if (problems.length > 0) return errorResult("No operation was applied.", { problems });
|
|
79
86
|
const applied = applyPatchToCopy(doc, args.patches);
|
|
80
87
|
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
id: args.id,
|
|
84
|
-
data: buildWriteData(payload.config, collection, applied.next),
|
|
88
|
+
const write = {
|
|
89
|
+
data: buildWriteData(payload.config, target.config, applied.next),
|
|
85
90
|
depth: 0,
|
|
86
91
|
draft: true,
|
|
87
92
|
overrideAccess: false,
|
|
88
93
|
req: scope.req,
|
|
89
94
|
...locale === void 0 ? {} : { locale }
|
|
95
|
+
};
|
|
96
|
+
if (target.kind === "collection") await payload.update({
|
|
97
|
+
...write,
|
|
98
|
+
collection: target.slug,
|
|
99
|
+
id
|
|
100
|
+
});
|
|
101
|
+
else await payload.updateGlobal({
|
|
102
|
+
...write,
|
|
103
|
+
slug: target.slug
|
|
90
104
|
});
|
|
91
|
-
const saved = await
|
|
92
|
-
|
|
93
|
-
id
|
|
105
|
+
const saved = await readTarget(scope, {
|
|
106
|
+
target,
|
|
107
|
+
id,
|
|
94
108
|
locale,
|
|
95
109
|
privileged: true
|
|
96
110
|
});
|
|
97
111
|
const notApplied = notAppliedPointers(args.patches, applied.next, saved);
|
|
98
112
|
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
doc: saved,
|
|
114
|
+
entity: target
|
|
101
115
|
});
|
|
102
116
|
return jsonResult({
|
|
103
|
-
id: saved["id"],
|
|
117
|
+
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
104
118
|
status: saved["_status"],
|
|
105
119
|
updatedAt: saved["updatedAt"],
|
|
106
120
|
...publishBlockers.length > 0 ? { publishBlockers } : {},
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NotFound } from "payload";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
//#region src/tools/shared.ts
|
|
4
|
-
const
|
|
4
|
+
const slugEnum = (slugs) => z.enum(slugs);
|
|
5
5
|
const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
6
|
+
const slugsFor = (scope, operation) => ({
|
|
7
|
+
collections: operation === "read" ? scope.readable : scope.writable,
|
|
8
|
+
globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* The `collection` and `global` arguments.
|
|
12
|
+
*
|
|
13
|
+
* When the key can reach no global, `global` is left out of the shape entirely
|
|
14
|
+
* and `collection` stays required, mirroring how {@link localeShape} omits
|
|
15
|
+
* `locale` when localization is off. A deployment without globals therefore
|
|
16
|
+
* sees exactly the schema it saw before. Only the mixed case makes either
|
|
17
|
+
* argument optional, and the handler enforces the exclusivity there.
|
|
18
|
+
*/ const targetShape = (scope, operation, descriptions) => {
|
|
19
|
+
const { collections, globals } = slugsFor(scope, operation);
|
|
20
|
+
if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
|
|
21
|
+
if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
|
|
22
|
+
return {
|
|
23
|
+
collection: slugEnum(collections).optional().describe(descriptions.collection),
|
|
24
|
+
global: slugEnum(globals).optional().describe(descriptions.global)
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The `id` argument, which only a collection document has. Omitted when the key
|
|
29
|
+
* can reach no collection, required when it can reach no global, and optional
|
|
30
|
+
* in between, where `requireIdFor` enforces the dependency.
|
|
31
|
+
*/ const idShape = (scope, operation) => {
|
|
32
|
+
const { collections, globals } = slugsFor(scope, operation);
|
|
33
|
+
if (collections.length === 0) return {};
|
|
34
|
+
if (globals.length === 0) return { id: idSchema };
|
|
35
|
+
return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
|
|
36
|
+
};
|
|
6
37
|
/**
|
|
7
38
|
* The `locale` argument, present only when localization is configured.
|
|
8
39
|
*/ const localeShape = (scope, options) => {
|
|
@@ -12,15 +43,6 @@ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
|
12
43
|
};
|
|
13
44
|
const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.options.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.options.limits.maxDepth)}.`) });
|
|
14
45
|
/**
|
|
15
|
-
* Throws unless the key may perform `operation` on `slug`. The input schema
|
|
16
|
-
* already limits the enum, so this only guards against a stale tool list.
|
|
17
|
-
*/ const ensureAllowed = (scope, slug, operation) => {
|
|
18
|
-
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
19
|
-
const collection = scope.req.payload.collections[slug];
|
|
20
|
-
if (!allowed.includes(slug) || !collection) throw new Forbidden(scope.req.t);
|
|
21
|
-
return collection.config;
|
|
22
|
-
};
|
|
23
|
-
/**
|
|
24
46
|
* The locale to operate on: the explicit argument, else the request's, else
|
|
25
47
|
* the default. `undefined` when localization is off.
|
|
26
48
|
*/ const localeOf = (scope, locale) => {
|
|
@@ -31,23 +53,34 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
|
|
|
31
53
|
/**
|
|
32
54
|
* Reads the current draft in a fixed locale with no fallback, which is the
|
|
33
55
|
* shape that may be written back or validated without mixing locales.
|
|
34
|
-
*/ const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
*/ const readTarget = async (scope, args) => {
|
|
57
|
+
const { payload } = scope.req;
|
|
58
|
+
const privileged = args.privileged === true;
|
|
59
|
+
const shared = {
|
|
38
60
|
depth: 0,
|
|
39
61
|
draft: true,
|
|
40
62
|
...args.locale === void 0 ? {} : {
|
|
41
63
|
locale: args.locale,
|
|
42
64
|
fallbackLocale: false
|
|
43
65
|
},
|
|
44
|
-
overrideAccess:
|
|
45
|
-
showHiddenFields:
|
|
46
|
-
disableErrors: true,
|
|
66
|
+
overrideAccess: privileged,
|
|
67
|
+
showHiddenFields: privileged,
|
|
47
68
|
req: scope.req
|
|
69
|
+
};
|
|
70
|
+
if (args.target.kind === "collection") {
|
|
71
|
+
const doc = await payload.findByID({
|
|
72
|
+
...shared,
|
|
73
|
+
collection: args.target.slug,
|
|
74
|
+
id: args.id,
|
|
75
|
+
disableErrors: true
|
|
76
|
+
});
|
|
77
|
+
if (!doc) throw new NotFound(scope.req.t);
|
|
78
|
+
return doc;
|
|
79
|
+
}
|
|
80
|
+
return await payload.findGlobal({
|
|
81
|
+
...shared,
|
|
82
|
+
slug: args.target.slug
|
|
48
83
|
});
|
|
49
|
-
if (!doc) throw new NotFound(scope.req.t);
|
|
50
|
-
return doc;
|
|
51
84
|
};
|
|
52
85
|
/**
|
|
53
86
|
* Resolves a collection label for the request's language.
|
|
@@ -62,4 +95,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
|
|
|
62
95
|
return fallback;
|
|
63
96
|
};
|
|
64
97
|
//#endregion
|
|
65
|
-
export {
|
|
98
|
+
export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { APIError, Forbidden } from "payload";
|
|
2
|
+
//#region src/tools/target.ts
|
|
3
|
+
const refOf = (target) => ({
|
|
4
|
+
kind: target.kind,
|
|
5
|
+
slug: target.slug
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the `collection`/`global` arguments to one entity and checks the key
|
|
9
|
+
* may perform `operation` on it.
|
|
10
|
+
*
|
|
11
|
+
* A tool's `inputSchema` returns a raw shape, which leaves no top-level
|
|
12
|
+
* `.refine` to express "exactly one of collection and global". The rule is
|
|
13
|
+
* enforced here instead, with a message naming the offending arguments so one
|
|
14
|
+
* failed call teaches it.
|
|
15
|
+
*/ const resolveTarget = (scope, args, operation) => {
|
|
16
|
+
const { collection, global } = args;
|
|
17
|
+
if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
|
|
18
|
+
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
|
+
if (collection !== void 0) {
|
|
20
|
+
const allowed = operation === "read" ? scope.readable : scope.writable;
|
|
21
|
+
const found = scope.req.payload.collections[collection];
|
|
22
|
+
if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
|
|
23
|
+
return {
|
|
24
|
+
kind: "collection",
|
|
25
|
+
slug: collection,
|
|
26
|
+
config: found.config
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const slug = global;
|
|
30
|
+
const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
|
|
31
|
+
const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
|
|
32
|
+
if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
|
|
33
|
+
return {
|
|
34
|
+
kind: "global",
|
|
35
|
+
slug,
|
|
36
|
+
config: found
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Checks `id` against the resolved target. A collection document needs one; a
|
|
41
|
+
* global is a singleton and must not carry one. The schema cannot express the
|
|
42
|
+
* dependency, so it is stated here and in every affected tool description.
|
|
43
|
+
*/ const requireIdFor = (target, id) => {
|
|
44
|
+
if (target.kind === "collection" && id === void 0) throw new APIError(`"id" is required when "collection" is "${target.slug}".`, 400);
|
|
45
|
+
if (target.kind === "global" && id !== void 0) throw new APIError(`"id" must be omitted when "global" is "${target.slug}"; a global is a singleton.`, 400);
|
|
46
|
+
return target.kind === "collection" ? id : void 0;
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { refOf, requireIdFor, resolveTarget };
|
|
@@ -1,43 +1,50 @@
|
|
|
1
1
|
import { jsonResult } from "../endpoint/result.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
|
+
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
3
4
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
4
5
|
//#region src/tools/validate-document.ts
|
|
5
6
|
const validateDocument = {
|
|
6
7
|
name: "validateDocument",
|
|
7
|
-
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"
|
|
8
|
+
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
|
+
|
|
10
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.`,
|
|
8
11
|
annotations: {
|
|
9
12
|
readOnlyHint: true,
|
|
10
13
|
openWorldHint: false
|
|
11
14
|
},
|
|
12
|
-
isEnabled: (scope) => scope.writable.length > 0,
|
|
15
|
+
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
13
16
|
inputSchema: (scope) => ({
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
...targetShape(scope, "write", {
|
|
18
|
+
collection: "Collection holding the document.",
|
|
19
|
+
global: "Global to validate."
|
|
20
|
+
}),
|
|
21
|
+
...idShape(scope, "write"),
|
|
16
22
|
...localeShape(scope, {
|
|
17
23
|
required: true,
|
|
18
24
|
description: "Locale to validate."
|
|
19
25
|
})
|
|
20
26
|
}),
|
|
21
27
|
handler: async (args, scope) => {
|
|
22
|
-
const
|
|
28
|
+
const target = resolveTarget(scope, args, "write");
|
|
29
|
+
const id = requireIdFor(target, args.id);
|
|
23
30
|
const locale = localeOf(scope, args.locale);
|
|
24
|
-
await
|
|
25
|
-
|
|
26
|
-
id
|
|
31
|
+
await readTarget(scope, {
|
|
32
|
+
target,
|
|
33
|
+
id,
|
|
27
34
|
locale
|
|
28
35
|
});
|
|
29
|
-
const doc = await
|
|
30
|
-
|
|
31
|
-
id
|
|
36
|
+
const doc = await readTarget(scope, {
|
|
37
|
+
target,
|
|
38
|
+
id,
|
|
32
39
|
locale,
|
|
33
40
|
privileged: true
|
|
34
41
|
});
|
|
35
42
|
const publishBlockers = await collectPublishBlockers(scope.req, {
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
doc,
|
|
44
|
+
entity: target
|
|
38
45
|
});
|
|
39
46
|
return jsonResult({
|
|
40
|
-
id: doc["id"],
|
|
47
|
+
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
41
48
|
status: doc["_status"],
|
|
42
49
|
updatedAt: doc["updatedAt"],
|
|
43
50
|
publishBlockers
|
package/dist/types.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CollectionConfig, CollectionSlug, PayloadRequest, TypedUser } from "payload";
|
|
1
|
+
import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
4
4
|
import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -29,6 +29,26 @@ interface McpxCollectionOptions {
|
|
|
29
29
|
*/
|
|
30
30
|
allowLiveWrites?: boolean;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* What an exposed global offers to MCP clients. Structurally the same as
|
|
34
|
+
* {@link McpxCollectionOptions}, kept separate because the tools it names
|
|
35
|
+
* differ: a global is a singleton, so neither `findDocuments` nor
|
|
36
|
+
* `createDocument` reaches one.
|
|
37
|
+
*/
|
|
38
|
+
interface McpxGlobalOptions {
|
|
39
|
+
/** Expose `describeSchema` and `getDocument`. Default `true`. */
|
|
40
|
+
read?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Expose `patchDocument` and `validateDocument`. Default `false`. Requires
|
|
43
|
+
* `versions.drafts` unless `allowLiveWrites` is set.
|
|
44
|
+
*/
|
|
45
|
+
write?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Permit writes to a global without drafts. Such writes land on the live
|
|
48
|
+
* document because there is no draft to land on. Default `false`.
|
|
49
|
+
*/
|
|
50
|
+
allowLiveWrites?: boolean;
|
|
51
|
+
}
|
|
32
52
|
type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
33
53
|
/**
|
|
34
54
|
* A custom tool. It is gated by its own checkbox on every API key and runs
|
|
@@ -63,6 +83,8 @@ interface McpxAuthResult {
|
|
|
63
83
|
type McpxPluginOptions = {
|
|
64
84
|
/** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
|
|
65
85
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
86
|
+
/** Allow-list of globals. `true` is shorthand for `{ read: true }`. */
|
|
87
|
+
globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
|
|
66
88
|
/** Collection the keys act as. Default `config.admin.user`, then `users`. */
|
|
67
89
|
userCollection?: CollectionSlug;
|
|
68
90
|
apiKeys?: {
|
|
@@ -103,6 +125,7 @@ interface McpxCollectionCapabilities {
|
|
|
103
125
|
*/
|
|
104
126
|
interface McpxResolvedCapabilities {
|
|
105
127
|
collections: Record<string, McpxCollectionCapabilities>;
|
|
128
|
+
globals: Record<string, McpxCollectionCapabilities>;
|
|
106
129
|
tools: Record<string, boolean>;
|
|
107
130
|
}
|
|
108
131
|
interface McpxRequestContext {
|
|
@@ -110,4 +133,4 @@ interface McpxRequestContext {
|
|
|
110
133
|
capabilities: McpxResolvedCapabilities;
|
|
111
134
|
}
|
|
112
135
|
//#endregion
|
|
113
|
-
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
|
|
136
|
+
export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
|
|
@@ -27,9 +27,7 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
27
27
|
* so it holds for every create and update on an MCP request, not only the
|
|
28
28
|
* builtin tools. Deletes are not guarded in v1; custom tools that delete are
|
|
29
29
|
* the integrator's responsibility.
|
|
30
|
-
*/ const
|
|
31
|
-
const { args, operation, req } = hookArgs;
|
|
32
|
-
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
30
|
+
*/ const scrubWriteArgs = (args) => {
|
|
33
31
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
34
32
|
if (next["data"] && typeof next["data"] === "object") {
|
|
35
33
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
@@ -41,18 +39,50 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
41
39
|
next["trash"] = false;
|
|
42
40
|
return next;
|
|
43
41
|
};
|
|
42
|
+
const forceDraftWrite = (hookArgs) => {
|
|
43
|
+
const { args, operation, req } = hookArgs;
|
|
44
|
+
if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
|
|
45
|
+
return scrubWriteArgs(args);
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The global counterpart of {@link forceDraftWrite}. Payload invokes a global's
|
|
49
|
+
* `beforeOperation` with the whole argument bag and assigns the result back,
|
|
50
|
+
* exactly as the collection path does and before it reads `draft`,
|
|
51
|
+
* `publishAllLocales` or `data._status`, so the guard has the same reach here:
|
|
52
|
+
* every MCP write to a global, builtin tool or custom.
|
|
53
|
+
*
|
|
54
|
+
* The global operation union has no `create` member because a global always
|
|
55
|
+
* exists, so only `update` is intercepted. `STRIPPED_ARGS` covers the three
|
|
56
|
+
* publish vectors `updateGlobal` accepts; the rest of the set does not exist on
|
|
57
|
+
* that signature and filtering it is a harmless no-op. `slug` survives the
|
|
58
|
+
* filter, so the operation still knows what it is updating.
|
|
59
|
+
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
60
|
+
const { operation, req } = hookArgs;
|
|
61
|
+
const args = hookArgs.args;
|
|
62
|
+
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
63
|
+
return scrubWriteArgs(args);
|
|
64
|
+
};
|
|
44
65
|
/**
|
|
45
66
|
* Refuses an MCP write that would still not land as a draft. An alarm rather
|
|
46
67
|
* than the guarantee: `forceDraftWrite` should make it unreachable. It throws
|
|
47
68
|
* instead of correcting `_status` because Payload has already chosen the write
|
|
48
69
|
* branch by the time a `beforeChange` hook runs.
|
|
49
|
-
*/ const
|
|
50
|
-
if (!isMcpxRequest(req)) return
|
|
70
|
+
*/ const refuseUnlessDraft = (req, slug, data) => {
|
|
71
|
+
if (!isMcpxRequest(req)) return;
|
|
51
72
|
const status = data._status;
|
|
52
|
-
if (status === "draft") return
|
|
53
|
-
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${
|
|
73
|
+
if (status === "draft") return;
|
|
74
|
+
req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a draft (_status: ${String(status)}).`);
|
|
54
75
|
throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
|
|
55
76
|
};
|
|
77
|
+
const refusePublish = ({ collection, data, req }) => {
|
|
78
|
+
refuseUnlessDraft(req, collection.slug, data);
|
|
79
|
+
return data;
|
|
80
|
+
};
|
|
81
|
+
/** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
|
|
82
|
+
const next = data;
|
|
83
|
+
refuseUnlessDraft(req, global.slug, next);
|
|
84
|
+
return next;
|
|
85
|
+
};
|
|
56
86
|
/**
|
|
57
87
|
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
58
88
|
* (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
|
|
@@ -66,5 +96,18 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
66
96
|
...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
|
|
67
97
|
}
|
|
68
98
|
}));
|
|
99
|
+
/**
|
|
100
|
+
* Attaches the guard to every global, exposed or not, for the same reason
|
|
101
|
+
* `installDraftGuards` covers every collection: a custom tool running on an MCP
|
|
102
|
+
* request must not be able to publish through a global the plugin config never
|
|
103
|
+
* mentioned.
|
|
104
|
+
*/ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
|
|
105
|
+
...global,
|
|
106
|
+
hooks: {
|
|
107
|
+
...global.hooks,
|
|
108
|
+
beforeOperation: [...global.hooks?.beforeOperation ?? [], forceDraftWriteGlobal],
|
|
109
|
+
...hasDraftsEnabled(global) ? { beforeChange: [...global.hooks?.beforeChange ?? [], refusePublishGlobal] } : {}
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
69
112
|
//#endregion
|
|
70
|
-
export { forceDraftWrite, installDraftGuards, isMcpxRequest, refusePublish };
|
|
113
|
+
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
|
package/dist/write/patch.mjs
CHANGED
|
@@ -126,9 +126,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
126
126
|
for (const pointer of pointers) {
|
|
127
127
|
const resolution = resolveDataPointer(config, {
|
|
128
128
|
addedValue: value ?? moved,
|
|
129
|
-
collection: target.collection,
|
|
130
129
|
doc: target.doc,
|
|
131
|
-
pointer
|
|
130
|
+
pointer,
|
|
131
|
+
ref: target.ref
|
|
132
132
|
});
|
|
133
133
|
if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
|
|
134
134
|
pointer,
|
|
@@ -208,9 +208,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
208
208
|
/**
|
|
209
209
|
* The data handed to `payload.update` after a patch: the patched document
|
|
210
210
|
* reduced to the fields the client may write, plus row identity keys.
|
|
211
|
-
*/ const buildWriteData = (config,
|
|
211
|
+
*/ const buildWriteData = (config, target, doc) => {
|
|
212
212
|
return pickDescribed(config, doc, {
|
|
213
|
-
fields:
|
|
213
|
+
fields: target.flattenedFields,
|
|
214
214
|
prefix: [],
|
|
215
215
|
isRow: false
|
|
216
216
|
});
|