@abinnovision/payloadcms-mcpx 1.0.0-beta.11 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +103 -44
  2. package/dist/api-keys/fields.mjs +17 -4
  3. package/dist/capabilities.mjs +22 -3
  4. package/dist/endpoint/{result.mjs → errors.mjs} +4 -21
  5. package/dist/endpoint/handler.mjs +4 -2
  6. package/dist/endpoint/index.mjs +4 -0
  7. package/dist/endpoint/server.mjs +10 -5
  8. package/dist/index.d.mts +4 -5
  9. package/dist/index.mjs +2 -2
  10. package/dist/options.mjs +26 -9
  11. package/dist/plugin.mjs +1 -0
  12. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  13. package/dist/request.mjs +8 -0
  14. package/dist/{endpoint/result.d.mts → result.d.mts} +1 -2
  15. package/dist/result.mjs +22 -0
  16. package/dist/schema/index.mjs +6 -0
  17. package/dist/schema/pointer.mjs +1 -1
  18. package/dist/schema/walk.mjs +11 -15
  19. package/dist/tools/{index.mjs → builtin.mjs} +4 -2
  20. package/dist/tools/create-document.mjs +14 -9
  21. package/dist/tools/describe-schema.mjs +3 -3
  22. package/dist/tools/find-documents.mjs +1 -2
  23. package/dist/tools/get-document.mjs +2 -2
  24. package/dist/tools/list-capabilities.mjs +3 -2
  25. package/dist/tools/names.mjs +2 -1
  26. package/dist/tools/patch-document.mjs +14 -15
  27. package/dist/tools/publish-document.mjs +81 -0
  28. package/dist/tools/shared.mjs +50 -5
  29. package/dist/tools/target.mjs +4 -4
  30. package/dist/tools/validate-document.mjs +8 -9
  31. package/dist/types.d.mts +44 -23
  32. package/dist/write/draft-guard.mjs +71 -36
  33. package/dist/write/patch.mjs +131 -57
  34. package/dist/write/publish-blockers.mjs +10 -3
  35. package/dist/write/publish-intent.mjs +39 -0
  36. package/dist/write/transaction.mjs +7 -1
  37. package/package.json +1 -1
  38. package/dist/i18n.d.mts +0 -1
  39. package/dist/schema/lexical.d.mts +0 -1
  40. package/dist/schema/walk.d.mts +0 -3
  41. package/dist/tools/target.d.mts +0 -3
  42. package/dist/write/publish-blockers.d.mts +0 -15
package/dist/types.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
2
2
  import { z } from "zod";
3
- import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
4
3
  import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
4
+ import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
5
5
  //#region src/types.d.ts
6
6
  declare module "payload" {
7
7
  interface RequestContext {
@@ -11,6 +11,17 @@ declare module "payload" {
11
11
  "@abinnovision/payloadcms-mcpx": McpxPluginOptions;
12
12
  }
13
13
  }
14
+ /**
15
+ * How far an exposed entity lets MCP writes reach.
16
+ *
17
+ * - `false`: no write tool touches it.
18
+ * - `"draft"`: writes land as drafts and nothing MCP does changes what the
19
+ * public sees. Requires `versions.drafts`.
20
+ * - `"live"`: MCP may change live content. On an entity with drafts that means
21
+ * `publishDocument` is exposed; on one without, where there is no draft to
22
+ * land on, it means the write itself is permitted and lands live.
23
+ */
24
+ type McpxWriteMode = "draft" | "live" | false;
14
25
  /**
15
26
  * What an exposed collection offers to MCP clients. A key can only enable
16
27
  * what the config exposes here.
@@ -21,15 +32,10 @@ interface McpxCollectionOptions {
21
32
  */
22
33
  read?: boolean;
23
34
  /**
24
- * Expose `patchDocument`, `createDocument` and `validateDocument`. Default
25
- * `false`. Requires `versions.drafts` unless `allowLiveWrites` is set.
35
+ * Expose `patchDocument`, `createDocument` and `validateDocument`, and how
36
+ * far those writes reach. Default `false`.
26
37
  */
27
- write?: boolean;
28
- /**
29
- * Permit writes to a collection without drafts. Such writes land on the live
30
- * document because there is no draft to land on. Default `false`.
31
- */
32
- allowLiveWrites?: boolean;
38
+ write?: McpxWriteMode;
33
39
  }
34
40
  /**
35
41
  * What an exposed global offers to MCP clients. Structurally the same as
@@ -41,15 +47,10 @@ interface McpxGlobalOptions {
41
47
  /** Expose `describeSchema` and `getDocument`. Default `true`. */
42
48
  read?: boolean;
43
49
  /**
44
- * Expose `patchDocument` and `validateDocument`. Default `false`. Requires
45
- * `versions.drafts` unless `allowLiveWrites` is set.
46
- */
47
- write?: boolean;
48
- /**
49
- * Permit writes to a global without drafts. Such writes land on the live
50
- * document because there is no draft to land on. Default `false`.
50
+ * Expose `patchDocument` and `validateDocument`, and how far those writes
51
+ * reach. Default `false`.
51
52
  */
52
- allowLiveWrites?: boolean;
53
+ write?: McpxWriteMode;
53
54
  }
54
55
  type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
55
56
  /**
@@ -59,8 +60,7 @@ type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
59
60
  interface McpxExposedEntity {
60
61
  slug: string;
61
62
  read: boolean;
62
- write: boolean;
63
- allowLiveWrites: boolean;
63
+ write: McpxWriteMode;
64
64
  hasDrafts: boolean;
65
65
  /** Name of the capability group on the key document. */
66
66
  fieldName: string;
@@ -72,12 +72,14 @@ interface McpxExposedEntity {
72
72
  interface McpxToolScope {
73
73
  req: PayloadRequest;
74
74
  capabilities: McpxResolvedCapabilities;
75
- /** Collection slugs the key may read / write. */
75
+ /** Collection slugs the key may read / write / publish. */
76
76
  readable: string[];
77
77
  writable: string[];
78
- /** Global slugs the key may read / write. */
78
+ publishable: string[];
79
+ /** Global slugs the key may read / write / publish. */
79
80
  readableGlobals: string[];
80
81
  writableGlobals: string[];
82
+ publishableGlobals: string[];
81
83
  /** Configured locale codes, or `null` when localization is off. */
82
84
  locales: null | string[];
83
85
  defaultLocale: null | string;
@@ -103,7 +105,11 @@ interface McpxToolScope {
103
105
  interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
104
106
  /** camelCase, unique, not one of the builtin tool names. */
105
107
  name: string;
106
- description: string;
108
+ /**
109
+ * Fixed text, or text built per request so it can state what this key's
110
+ * writes actually do.
111
+ */
112
+ description: string | ((scope: McpxToolScope) => string);
107
113
  annotations?: ToolAnnotations;
108
114
  /**
109
115
  * Whether this key may call the tool; a tool that is not enabled never
@@ -210,6 +216,11 @@ type McpxPluginOptions = {
210
216
  interface McpxCollectionCapabilities {
211
217
  read: boolean;
212
218
  write: boolean;
219
+ /**
220
+ * Whether the key may publish this entity's draft. Only ever true where the
221
+ * config sets `write: "live"` and the entity has drafts.
222
+ */
223
+ publish: boolean;
213
224
  }
214
225
  /**
215
226
  * Capabilities in force for one request: plugin config AND key checkboxes.
@@ -223,5 +234,15 @@ interface McpxRequestContext {
223
234
  apiKeyId: number | string;
224
235
  capabilities: McpxResolvedCapabilities;
225
236
  }
237
+ /**
238
+ * One reason a human could not publish the draft as it stands.
239
+ */
240
+ interface PublishBlocker {
241
+ /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
242
+ field?: string;
243
+ message: string;
244
+ /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
245
+ path: string;
246
+ }
226
247
  //#endregion
227
- export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool };
248
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
@@ -1,9 +1,11 @@
1
+ import { claimPublishIntent, isClaimedPublish } from "./publish-intent.mjs";
2
+ import { isMcpxRequest } from "../request.mjs";
1
3
  import { APIError } from "payload";
2
4
  import { hasDraftsEnabled } from "payload/shared";
3
5
  //#region src/write/draft-guard.ts
4
6
  /**
5
7
  * Operation arguments that widen or redirect a write. Cleared on every MCP
6
- * create and update so a tool cannot smuggle them in.
8
+ * create and update, publishes included, so a tool cannot smuggle them in.
7
9
  */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
8
10
  "where",
9
11
  "publishAllLocales",
@@ -14,80 +16,113 @@ import { hasDraftsEnabled } from "payload/shared";
14
16
  "overwriteExistingFiles"
15
17
  ]);
16
18
  /**
17
- * Whether a request originated from the MCP endpoint. The endpoint stamps
18
- * `req.context.mcpx`, which travels into every local API call made with the
19
- * same `req`, including those made by custom tools.
20
- */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
21
- /**
22
- * Forces every MCP write into a draft save.
19
+ * Forces every MCP write into a draft save, unless it is the one write
20
+ * `publishDocument` asked for.
23
21
  *
24
22
  * `draft` alone is not enough: Payload's update path only saves a draft when
25
23
  * `data._status !== "published"`, so `_status` is dropped and left to Payload.
26
24
  * This runs as `beforeOperation`, before Payload reads any of these arguments,
27
25
  * so it holds for every create and update on an MCP request, not only the
28
26
  * builtin tools. Deletes are not guarded in v1; custom tools that delete are
29
- * the integrator's responsibility.
30
- */ const scrubWriteArgs = (args) => {
27
+ * the integrator's responsibility. `restoreVersion` and `duplicate` are outside
28
+ * the operation filter too — `restoreVersion` is caught by `refusePublish`
29
+ * because it runs the collection's `beforeChange` hooks, and anything going
30
+ * straight to `payload.db` bypasses all of this.
31
+ *
32
+ * On a claimed publish the argument scrubbing is unchanged — the whole
33
+ * `STRIPPED_ARGS` list still goes, `deletedAt` still goes, autosave, locks and
34
+ * trash are still forced off. Only `draft` and `_status` differ. Writing
35
+ * `_status` here rather than in the tool keeps the tool honest: it asks to
36
+ * publish, and this is the only thing that can grant it.
37
+ */ const scrubWriteArgs = (args, publishing) => {
31
38
  const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
32
39
  if (next["data"] && typeof next["data"] === "object") {
33
40
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
34
- next["data"] = data;
41
+ next["data"] = publishing ? {
42
+ ...data,
43
+ _status: "published"
44
+ } : data;
35
45
  }
36
- next["draft"] = true;
46
+ next["draft"] = !publishing;
37
47
  next["autosave"] = false;
38
48
  next["overrideLock"] = false;
39
49
  next["trash"] = false;
40
50
  return next;
41
51
  };
42
52
  const forceDraftWrite = (hookArgs) => {
43
- const { args, operation, req } = hookArgs;
53
+ const { args, collection, operation, req } = hookArgs;
44
54
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
45
- return scrubWriteArgs(args);
55
+ const publishing = operation === "update" && claimPublishIntent({
56
+ kind: "collection",
57
+ slug: collection.slug,
58
+ id: args.id
59
+ });
60
+ return scrubWriteArgs(args, publishing);
46
61
  };
47
62
  /**
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.
63
+ * The global counterpart of {@link forceDraftWrite}, with one important
64
+ * difference: Payload's `updateGlobal` destructures `draft`,
65
+ * `publishAllLocales`, `publishSpecificLocale`, `unpublishAllLocales` and
66
+ * `overrideLock` *before* it runs `beforeOperation`, and re-reads only `data`
67
+ * afterwards. Setting those here is a no-op. What still lands is `data`, and
68
+ * that is what the global draft guarantee actually rests on: `_status` is
69
+ * stripped, so a rogue `updateGlobal({ draft: false })` reaches
70
+ * {@link refusePublishGlobal} with no status and is refused there. The alarm,
71
+ * not the correction, is load-bearing for globals.
72
+ *
73
+ * The publish branch matters for the same reason. `publishDocument` passes
74
+ * `draft: false` at the call site because the hook cannot, and this hook must
75
+ * put `_status` back rather than strip it.
53
76
  *
54
77
  * 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.
78
+ * exists, so only `update` is intercepted.
59
79
  */ const forceDraftWriteGlobal = (hookArgs) => {
60
- const { operation, req } = hookArgs;
80
+ const { global, operation, req } = hookArgs;
61
81
  const args = hookArgs.args;
62
82
  if (!isMcpxRequest(req) || operation !== "update") return args;
63
- return scrubWriteArgs(args);
83
+ const publishing = claimPublishIntent({
84
+ kind: "global",
85
+ slug: global.slug
86
+ });
87
+ return scrubWriteArgs(args, publishing);
64
88
  };
65
89
  /**
66
- * Refuses an MCP write that would still not land as a draft. An alarm rather
67
- * than the guarantee: `forceDraftWrite` should make it unreachable. It throws
68
- * instead of correcting `_status` because Payload has already chosen the write
69
- * branch by the time a `beforeChange` hook runs.
70
- */ const refuseUnlessDraft = (req, slug, data) => {
90
+ * Refuses an MCP write that would still not land as a draft, and — on the one
91
+ * operation that claimed a publish intent — refuses anything that would not
92
+ * land as a publish. An alarm rather than the guarantee for collections, where
93
+ * `forceDraftWrite` should make it unreachable; the guarantee itself for
94
+ * globals, per {@link forceDraftWriteGlobal}. It throws instead of correcting
95
+ * `_status` because Payload has already chosen the write branch by the time a
96
+ * `beforeChange` hook runs.
97
+ */ const refuseUnlessExpected = (req, target, data) => {
71
98
  if (!isMcpxRequest(req)) return;
72
99
  const status = data._status;
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)}).`);
75
- throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
100
+ const publishing = isClaimedPublish(target.kind, target.slug);
101
+ const expected = publishing ? "published" : "draft";
102
+ if (status === expected) return;
103
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${target.slug} that would not have been a ${expected} (_status: ${String(status)}).`);
104
+ throw new APIError(publishing ? "This publish was refused because it would not have saved a published document." : "MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
76
105
  };
77
106
  const refusePublish = ({ collection, data, req }) => {
78
- refuseUnlessDraft(req, collection.slug, data);
107
+ refuseUnlessExpected(req, {
108
+ kind: "collection",
109
+ slug: collection.slug
110
+ }, data);
79
111
  return data;
80
112
  };
81
113
  /** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
82
114
  const next = data;
83
- refuseUnlessDraft(req, global.slug, next);
115
+ refuseUnlessExpected(req, {
116
+ kind: "global",
117
+ slug: global.slug
118
+ }, next);
84
119
  return next;
85
120
  };
86
121
  /**
87
122
  * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
88
123
  * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
89
124
  * exist. Applied to the built collection list so nothing can join later
90
- * without being covered.
125
+ * without being covered. Both are appended last, so a user hook cannot win.
91
126
  */ const installDraftGuards = (collections) => collections.map((collection) => ({
92
127
  ...collection,
93
128
  hooks: {
@@ -110,4 +145,4 @@ const refusePublish = ({ collection, data, req }) => {
110
145
  }
111
146
  }));
112
147
  //#endregion
113
- export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
148
+ export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
@@ -1,24 +1,45 @@
1
- import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, splitPath } from "../schema/walk.mjs";
2
- import { validateWriteValue } from "../schema/shape.mjs";
1
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, joinPath, splitPath } from "../schema/walk.mjs";
3
2
  import { resolveDataPointer } from "../schema/pointer.mjs";
3
+ import { validateWriteValue } from "../schema/shape.mjs";
4
+ import "../schema/index.mjs";
4
5
  import { z } from "zod";
5
6
  import { Pointer, applyPatch } from "rfc6902";
6
7
  //#region src/write/patch.ts
8
+ const POINTER = z.string().regex(JSON_POINTER_PATTERN);
7
9
  /**
8
- * One RFC 6902 operation as accepted by `patchDocument`.
9
- */ const PATCH_OPERATION_SCHEMA = z.object({
10
- from: z.string().regex(JSON_POINTER_PATTERN).optional(),
11
- op: z.enum([
12
- "add",
13
- "copy",
14
- "move",
15
- "remove",
16
- "replace",
17
- "test"
18
- ]),
19
- path: z.string().regex(JSON_POINTER_PATTERN),
20
- value: z.unknown().optional()
21
- }).describe("An RFC 6902 operation.");
10
+ * One RFC 6902 operation as accepted by `patchDocument`. Discriminated on `op`
11
+ * so an operation carries only the members RFC 6902 defines for it.
12
+ */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
13
+ z.strictObject({
14
+ op: z.literal("add"),
15
+ path: POINTER,
16
+ value: z.unknown()
17
+ }),
18
+ z.strictObject({
19
+ op: z.literal("remove"),
20
+ path: POINTER
21
+ }),
22
+ z.strictObject({
23
+ op: z.literal("replace"),
24
+ path: POINTER,
25
+ value: z.unknown()
26
+ }),
27
+ z.strictObject({
28
+ from: POINTER,
29
+ op: z.literal("move"),
30
+ path: POINTER
31
+ }),
32
+ z.strictObject({
33
+ from: POINTER,
34
+ op: z.literal("copy"),
35
+ path: POINTER
36
+ }),
37
+ z.strictObject({
38
+ op: z.literal("test"),
39
+ path: POINTER,
40
+ value: z.unknown()
41
+ })
42
+ ]).describe("An RFC 6902 operation.");
22
43
  /**
23
44
  * Whether a pointer touches a field Payload maintains.
24
45
  */ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
@@ -87,59 +108,112 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
87
108
  return next;
88
109
  };
89
110
  /**
90
- * Applies a patch to a deep copy of the document, so a failing operation
91
- * leaves the original untouched and nothing partial is ever written.
92
- */ const applyPatchToCopy = (doc, patches) => {
93
- const next = structuredClone(doc);
94
- const prepared = patches.map((operation) => {
95
- const cloned = "value" in operation ? {
96
- ...operation,
97
- value: structuredClone(operation.value)
98
- } : operation;
99
- if (cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(next) === void 0) return {
100
- ...cloned,
101
- op: "add"
102
- };
103
- return cloned;
104
- });
105
- const problems = applyPatch(next, prepared).flatMap((error, index) => error ? [`patches[${String(index)}]: ${error.message}`] : []);
106
- if (problems.length > 0) return { problems };
107
- reconcileRowIds(next, doc);
108
- return { next };
111
+ * The operation as it is applied: values are cloned so the written document
112
+ * never shares references with the caller's operations, and a `replace` of a
113
+ * field the target locale has no value for becomes an `add`, which is what
114
+ * RFC 6902 requires when nothing is there to replace.
115
+ */ const prepare = (operation, doc) => {
116
+ const cloned = "value" in operation ? {
117
+ ...operation,
118
+ value: structuredClone(operation.value)
119
+ } : operation;
120
+ return cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(doc) === void 0 ? {
121
+ ...cloned,
122
+ op: "add"
123
+ } : cloned;
109
124
  };
110
125
  /**
111
- * Checks every operation against the schema before any is applied.
112
- *
113
- * A partially applied batch is worse than a refused one, so this returns all
114
- * problems and the caller applies nothing unless the list is empty.
115
- */ const findPatchProblems = (config, target) => target.patches.flatMap((operation, index) => {
116
- const at = `patches[${String(index)}]`;
117
- const pointers = [operation.path, ..."from" in operation && operation.from ? [operation.from] : []];
118
- if (pointers.includes("")) return [`${at}: an empty pointer addresses the whole document. Address a field instead.`];
126
+ * The value an operation writes at its path: the one it carries, or the one it
127
+ * takes from `from`. A `remove` writes nothing.
128
+ */ const effectiveValue = (operation, doc) => {
129
+ if ("value" in operation) return operation.value;
130
+ return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
131
+ };
132
+ /**
133
+ * Whether a resolved pointer lands in a read-only field. A pointer that stops
134
+ * short of one addresses a subtree, and the fields beneath it decide.
135
+ */ const resolvesReadOnly = (resolution) => {
136
+ if (resolution.descriptor) return resolution.descriptor.readOnly === true;
137
+ const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
138
+ return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
139
+ };
140
+ /**
141
+ * Whether the pointer addresses something read-only. An element carries no
142
+ * descriptor of its own, so the field it belongs to is read one segment up.
143
+ */ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
144
+ doc: target.doc,
145
+ pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
146
+ ref: target.ref
147
+ }));
148
+ /**
149
+ * Checks one operation against the schema, in the state the document is in
150
+ * when that operation runs. Both pointers must resolve, whatever the operation
151
+ * writes at its path must pass write validation, and what it drops must not sit
152
+ * in a read-only field.
153
+ */ const findOperationProblems = (config, target) => {
154
+ const { doc, operation, ref } = target;
155
+ const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
156
+ if (pointers.includes("")) return ["an empty pointer addresses the whole document. Address a field instead."];
119
157
  const reserved = pointers.find(isReservedPointer);
120
- if (reserved !== void 0) return [`${at}: "${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
158
+ if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. This tool only ever writes drafts, and id, _status, createdAt and updatedAt are not writable; use publishDocument to publish.`];
121
159
  const dropped = droppedPointer(operation);
122
- if (dropped !== void 0 && !isElementPointer(dropped)) return [`${at}: "${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
123
- const value = "value" in operation ? operation.value : void 0;
160
+ if (dropped !== void 0 && !isElementPointer(dropped)) return [`"${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
124
161
  try {
125
- const moved = "from" in operation && operation.from ? Pointer.fromJSON(operation.from).get(target.doc) : void 0;
162
+ const value = effectiveValue(operation, doc);
163
+ if (value !== void 0 && operation.op !== "test" && isReadOnlyPointer(config, {
164
+ doc,
165
+ pointer: operation.path,
166
+ ref
167
+ })) return [`"${operation.path}" is read-only and cannot be written.`];
168
+ if (dropped !== void 0 && isReadOnlyPointer(config, {
169
+ doc,
170
+ pointer: dropped,
171
+ ref
172
+ })) return [`"${dropped}" sits in a read-only field and cannot be removed.`];
126
173
  for (const pointer of pointers) {
127
174
  const resolution = resolveDataPointer(config, {
128
- addedValue: value ?? moved,
129
- doc: target.doc,
175
+ addedValue: value,
176
+ doc,
130
177
  pointer,
131
- ref: target.ref
178
+ ref
132
179
  });
133
- if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
134
- pointer,
135
- resolution
136
- }, value).map((problem) => `${at}: ${problem}`);
180
+ if (pointer === operation.path && value !== void 0) {
181
+ const problems = validateWriteValue(config, {
182
+ pointer,
183
+ resolution
184
+ }, value);
185
+ if (problems.length > 0) return problems;
186
+ }
137
187
  }
138
188
  return [];
139
189
  } catch (error) {
140
- return [`${at}: ${error instanceof Error ? error.message : "invalid"}`];
190
+ return [error instanceof Error ? error.message : "invalid"];
191
+ }
192
+ };
193
+ /**
194
+ * Validates and applies every operation against one evolving copy of the
195
+ * document, so an operation that depends on an earlier one resolves against
196
+ * the shape it actually modifies.
197
+ *
198
+ * The copy means a failing operation leaves the original untouched, and the
199
+ * caller writes nothing unless the whole batch came back applied, so a
200
+ * partially applied batch is never persisted.
201
+ */ const applyPatchOperations = (config, target) => {
202
+ const next = structuredClone(target.doc);
203
+ for (const [index, operation] of target.patches.entries()) {
204
+ const at = `patches[${String(index)}]`;
205
+ const problems = findOperationProblems(config, {
206
+ doc: next,
207
+ operation,
208
+ ref: target.ref
209
+ });
210
+ if (problems.length > 0) return { problems: problems.map((problem) => `${at}: ${problem}`) };
211
+ const [error] = applyPatch(next, [prepare(operation, next)]);
212
+ if (error) return { problems: [`${at}: ${error.message}`] };
141
213
  }
142
- });
214
+ reconcileRowIds(next, target.doc);
215
+ return { next };
216
+ };
143
217
  /**
144
218
  * Keys Payload manages on a row that travel back into the write unchanged.
145
219
  */ const ROW_KEYS = /* @__PURE__ */ new Set([
@@ -216,4 +290,4 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
216
290
  });
217
291
  };
218
292
  //#endregion
219
- export { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, droppedPointer, findPatchProblems, isElementPointer, isReservedPointer, stripRowIds };
293
+ export { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, droppedPointer, isElementPointer, isReservedPointer, stripRowIds };
@@ -1,4 +1,5 @@
1
1
  import { pointerFromPayloadPath } from "../schema/walk.mjs";
2
+ import "../schema/index.mjs";
2
3
  import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
3
4
  //#region src/write/publish-blockers.ts
4
5
  /**
@@ -18,6 +19,9 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
18
19
  *
19
20
  * Limits: only the locale the doc was read in is checked, and field-level
20
21
  * `beforeChange` hooks run again, which is safe only for pure ones.
22
+ *
23
+ * `unavailable` marks a traversal that threw, which is not the same answer as
24
+ * a document with nothing wrong with it.
21
25
  */ const collectPublishBlockers = async (req, target) => {
22
26
  const { doc, entity } = target;
23
27
  const id = doc["id"];
@@ -62,13 +66,16 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
62
66
  });
63
67
  } catch (error) {
64
68
  req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${entity.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
65
- return [];
69
+ return {
70
+ blockers: [],
71
+ unavailable: true
72
+ };
66
73
  }
67
- return errors.map((error) => ({
74
+ return { blockers: errors.map((error) => ({
68
75
  message: error.message,
69
76
  path: pointerFromPayloadPath(error.path),
70
77
  ...typeof error.label === "string" ? { field: error.label } : {}
71
- }));
78
+ })) };
72
79
  };
73
80
  //#endregion
74
81
  export { collectPublishBlockers };
@@ -0,0 +1,39 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/write/publish-intent.ts
3
+ const store = new AsyncLocalStorage();
4
+ /** Runs `fn` with `intent` in force. */ const withPublishIntent = async (intent, fn) => await store.run({
5
+ ...intent,
6
+ claimed: false
7
+ }, fn);
8
+ const activeFor = (target) => {
9
+ const active = store.getStore();
10
+ return active && active.kind === target.kind && active.slug === target.slug && active.id === target.id ? active : void 0;
11
+ };
12
+ /**
13
+ * Claims the intent for one operation, which `beforeOperation` does so that a
14
+ * re-entrant write to the same document — an `afterChange` hook calling
15
+ * `payload.update`, say — cannot ride along on it. Only the first operation to
16
+ * ask gets it.
17
+ */ const claimPublishIntent = (target) => {
18
+ const active = activeFor(target);
19
+ if (!active || active.claimed) return false;
20
+ active.claimed = true;
21
+ return true;
22
+ };
23
+ /**
24
+ * Whether this change belongs to the operation that claimed the intent, which
25
+ * is what lets the `beforeChange` alarm accept a published status.
26
+ *
27
+ * The id is deliberately not compared here. A `beforeChange` hook reads it from
28
+ * the loaded document, where Payload has already coerced it to the collection's
29
+ * id type, while the claim above saw the raw tool argument; comparing the two
30
+ * would refuse a legitimate publish over `1` versus `"1"`. Nothing is lost: a
31
+ * nested write to another document of the same collection during the publish
32
+ * cannot claim the intent, so `forceDraftWrite` has already stripped its
33
+ * `_status` and it fails the alarm on that.
34
+ */ const isClaimedPublish = (kind, slug) => {
35
+ const active = store.getStore();
36
+ return active?.kind === kind && active.slug === slug && active.claimed;
37
+ };
38
+ //#endregion
39
+ export { claimPublishIntent, isClaimedPublish, withPublishIntent };
@@ -2,8 +2,14 @@ import { commitTransaction, initTransaction, killTransaction } from "payload";
2
2
  //#region src/write/transaction.ts
3
3
  /**
4
4
  * Runs `fn` inside one database transaction on `req`, so a read followed by a
5
- * write cannot interleave with another writer. Adapters without transaction
5
+ * write is committed or rolled back together. Adapters without transaction
6
6
  * support, or a request that already owns one, run `fn` as is.
7
+ *
8
+ * Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
9
+ * the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
10
+ * is this safe across the tool calls of one JSON-RPC batch, which share a
11
+ * request: the second caller joins the first's transaction, so one tool's
12
+ * rollback takes the other's work with it.
7
13
  */ const withTransaction = async (req, fn) => {
8
14
  if (!await initTransaction(req)) return await fn();
9
15
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.11",
4
+ "version": "1.0.0-beta.13",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",
package/dist/i18n.d.mts DELETED
@@ -1 +0,0 @@
1
- import { PayloadRequest } from "payload";
@@ -1 +0,0 @@
1
- import "payload";
@@ -1,3 +0,0 @@
1
- import "./lexical.mjs";
2
- import "../i18n.mjs";
3
- import "payload";
@@ -1,3 +0,0 @@
1
- import "../types.mjs";
2
- import "../schema/walk.mjs";
3
- import "payload";