@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.12

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.
Files changed (76) hide show
  1. package/README.md +144 -470
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +14 -86
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +71 -23
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  13. package/dist/capabilities.mjs +44 -46
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +73 -29
  18. package/dist/endpoint/server.mjs +85 -22
  19. package/dist/endpoint/upload.mjs +135 -0
  20. package/dist/entity.d.mts +9 -0
  21. package/dist/guards.mjs +7 -0
  22. package/dist/i18n.mjs +10 -8
  23. package/dist/index.d.mts +5 -2
  24. package/dist/index.mjs +3 -2
  25. package/dist/options.mjs +54 -47
  26. package/dist/plugin.d.mts +2 -2
  27. package/dist/plugin.mjs +13 -3
  28. package/dist/request.d.mts +2 -3
  29. package/dist/request.mjs +2 -3
  30. package/dist/result.d.mts +2 -3
  31. package/dist/result.mjs +3 -3
  32. package/dist/schema/admin-hidden.mjs +91 -0
  33. package/dist/schema/describe.mjs +17 -33
  34. package/dist/schema/errors.mjs +8 -0
  35. package/dist/schema/index.mjs +5 -2
  36. package/dist/schema/lexical-pointer.mjs +18 -20
  37. package/dist/schema/lexical.mjs +44 -93
  38. package/dist/schema/outline.mjs +5 -15
  39. package/dist/schema/path.mjs +28 -0
  40. package/dist/schema/pointer.mjs +18 -25
  41. package/dist/schema/shape.mjs +25 -71
  42. package/dist/schema/walk.mjs +43 -45
  43. package/dist/tools/builtin.mjs +4 -4
  44. package/dist/tools/create-document.mjs +50 -24
  45. package/dist/tools/describe-schema.mjs +38 -34
  46. package/dist/tools/document.mjs +124 -0
  47. package/dist/tools/find-documents.mjs +32 -19
  48. package/dist/tools/find-versions.mjs +22 -18
  49. package/dist/tools/get-document.mjs +60 -33
  50. package/dist/tools/list-capabilities.mjs +35 -19
  51. package/dist/tools/patch-document.mjs +122 -66
  52. package/dist/tools/publish-document.mjs +26 -32
  53. package/dist/tools/query-paths.mjs +57 -0
  54. package/dist/tools/read-request.d.mts +10 -0
  55. package/dist/tools/read-request.mjs +43 -0
  56. package/dist/tools/shared.mjs +74 -105
  57. package/dist/tools/validate-document.mjs +18 -30
  58. package/dist/tools/versions.mjs +37 -20
  59. package/dist/types.d.mts +135 -75
  60. package/dist/upload/file.mjs +166 -0
  61. package/dist/upload/grant.mjs +96 -0
  62. package/dist/version.mjs +3 -1
  63. package/dist/write/draft-guard.mjs +23 -41
  64. package/dist/write/patch.mjs +27 -183
  65. package/dist/write/publish-blockers.mjs +7 -14
  66. package/dist/write/publish-intent.mjs +6 -4
  67. package/dist/write/row-ids.mjs +46 -0
  68. package/dist/write/transaction.mjs +5 -8
  69. package/dist/write/write-data.mjs +64 -0
  70. package/package.json +11 -10
  71. package/dist/client/capability-matrix.d.mts +0 -26
  72. package/dist/client/capability-matrix.mjs +0 -349
  73. package/dist/endpoint/index.mjs +0 -4
  74. package/dist/tools/target.mjs +0 -45
  75. /package/dist/{client → admin}/index.d.mts +0 -0
  76. /package/dist/{client → admin}/index.mjs +0 -0
@@ -3,30 +3,16 @@ 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
- /** Cleared on every MCP write, publishes included, so none can be smuggled in. */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
6
+ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
7
7
  "where",
8
8
  "publishAllLocales",
9
- "publishSpecificLocale",
10
9
  "unpublishAllLocales",
11
10
  "duplicateFromID",
12
11
  "selectedLocales",
13
12
  "overwriteExistingFiles"
14
13
  ]);
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) => {
29
- const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
14
+ const scrubWriteArgs = (args, publishing) => {
15
+ const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key) && (publishing || key !== "publishSpecificLocale")));
30
16
  if (next["data"] && typeof next["data"] === "object") {
31
17
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
32
18
  next["data"] = publishing ? {
@@ -47,25 +33,22 @@ const forceDraftWrite = (hookArgs) => {
47
33
  return scrubWriteArgs(args, publishing);
48
34
  };
49
35
  /**
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.
36
+ * The global counterpart of {@link forceDraftWrite}. `updateGlobal` reads
37
+ * `draft` and the publish arguments before this hook runs, so only changes to
38
+ * `data` take effect. A write to a global with drafts that did not ask for a
39
+ * draft save is therefore refused here, and `_status` is restored for a publish.
57
40
  */ const forceDraftWriteGlobal = (hookArgs) => {
58
- const { operation, req } = hookArgs;
41
+ const { global, operation, req } = hookArgs;
59
42
  const args = hookArgs.args;
60
43
  if (!isMcpxRequest(req) || operation !== "update") return args;
61
- return scrubWriteArgs(args, hasPublishIntent(args["data"]));
44
+ const publishing = hasPublishIntent(args["data"]);
45
+ if (!publishing && args["draft"] !== true && hasDraftsEnabled(global)) {
46
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${global.slug} that did not ask for a draft save.`);
47
+ throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
48
+ }
49
+ return scrubWriteArgs(args, publishing);
62
50
  };
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) => {
51
+ const refuseUnlessExpected = (req, slug, data) => {
69
52
  const publishing = takePublishIntent(data);
70
53
  if (!isMcpxRequest(req)) return;
71
54
  const status = data._status;
@@ -75,8 +58,8 @@ const forceDraftWrite = (hookArgs) => {
75
58
  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
59
  };
77
60
  /**
78
- * Installs {@link refuseUnlessExpected} on every collection write. Returns
79
- * `data` unchanged when the write is allowed; the hook exists for its throw.
61
+ * Runs {@link refuseUnlessExpected} on every collection write. Returns `data`
62
+ * unchanged; the hook exists for its throw.
80
63
  */ const refusePublish = ({ collection, data, req }) => {
81
64
  refuseUnlessExpected(req, collection.slug, data);
82
65
  return data;
@@ -88,9 +71,9 @@ const refusePublishGlobal = ({ data, global, req }) => {
88
71
  };
89
72
  /**
90
73
  * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
91
- * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
92
- * exist. Applied to the built collection list so nothing can join later
93
- * without being covered. Both are appended last, so a user hook cannot win.
74
+ * (it does nothing outside MCP requests) and `refusePublish` wherever drafts
75
+ * exist. It runs on the final collection list, so no collection escapes it, and
76
+ * both hooks are appended last, so a user hook cannot override them.
94
77
  */ const installDraftGuards = (collections) => collections.map((collection) => ({
95
78
  ...collection,
96
79
  hooks: {
@@ -100,10 +83,9 @@ const refusePublishGlobal = ({ data, global, req }) => {
100
83
  }
101
84
  }));
102
85
  /**
103
- * Attaches the guard to every global, exposed or not, for the same reason
104
- * `installDraftGuards` covers every collection: a custom tool running on an MCP
105
- * request must not be able to publish through a global the plugin config never
106
- * mentioned.
86
+ * Attaches the guard to every global, exposed or not: a custom tool running on
87
+ * an MCP request must not be able to publish through a global the plugin config
88
+ * never mentioned.
107
89
  */ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
108
90
  ...global,
109
91
  hooks: {
@@ -1,98 +1,21 @@
1
- import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, isIndexSegment, joinPath, splitPath } from "../schema/walk.mjs";
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 { z } from "zod";
7
+ import { reconcileRowIds } from "./row-ids.mjs";
6
8
  import { Pointer, applyPatch } from "rfc6902";
7
9
  //#region src/write/patch.ts
8
- const POINTER = z.string().regex(JSON_POINTER_PATTERN);
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.split("/").pop() ?? "");
16
+ return isIndexSegment(splitPath(pointer).at(-1) ?? "");
47
17
  };
48
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
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
- /** The one it carries, or the one it takes from `from`. `remove` writes nothing. */ const effectiveValue = (operation, doc) => {
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
- /** A pointer stopping short addresses a subtree; the fields beneath decide. */ const resolvesReadOnly = (resolution) => {
32
+ const resolvesReadOnly = (resolution) => {
110
33
  if (resolution.readOnly) return true;
111
34
  if (resolution.descriptor) return resolution.descriptor.readOnly === true;
112
- const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
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
- /** An element has no descriptor, so its field is read one segment up. */ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
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
- /** Unresolvable pointers say nothing here; the caller reports them anyway. */ const resolutionAt = (config, target) => {
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. This tool only ever writes drafts, and id, _status, createdAt and updatedAt are not writable; use publishDocument to publish.`];
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
- return [error instanceof Error ? error.message : "invalid"];
120
+ if (!(error instanceof SchemaError)) throw error;
121
+ return [error.message];
208
122
  }
209
123
  };
210
124
  /**
211
- * One evolving copy, so an operation depending on an earlier one resolves
212
- * against the shape it actually modifies, and a failure leaves the original
213
- * untouched. The caller writes nothing unless the whole batch came back
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 { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, droppedPointer, isElementPointer, isReservedPointer, stripRowIds };
145
+ export { applyPatchOperations, droppedPointer, isElementPointer, isReservedPointer };
@@ -1,21 +1,14 @@
1
- import { pointerFromPayloadPath } from "../schema/walk.mjs";
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
- * Runs Payload's own field validation over a draft without saving anything.
7
- *
8
- * The same traversal a real save runs, exported from `payload` and called with
9
- * `skipValidation: false` so it collects into `errors` instead of throwing. The
10
- * `beforeValidate` pass runs first because some field hooks (Lexical's) prepare
11
- * state in `context` that their `beforeChange` counterpart depends on.
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,11 @@ const withPublishIntent = (data) => ({
6
7
  ...data,
7
8
  [PUBLISH_INTENT]: TOKEN
8
9
  });
9
- const carries = (data) => typeof data === "object" && data !== null && data[PUBLISH_INTENT] === TOKEN;
10
- const hasPublishIntent = (data) => carries(data);
11
- /** Asked by the last hook that needs it, so it takes the marker off. */ const takePublishIntent = (data) => {
12
- if (!carries(data)) return false;
10
+ const hasPublishIntent = (data) => isPlainObject(data) && data[PUBLISH_INTENT] === TOKEN;
11
+ /**
12
+ * Called by the last hook that needs the marker, which removes it.
13
+ */ const takePublishIntent = (data) => {
14
+ if (!hasPublishIntent(data)) return false;
13
15
  delete data[PUBLISH_INTENT];
14
16
  return true;
15
17
  };
@@ -0,0 +1,46 @@
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
+ /**
37
+ * For create, where no stored row exists and any incoming id is invented.
38
+ */ const stripRowIds = (value) => {
39
+ const next = structuredClone(value);
40
+ walkRows(next, (row) => {
41
+ delete row["id"];
42
+ });
43
+ return next;
44
+ };
45
+ //#endregion
46
+ 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, run
5
- * `fn` as is.
6
- *
7
- * Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
8
- * the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
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": "1.1.0-beta.6",
4
+ "version": "2.0.0-beta.12",
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
- "./client": {
31
- "types": "./dist/client/index.d.mts",
32
- "default": "./dist/client/index.mjs"
30
+ "./admin": {
31
+ "types": "./dist/admin/index.d.mts",
32
+ "default": "./dist/admin/index.mjs"
33
33
  }
34
34
  },
35
35
  "files": [
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "prettier": "@abinnovision/prettier-config",
59
59
  "dependencies": {
60
- "@modelcontextprotocol/sdk": "^1.30.0",
60
+ "@modelcontextprotocol/sdk": "^1.30.1",
61
61
  "rfc6902": "^5.3.0",
62
62
  "zod": "^4.6.5"
63
63
  },
@@ -65,15 +65,16 @@
65
65
  "@abinnovision/eslint-config-base": "^3.4.2",
66
66
  "@abinnovision/prettier-config": "^2.2.0",
67
67
  "@arethetypeswrong/core": "^0.18.5",
68
- "@payloadcms/db-sqlite": "3.89.0",
69
- "@payloadcms/richtext-lexical": "3.89.0",
70
- "@payloadcms/ui": "3.89.0",
68
+ "@internal/test-utils": "0.0.0",
69
+ "@payloadcms/db-sqlite": "3.90.2",
70
+ "@payloadcms/richtext-lexical": "3.90.2",
71
+ "@payloadcms/ui": "3.90.2",
71
72
  "@swc/core": "^1.16.2",
72
73
  "@types/node": "^26.5.1",
73
74
  "@types/react": "^19.3.0",
74
75
  "@vitest/coverage-v8": "^5.0.0",
75
76
  "eslint": "^10.10.0",
76
- "payload": "3.89.0",
77
+ "payload": "3.90.2",
77
78
  "prettier": "^3.9.5",
78
79
  "publint": "^0.3.24",
79
80
  "react": "^19.3.0",
@@ -101,4 +102,4 @@
101
102
  "npm": true,
102
103
  "npmAccess": "public"
103
104
  }
104
- }
105
+ }