@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.7
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 +141 -470
- package/dist/{client → admin}/capability-matrix.d.mts +3 -4
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +1 -1
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +6 -6
- package/dist/api-keys/capability-matrix.mjs +15 -85
- package/dist/api-keys/fields.mjs +15 -24
- package/dist/auth/resolve.mjs +23 -5
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
- package/dist/capabilities.mjs +34 -42
- package/dist/define-tool.d.mts +19 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +49 -19
- package/dist/endpoint/server.mjs +65 -19
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +5 -0
- package/dist/i18n.mjs +8 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +51 -45
- package/dist/plugin.mjs +0 -1
- package/dist/request.d.mts +2 -3
- package/dist/request.mjs +2 -3
- package/dist/result.d.mts +2 -3
- package/dist/result.mjs +3 -3
- package/dist/schema/admin-hidden.mjs +91 -0
- package/dist/schema/describe.mjs +17 -33
- package/dist/schema/errors.mjs +8 -0
- package/dist/schema/index.mjs +5 -2
- package/dist/schema/lexical-pointer.mjs +18 -20
- package/dist/schema/lexical.mjs +39 -92
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +22 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +35 -45
- package/dist/tools/builtin.mjs +4 -4
- package/dist/tools/create-document.mjs +26 -23
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +84 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +22 -18
- package/dist/tools/get-document.mjs +39 -32
- package/dist/tools/list-capabilities.mjs +23 -16
- package/dist/tools/patch-document.mjs +74 -50
- package/dist/tools/publish-document.mjs +17 -28
- package/dist/tools/query-paths.mjs +55 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +58 -99
- package/dist/tools/validate-document.mjs +16 -29
- package/dist/tools/versions.mjs +25 -17
- package/dist/types.d.mts +54 -56
- package/dist/write/draft-guard.mjs +17 -37
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +4 -4
- package/dist/write/row-ids.mjs +44 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +4 -4
- package/dist/client/capability-matrix.mjs +0 -349
- package/dist/endpoint/index.mjs +0 -4
- package/dist/tools/target.mjs +0 -45
- /package/dist/{client → admin}/index.d.mts +0 -0
- /package/dist/{client → admin}/index.mjs +0 -0
|
@@ -3,7 +3,7 @@ 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
|
-
|
|
6
|
+
const STRIPPED_ARGS = /* @__PURE__ */ new Set([
|
|
7
7
|
"where",
|
|
8
8
|
"publishAllLocales",
|
|
9
9
|
"publishSpecificLocale",
|
|
@@ -12,20 +12,7 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
12
12
|
"selectedLocales",
|
|
13
13
|
"overwriteExistingFiles"
|
|
14
14
|
]);
|
|
15
|
-
|
|
16
|
-
* Forces every MCP write into a draft save, unless it is the one write
|
|
17
|
-
* `publishDocument` asked for.
|
|
18
|
-
*
|
|
19
|
-
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
20
|
-
* `data._status !== "published"`, so `_status` is dropped and left to Payload.
|
|
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.
|
|
23
|
-
*
|
|
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.
|
|
28
|
-
*/ const scrubWriteArgs = (args, publishing) => {
|
|
15
|
+
const scrubWriteArgs = (args, publishing) => {
|
|
29
16
|
const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
|
|
30
17
|
if (next["data"] && typeof next["data"] === "object") {
|
|
31
18
|
const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
|
|
@@ -47,25 +34,19 @@ const forceDraftWrite = (hookArgs) => {
|
|
|
47
34
|
return scrubWriteArgs(args, publishing);
|
|
48
35
|
};
|
|
49
36
|
/**
|
|
50
|
-
* The global counterpart of {@link forceDraftWrite}
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* `data`
|
|
54
|
-
* `
|
|
55
|
-
*
|
|
56
|
-
* the call site, and this hook puts `_status` back rather than stripping it.
|
|
37
|
+
* The global counterpart of {@link forceDraftWrite}. `updateGlobal` destructures
|
|
38
|
+
* `draft` and the publish arguments before it runs `beforeOperation` and
|
|
39
|
+
* re-reads only `data` afterwards, so setting them here has no effect. Only
|
|
40
|
+
* `data` with `_status` stripped lands, so {@link refusePublishGlobal} is what
|
|
41
|
+
* enforces the guard. `publishDocument` therefore passes `draft: false` at the
|
|
42
|
+
* call site, and this hook restores `_status` instead of stripping it.
|
|
57
43
|
*/ const forceDraftWriteGlobal = (hookArgs) => {
|
|
58
44
|
const { operation, req } = hookArgs;
|
|
59
45
|
const args = hookArgs.args;
|
|
60
46
|
if (!isMcpxRequest(req) || operation !== "update") return args;
|
|
61
47
|
return scrubWriteArgs(args, hasPublishIntent(args["data"]));
|
|
62
48
|
};
|
|
63
|
-
|
|
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) => {
|
|
49
|
+
const refuseUnlessExpected = (req, slug, data) => {
|
|
69
50
|
const publishing = takePublishIntent(data);
|
|
70
51
|
if (!isMcpxRequest(req)) return;
|
|
71
52
|
const status = data._status;
|
|
@@ -75,8 +56,8 @@ const forceDraftWrite = (hookArgs) => {
|
|
|
75
56
|
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);
|
|
76
57
|
};
|
|
77
58
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
59
|
+
* Runs {@link refuseUnlessExpected} on every collection write. Returns `data`
|
|
60
|
+
* unchanged; the hook exists for its throw.
|
|
80
61
|
*/ const refusePublish = ({ collection, data, req }) => {
|
|
81
62
|
refuseUnlessExpected(req, collection.slug, data);
|
|
82
63
|
return data;
|
|
@@ -88,9 +69,9 @@ const refusePublishGlobal = ({ data, global, req }) => {
|
|
|
88
69
|
};
|
|
89
70
|
/**
|
|
90
71
|
* Attaches the draft guard to every collection: `forceDraftWrite` everywhere
|
|
91
|
-
* (it
|
|
92
|
-
* exist.
|
|
93
|
-
*
|
|
72
|
+
* (it does nothing outside MCP requests) and `refusePublish` wherever drafts
|
|
73
|
+
* exist. It runs on the final collection list, so no collection escapes it, and
|
|
74
|
+
* both hooks are appended last, so a user hook cannot override them.
|
|
94
75
|
*/ const installDraftGuards = (collections) => collections.map((collection) => ({
|
|
95
76
|
...collection,
|
|
96
77
|
hooks: {
|
|
@@ -100,10 +81,9 @@ const refusePublishGlobal = ({ data, global, req }) => {
|
|
|
100
81
|
}
|
|
101
82
|
}));
|
|
102
83
|
/**
|
|
103
|
-
* Attaches the guard to every global, exposed or not
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* mentioned.
|
|
84
|
+
* Attaches the guard to every global, exposed or not: a custom tool running on
|
|
85
|
+
* an MCP request must not be able to publish through a global the plugin config
|
|
86
|
+
* never mentioned.
|
|
107
87
|
*/ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
|
|
108
88
|
...global,
|
|
109
89
|
hooks: {
|
package/dist/write/patch.mjs
CHANGED
|
@@ -1,98 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isIndexSegment, joinPath, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
|
|
2
|
+
import { RESERVED_FIELD_NAMES, descriptorsUnder } from "../schema/walk.mjs";
|
|
3
|
+
import { SchemaError } from "../schema/errors.mjs";
|
|
2
4
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
3
5
|
import { EMPTY_ROOT, validateWriteValue } from "../schema/shape.mjs";
|
|
4
6
|
import "../schema/index.mjs";
|
|
5
|
-
import {
|
|
7
|
+
import { reconcileRowIds } from "./row-ids.mjs";
|
|
6
8
|
import { Pointer, applyPatch } from "rfc6902";
|
|
7
9
|
//#region src/write/patch.ts
|
|
8
|
-
const
|
|
9
|
-
/** Discriminated on `op`, so an operation carries only its own members. */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
|
|
10
|
-
z.strictObject({
|
|
11
|
-
op: z.literal("add"),
|
|
12
|
-
path: POINTER,
|
|
13
|
-
value: z.unknown()
|
|
14
|
-
}),
|
|
15
|
-
z.strictObject({
|
|
16
|
-
op: z.literal("remove"),
|
|
17
|
-
path: POINTER
|
|
18
|
-
}),
|
|
19
|
-
z.strictObject({
|
|
20
|
-
op: z.literal("replace"),
|
|
21
|
-
path: POINTER,
|
|
22
|
-
value: z.unknown()
|
|
23
|
-
}),
|
|
24
|
-
z.strictObject({
|
|
25
|
-
from: POINTER,
|
|
26
|
-
op: z.literal("move"),
|
|
27
|
-
path: POINTER
|
|
28
|
-
}),
|
|
29
|
-
z.strictObject({
|
|
30
|
-
from: POINTER,
|
|
31
|
-
op: z.literal("copy"),
|
|
32
|
-
path: POINTER
|
|
33
|
-
}),
|
|
34
|
-
z.strictObject({
|
|
35
|
-
op: z.literal("test"),
|
|
36
|
-
path: POINTER,
|
|
37
|
-
value: z.unknown()
|
|
38
|
-
})
|
|
39
|
-
]).describe("An RFC 6902 operation.");
|
|
40
|
-
const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
10
|
+
const isReservedPointer = (pointer) => splitPath(pointer).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
41
11
|
const droppedPointer = (operation) => {
|
|
42
12
|
if (operation.op === "remove") return operation.path;
|
|
43
13
|
return operation.op === "move" ? operation.from : void 0;
|
|
44
14
|
};
|
|
45
15
|
const isElementPointer = (pointer) => {
|
|
46
|
-
return isIndexSegment(pointer
|
|
16
|
+
return isIndexSegment(splitPath(pointer).at(-1) ?? "");
|
|
47
17
|
};
|
|
48
|
-
const
|
|
49
|
-
/** Its nodes manage their own ids, so it is never descended into. */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
|
|
50
|
-
/** A row is a plain object carrying `blockType`, or one sitting in an array. */ const walkRows = (value, visit, isRow = false) => {
|
|
51
|
-
if (Array.isArray(value)) {
|
|
52
|
-
for (const entry of value) walkRows(entry, visit, true);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
if (!isPlainObject(value) || isRichTextState(value)) return;
|
|
56
|
-
if (isRow || typeof value["blockType"] === "string") visit(value);
|
|
57
|
-
for (const entry of Object.values(value)) walkRows(entry, visit);
|
|
58
|
-
};
|
|
59
|
-
/**
|
|
60
|
-
* Keeps a row id only when the stored document already has it and no earlier
|
|
61
|
-
* row in the write claimed it; every other id is dropped so Payload assigns a
|
|
62
|
-
* fresh one.
|
|
63
|
-
*
|
|
64
|
-
* A kept id makes Payload update the row in place, which preserves the other
|
|
65
|
-
* locales of any localized field inside it. A duplicated id (a copied row) or
|
|
66
|
-
* an id from elsewhere would violate a SQL primary key, so those never pass.
|
|
67
|
-
*/ const reconcileRowIds = (next, stored) => {
|
|
68
|
-
const known = /* @__PURE__ */ new Set();
|
|
69
|
-
walkRows(stored, (row) => {
|
|
70
|
-
if (row["id"] !== void 0) known.add(row["id"]);
|
|
71
|
-
});
|
|
72
|
-
const seen = /* @__PURE__ */ new Set();
|
|
73
|
-
walkRows(next, (row) => {
|
|
74
|
-
const id = row["id"];
|
|
75
|
-
if (id === void 0) return;
|
|
76
|
-
if (known.has(id) && !seen.has(id)) {
|
|
77
|
-
seen.add(id);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
delete row["id"];
|
|
81
|
-
});
|
|
82
|
-
};
|
|
83
|
-
/** For create, where no stored row exists and any incoming id is invented. */ const stripRowIds = (value) => {
|
|
84
|
-
const next = structuredClone(value);
|
|
85
|
-
walkRows(next, (row) => {
|
|
86
|
-
delete row["id"];
|
|
87
|
-
});
|
|
88
|
-
return next;
|
|
89
|
-
};
|
|
90
|
-
/**
|
|
91
|
-
* The operation as it is applied: values are cloned so the written document
|
|
92
|
-
* never shares references with the caller's operations, and a `replace` of a
|
|
93
|
-
* field the target locale has no value for becomes an `add`, which is what
|
|
94
|
-
* RFC 6902 requires when nothing is there to replace.
|
|
95
|
-
*/ const prepare = (operation, doc) => {
|
|
18
|
+
const prepare = (operation, doc) => {
|
|
96
19
|
const cloned = "value" in operation ? {
|
|
97
20
|
...operation,
|
|
98
21
|
value: structuredClone(operation.value)
|
|
@@ -102,49 +25,41 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
102
25
|
op: "add"
|
|
103
26
|
} : cloned;
|
|
104
27
|
};
|
|
105
|
-
|
|
28
|
+
const effectiveValue = (operation, doc) => {
|
|
106
29
|
if ("value" in operation) return operation.value;
|
|
107
30
|
return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
|
|
108
31
|
};
|
|
109
|
-
|
|
32
|
+
const resolvesReadOnly = (resolution) => {
|
|
110
33
|
if (resolution.readOnly) return true;
|
|
111
34
|
if (resolution.descriptor) return resolution.descriptor.readOnly === true;
|
|
112
|
-
const below =
|
|
113
|
-
return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
|
|
35
|
+
const below = descriptorsUnder(resolution.fields, resolution.prefix);
|
|
36
|
+
return below.length > 0 && below.every(({ descriptor }) => descriptor.readOnly);
|
|
114
37
|
};
|
|
115
|
-
|
|
38
|
+
const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
|
|
116
39
|
doc: target.doc,
|
|
117
40
|
pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
|
|
118
41
|
ref: target.ref
|
|
119
42
|
}));
|
|
120
|
-
|
|
43
|
+
const resolutionAt = (config, target) => {
|
|
121
44
|
try {
|
|
122
45
|
return resolveDataPointer(config, {
|
|
123
46
|
doc: target.doc,
|
|
124
47
|
pointer: target.pointer,
|
|
125
48
|
ref: target.ref
|
|
126
49
|
});
|
|
127
|
-
} catch {
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!(error instanceof SchemaError)) throw error;
|
|
128
52
|
return;
|
|
129
53
|
}
|
|
130
54
|
};
|
|
131
|
-
|
|
132
|
-
* Removing a field does nothing, but removing part of an editor state does
|
|
133
|
-
* something, and something worse: a node written without the properties its
|
|
134
|
-
* class hydrates from throws when the admin editor opens it. Both are refused,
|
|
135
|
-
* and only the reason differs.
|
|
136
|
-
*/ const droppedFieldProblem = (config, target) => {
|
|
55
|
+
const droppedFieldProblem = (config, target) => {
|
|
137
56
|
const resolution = resolutionAt(config, target);
|
|
138
57
|
const lexical = resolution?.lexical;
|
|
139
58
|
if (lexical?.kind === "property") return `"${target.pointer}" is a node property, not a list element. A "${lexical.nodeType}" node needs it, so replace it rather than removing it.`;
|
|
140
59
|
if (lexical ?? resolution?.inLexical) return `"${target.pointer}" sits inside an editor state, which is written whole, so removing it would take effect and leave a node the admin editor cannot open. Replace it instead, or remove the node that holds it.`;
|
|
141
60
|
return `"${target.pointer}" 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.`;
|
|
142
61
|
};
|
|
143
|
-
|
|
144
|
-
* Dropping the only node under a root empties the state, which Lexical refuses
|
|
145
|
-
* to hydrate. Checked here rather than on the written value, because a
|
|
146
|
-
* `remove` carries none.
|
|
147
|
-
*/ const emptiesTheRoot = (config, target) => {
|
|
62
|
+
const emptiesTheRoot = (config, target) => {
|
|
148
63
|
const list = joinPath(splitPath(target.pointer).slice(0, -1));
|
|
149
64
|
const owner = resolutionAt(config, {
|
|
150
65
|
...target,
|
|
@@ -154,16 +69,14 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
154
69
|
const nodes = Pointer.fromJSON(list).get(target.doc);
|
|
155
70
|
return Array.isArray(nodes) && nodes.length === 1;
|
|
156
71
|
};
|
|
157
|
-
|
|
158
|
-
* Checked against the document as it stands when this operation runs: both
|
|
159
|
-
* pointers must resolve, the written value must pass write validation, and what
|
|
160
|
-
* it drops must not sit in a read-only field.
|
|
161
|
-
*/ const findOperationProblems = (config, target) => {
|
|
72
|
+
const findOperationProblems = (config, target) => {
|
|
162
73
|
const { doc, operation, ref } = target;
|
|
163
74
|
const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
|
|
164
75
|
if (pointers.includes("")) return ["an empty pointer addresses the whole document. Address a field instead."];
|
|
76
|
+
const prototyped = pointers.map(prototypeSegmentProblem).find((problem) => problem !== void 0);
|
|
77
|
+
if (prototyped !== void 0) return [prototyped];
|
|
165
78
|
const reserved = pointers.find(isReservedPointer);
|
|
166
|
-
if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains.
|
|
79
|
+
if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. id, _status, createdAt and updatedAt are not writable, and "_status" changes only when a draft is published.`];
|
|
167
80
|
const dropped = droppedPointer(operation);
|
|
168
81
|
if (dropped !== void 0 && !isElementPointer(dropped)) return [droppedFieldProblem(config, {
|
|
169
82
|
doc,
|
|
@@ -204,14 +117,14 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
204
117
|
}
|
|
205
118
|
return [];
|
|
206
119
|
} catch (error) {
|
|
207
|
-
|
|
120
|
+
if (!(error instanceof SchemaError)) throw error;
|
|
121
|
+
return [error.message];
|
|
208
122
|
}
|
|
209
123
|
};
|
|
210
124
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* applied, so a partial batch is never persisted.
|
|
125
|
+
* Works on one evolving copy, so each operation resolves against the result of
|
|
126
|
+
* the earlier ones and a failure leaves the original untouched. The caller
|
|
127
|
+
* writes only when the whole batch applied, so a partial batch is never saved.
|
|
215
128
|
*/ const applyPatchOperations = (config, target) => {
|
|
216
129
|
const next = structuredClone(target.doc);
|
|
217
130
|
for (const [index, operation] of target.patches.entries()) {
|
|
@@ -228,74 +141,5 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
228
141
|
reconcileRowIds(next, target.doc);
|
|
229
142
|
return { next };
|
|
230
143
|
};
|
|
231
|
-
const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
232
|
-
"blockName",
|
|
233
|
-
"blockType",
|
|
234
|
-
"id"
|
|
235
|
-
]);
|
|
236
|
-
/**
|
|
237
|
-
* Everything Payload maintains or derives (`_status`, timestamps, join and
|
|
238
|
-
* virtual fields, upload base fields) is left out, so the write-back carries
|
|
239
|
-
* only what a client could have set.
|
|
240
|
-
*/ const pickDescribed = (config, value, at) => {
|
|
241
|
-
const { fields, prefix, isRow } = at;
|
|
242
|
-
const relative = describeAddressableFields(fields).flatMap((descriptor) => {
|
|
243
|
-
const parts = splitPath(descriptor.path);
|
|
244
|
-
return prefix.every((part, offset) => part === parts[offset]) ? [{
|
|
245
|
-
descriptor,
|
|
246
|
-
parts: parts.slice(prefix.length)
|
|
247
|
-
}] : [];
|
|
248
|
-
});
|
|
249
|
-
const result = {};
|
|
250
|
-
if (isRow) {
|
|
251
|
-
for (const key of ROW_KEYS) if (key in value) result[key] = value[key];
|
|
252
|
-
}
|
|
253
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
254
|
-
const candidates = relative.filter(({ parts }) => parts[0] === key);
|
|
255
|
-
if (candidates.length === 0) continue;
|
|
256
|
-
const exact = candidates.find(({ parts }) => parts.length === 1);
|
|
257
|
-
if (exact?.descriptor.type === "blocks" && Array.isArray(entry)) {
|
|
258
|
-
const field = findBlocksField(fields, splitPath(exact.descriptor.path));
|
|
259
|
-
result[key] = entry.map((row) => {
|
|
260
|
-
const block = field && isPlainObject(row) && typeof row["blockType"] === "string" ? blockOf(config, field, row["blockType"]) : void 0;
|
|
261
|
-
return block && isPlainObject(row) ? pickDescribed(config, row, {
|
|
262
|
-
fields: block.flattenedFields,
|
|
263
|
-
prefix: [],
|
|
264
|
-
isRow: true
|
|
265
|
-
}) : row;
|
|
266
|
-
});
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
if (exact) {
|
|
270
|
-
result[key] = entry;
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
if (candidates.some(({ parts }) => parts[1] === "*")) {
|
|
274
|
-
result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
|
|
275
|
-
fields,
|
|
276
|
-
prefix: [
|
|
277
|
-
...prefix,
|
|
278
|
-
key,
|
|
279
|
-
"*"
|
|
280
|
-
],
|
|
281
|
-
isRow: true
|
|
282
|
-
}) : row) : entry;
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
result[key] = isPlainObject(entry) ? pickDescribed(config, entry, {
|
|
286
|
-
fields,
|
|
287
|
-
prefix: [...prefix, key],
|
|
288
|
-
isRow: false
|
|
289
|
-
}) : entry;
|
|
290
|
-
}
|
|
291
|
-
return result;
|
|
292
|
-
};
|
|
293
|
-
/** The patched document reduced to writable fields, plus row identity keys. */ const buildWriteData = (config, target, doc) => {
|
|
294
|
-
return pickDescribed(config, doc, {
|
|
295
|
-
fields: target.flattenedFields,
|
|
296
|
-
prefix: [],
|
|
297
|
-
isRow: false
|
|
298
|
-
});
|
|
299
|
-
};
|
|
300
144
|
//#endregion
|
|
301
|
-
export {
|
|
145
|
+
export { applyPatchOperations, droppedPointer, isElementPointer, isReservedPointer };
|
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import { pointerFromPayloadPath } from "../schema/
|
|
1
|
+
import { pointerFromPayloadPath } from "../schema/path.mjs";
|
|
2
2
|
import "../schema/index.mjs";
|
|
3
3
|
import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
|
|
4
4
|
//#region src/write/publish-blockers.ts
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Nothing is written: `data` is a copy, the context is a scratch copy and the
|
|
14
|
-
* locale merge actions are discarded. `overrideAccess` is true because the
|
|
15
|
-
* question is "could this be published", not "may this client write it".
|
|
16
|
-
*
|
|
17
|
-
* `unavailable` marks a traversal that threw, which is not the same answer as a
|
|
18
|
-
* document with nothing wrong with it.
|
|
6
|
+
* Payload's field validation over a draft, without saving: `data` and the
|
|
7
|
+
* context are copies. `beforeValidate` runs first because some field hooks
|
|
8
|
+
* (Lexical's) prepare `context` state that `beforeChange` needs. Access is
|
|
9
|
+
* overridden because the question is whether the draft could be published, not
|
|
10
|
+
* whether this client may write it. `unavailable` means the traversal threw,
|
|
11
|
+
* which is not the same as a clean document.
|
|
19
12
|
*/ const collectPublishBlockers = async (req, target) => {
|
|
20
13
|
const { doc, entity } = target;
|
|
21
14
|
const id = doc["id"];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "../guards.mjs";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
//#region src/write/publish-intent.ts
|
|
3
4
|
const PUBLISH_INTENT = "__mcpxPublishIntent";
|
|
@@ -6,10 +7,9 @@ const withPublishIntent = (data) => ({
|
|
|
6
7
|
...data,
|
|
7
8
|
[PUBLISH_INTENT]: TOKEN
|
|
8
9
|
});
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
if (!carries(data)) return false;
|
|
10
|
+
const hasPublishIntent = (data) => isPlainObject(data) && data[PUBLISH_INTENT] === TOKEN;
|
|
11
|
+
/** Called by the last hook that needs the marker, which removes it. */ const takePublishIntent = (data) => {
|
|
12
|
+
if (!hasPublishIntent(data)) return false;
|
|
13
13
|
delete data[PUBLISH_INTENT];
|
|
14
14
|
return true;
|
|
15
15
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isPlainObject } from "../guards.mjs";
|
|
2
|
+
//#region src/write/row-ids.ts
|
|
3
|
+
const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
|
|
4
|
+
const walkRows = (value, visit, isRow = false) => {
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
for (const entry of value) walkRows(entry, visit, true);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (!isPlainObject(value) || isRichTextState(value)) return;
|
|
10
|
+
if (isRow || typeof value["blockType"] === "string") visit(value);
|
|
11
|
+
for (const entry of Object.values(value)) walkRows(entry, visit);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Keeps a row id only when the stored document has it and no earlier row in the
|
|
15
|
+
* write claimed it. Other ids are dropped so Payload assigns a fresh one.
|
|
16
|
+
*
|
|
17
|
+
* A kept id makes Payload update the row in place, which preserves the other
|
|
18
|
+
* locales of localized fields inside it. A duplicated id (a copied row) or a
|
|
19
|
+
* foreign id would violate a SQL primary key.
|
|
20
|
+
*/ const reconcileRowIds = (next, stored) => {
|
|
21
|
+
const known = /* @__PURE__ */ new Set();
|
|
22
|
+
walkRows(stored, (row) => {
|
|
23
|
+
if (row["id"] !== void 0) known.add(row["id"]);
|
|
24
|
+
});
|
|
25
|
+
const seen = /* @__PURE__ */ new Set();
|
|
26
|
+
walkRows(next, (row) => {
|
|
27
|
+
const id = row["id"];
|
|
28
|
+
if (id === void 0) return;
|
|
29
|
+
if (known.has(id) && !seen.has(id)) {
|
|
30
|
+
seen.add(id);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
delete row["id"];
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
/** For create, where no stored row exists and any incoming id is invented. */ const stripRowIds = (value) => {
|
|
37
|
+
const next = structuredClone(value);
|
|
38
|
+
walkRows(next, (row) => {
|
|
39
|
+
delete row["id"];
|
|
40
|
+
});
|
|
41
|
+
return next;
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { reconcileRowIds, stripRowIds };
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { commitTransaction, initTransaction, killTransaction } from "payload";
|
|
2
2
|
//#region src/write/transaction.ts
|
|
3
3
|
/**
|
|
4
|
-
* Adapters without transaction support, or a request that already owns one,
|
|
5
|
-
* `fn` as is.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* is this safe across the tool calls of one JSON-RPC batch, which share a
|
|
10
|
-
* request: the second caller joins the first's transaction, so one tool's
|
|
11
|
-
* rollback takes the other's work with it.
|
|
4
|
+
* Adapters without transaction support, or a request that already owns one,
|
|
5
|
+
* run `fn` as is. This gives atomicity, not isolation: SQLite and Postgres at
|
|
6
|
+
* read committed do not lock the row on read, so an `expectedUpdatedAt` check
|
|
7
|
+
* stays best effort. Tool calls in one JSON-RPC batch run one after another, so
|
|
8
|
+
* each owns its transaction and a rollback never undoes another call.
|
|
12
9
|
*/ const withTransaction = async (req, fn) => {
|
|
13
10
|
if (!await initTransaction(req)) return await fn();
|
|
14
11
|
try {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { isPlainObject } from "../guards.mjs";
|
|
2
|
+
import { splitPath } from "../schema/path.mjs";
|
|
3
|
+
import { ROW_KEYS, blockOf, descriptorsUnder, findBlocksField } from "../schema/walk.mjs";
|
|
4
|
+
import "../schema/index.mjs";
|
|
5
|
+
//#region src/write/write-data.ts
|
|
6
|
+
const pickDescribed = (config, value, at) => {
|
|
7
|
+
const { fields, prefix, isRow } = at;
|
|
8
|
+
const relative = descriptorsUnder(fields, prefix);
|
|
9
|
+
const result = {};
|
|
10
|
+
if (isRow) {
|
|
11
|
+
for (const key of ROW_KEYS) if (Object.hasOwn(value, key)) result[key] = value[key];
|
|
12
|
+
}
|
|
13
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
14
|
+
const candidates = relative.filter(({ parts }) => parts[0] === key);
|
|
15
|
+
if (candidates.length === 0) continue;
|
|
16
|
+
const exact = candidates.find(({ parts }) => parts.length === 1);
|
|
17
|
+
if (exact?.descriptor.type === "blocks" && Array.isArray(entry)) {
|
|
18
|
+
const field = findBlocksField(fields, splitPath(exact.descriptor.path));
|
|
19
|
+
result[key] = entry.map((row) => {
|
|
20
|
+
const block = field && isPlainObject(row) && typeof row["blockType"] === "string" ? blockOf(config, field, row["blockType"]) : void 0;
|
|
21
|
+
return block && isPlainObject(row) ? pickDescribed(config, row, {
|
|
22
|
+
fields: block.flattenedFields,
|
|
23
|
+
prefix: [],
|
|
24
|
+
isRow: true
|
|
25
|
+
}) : row;
|
|
26
|
+
});
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (exact) {
|
|
30
|
+
result[key] = entry;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (candidates.some(({ parts }) => parts[1] === "*")) {
|
|
34
|
+
result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
|
|
35
|
+
fields,
|
|
36
|
+
prefix: [
|
|
37
|
+
...prefix,
|
|
38
|
+
key,
|
|
39
|
+
"*"
|
|
40
|
+
],
|
|
41
|
+
isRow: true
|
|
42
|
+
}) : row) : entry;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
result[key] = isPlainObject(entry) ? pickDescribed(config, entry, {
|
|
46
|
+
fields,
|
|
47
|
+
prefix: [...prefix, key],
|
|
48
|
+
isRow: false
|
|
49
|
+
}) : entry;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The patched document reduced to writable fields, plus row identity keys.
|
|
55
|
+
* `target` is a sanitized collection or global alike.
|
|
56
|
+
*/ const buildWriteData = (config, target, doc) => {
|
|
57
|
+
return pickDescribed(config, doc, {
|
|
58
|
+
fields: target.flattenedFields,
|
|
59
|
+
prefix: [],
|
|
60
|
+
isRow: false
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { buildWriteData };
|
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": "
|
|
4
|
+
"version": "2.0.0-beta.7",
|
|
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",
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"types": "./dist/index.d.mts",
|
|
28
28
|
"default": "./dist/index.mjs"
|
|
29
29
|
},
|
|
30
|
-
"./
|
|
31
|
-
"types": "./dist/
|
|
32
|
-
"default": "./dist/
|
|
30
|
+
"./admin": {
|
|
31
|
+
"types": "./dist/admin/index.d.mts",
|
|
32
|
+
"default": "./dist/admin/index.mjs"
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"files": [
|