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

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/dist/types.d.mts CHANGED
@@ -22,27 +22,18 @@ declare module "payload" {
22
22
  * land on, it means the write itself is permitted and lands live.
23
23
  */
24
24
  type McpxWriteMode = "draft" | "live" | false;
25
- /**
26
- * What an exposed collection offers to MCP clients. A key can only enable
27
- * what the config exposes here.
28
- */
25
+ /** A key can only enable what the config exposes here. */
29
26
  interface McpxCollectionOptions {
30
- /**
31
- * Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`.
32
- */
27
+ /** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
33
28
  read?: boolean;
34
29
  /**
35
- * Expose `patchDocument`, `createDocument` and `validateDocument`, and how
36
- * far those writes reach. Default `false`.
30
+ * Expose `patchDocument`, `validateDocument` and, unless this is an upload
31
+ * collection, `createDocument`, and how far those writes reach. Default
32
+ * `false`.
37
33
  */
38
34
  write?: McpxWriteMode;
39
35
  }
40
- /**
41
- * What an exposed global offers to MCP clients. Structurally the same as
42
- * {@link McpxCollectionOptions}, kept separate because the tools it names
43
- * differ: a global is a singleton, so neither `findDocuments` nor
44
- * `createDocument` reaches one.
45
- */
36
+ /** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
46
37
  interface McpxGlobalOptions {
47
38
  /** Expose `describeSchema` and `getDocument`. Default `true`. */
48
39
  read?: boolean;
@@ -53,77 +44,60 @@ interface McpxGlobalOptions {
53
44
  write?: McpxWriteMode;
54
45
  }
55
46
  type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
56
- /**
57
- * A collection or global the plugin config exposes, before an API key's
58
- * checkboxes narrow it further.
59
- */
47
+ /** What the config exposes, before an API key's checkboxes narrow it. */
60
48
  interface McpxExposedEntity {
61
49
  slug: string;
62
50
  read: boolean;
63
51
  write: McpxWriteMode;
64
52
  hasDrafts: boolean;
53
+ /** An upload document is a file, and no tool here can supply one. */
54
+ isUpload: boolean;
65
55
  /** Name of the capability group on the key document. */
66
56
  fieldName: string;
67
57
  }
68
- /**
69
- * Everything a tool knows about the current request: the authenticated
70
- * request, what this key may touch and the limits in force.
71
- */
58
+ /** What a tool knows about the current request. */
72
59
  interface McpxToolScope {
73
60
  req: PayloadRequest;
74
61
  capabilities: McpxResolvedCapabilities;
75
- /** Collection slugs the key may read / write / publish. */
76
62
  readable: string[];
77
63
  writable: string[];
78
64
  publishable: string[];
79
- /** Global slugs the key may read / write / publish. */
80
65
  readableGlobals: string[];
81
66
  writableGlobals: string[];
82
67
  publishableGlobals: string[];
83
- /** Configured locale codes, or `null` when localization is off. */
68
+ /** `null` when localization is off. */
84
69
  locales: null | string[];
85
70
  defaultLocale: null | string;
86
71
  limits: {
87
72
  maxLimit: number;
88
73
  maxDepth: number;
89
74
  };
90
- /** What the plugin config exposes, before the key's checkboxes apply. */
91
75
  exposure: {
92
76
  collections: McpxExposedEntity[];
93
77
  globals: McpxExposedEntity[];
94
78
  };
95
79
  }
96
80
  /**
97
- * A tool. The builtins and any tool passed through `options.tools` use this
98
- * same shape and register through the same loop. Every tool runs with
99
- * `req.user` resolved from the key and `req.context.mcpx` set.
100
- *
101
- * `Args` only needs stating when `inputSchema` is built per request, which
102
- * leaves no static shape to infer from; a tool with a fixed shape gets its
103
- * argument type from that shape.
81
+ * A tool, builtin or custom. Runs with `req.user` resolved from the key and
82
+ * `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
83
+ * per request, leaving no static shape to infer from.
104
84
  */
105
85
  interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
106
86
  /** camelCase, unique, not one of the builtin tool names. */
107
87
  name: string;
108
- /**
109
- * Fixed text, or text built per request so it can state what this key's
110
- * writes actually do.
111
- */
88
+ /** Built per request so it can state what this key's writes actually do. */
112
89
  description: string | ((scope: McpxToolScope) => string);
113
90
  annotations?: ToolAnnotations;
114
91
  /**
115
- * Whether this key may call the tool; a tool that is not enabled never
116
- * appears in `tools/list`. Defaults to the tool's own checkbox on the API
117
- * key, which the builtins replace to derive availability from the key's
118
- * collection and global capabilities. Defining it replaces that checkbox
119
- * check rather than adding to it.
92
+ * A tool that is not enabled never appears in `tools/list`. Defaults to the
93
+ * tool's own checkbox on the API key; defining it replaces that check rather
94
+ * than adding to it.
120
95
  */
121
96
  isEnabled?: (scope: McpxToolScope) => boolean;
122
97
  /**
123
- * A fixed shape, or one built per request so enums can be narrowed to what
124
- * the key may touch. Registered strictly either way: an unknown argument is
125
- * rejected by name instead of being stripped and the tool answering as if
126
- * it had not been passed.
98
+ * Built per request so enums can be narrowed to what the key may touch.
99
+ * Registered strictly either way: an unknown argument is rejected by name
100
+ * rather than stripped.
127
101
  */
128
102
  inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
129
103
  handler(ctx: {
@@ -134,44 +108,36 @@ interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z
134
108
  extra: McpxToolExtra;
135
109
  }): CallToolResult | Promise<CallToolResult>;
136
110
  }
137
- /**
138
- * A tool with its argument type erased, which is how a registry holds tools of
139
- * differing input shapes. Each tool validates its own arguments through its
140
- * input schema.
141
- */
111
+ /** Argument type erased, so a registry can hold tools of differing shapes. */
142
112
  type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
143
- /**
144
- * Defines a tool with a fixed input shape. The handler's arguments are
145
- * inferred from that shape.
146
- */
113
+ /** Fixed shape; arguments inferred from it. */
147
114
  declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
148
115
  inputSchema?: Shape;
149
116
  }): McpxTool<Shape>;
150
- /**
151
- * Defines a tool whose input shape is built per request and returned as an
152
- * object literal. The handler's arguments are inferred from that literal, so
153
- * a scope-narrowed enum still types as the value it produces.
154
- */
117
+ /** Per-request shape returned as an object literal; arguments inferred from it. */
155
118
  declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
156
119
  inputSchema: (scope: McpxToolScope) => Shape;
157
120
  }): McpxAnyTool;
158
121
  /**
159
- * Defines a tool whose input shape is assembled from helpers that erase to
160
- * `z.ZodRawShape`, as the builtins do. Nothing is left to infer from, so the
161
- * handler's arguments are stated instead: `defineMcpxTool<Args>({ ... })`.
122
+ * Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
123
+ * builtins do. Nothing to infer from, so state the arguments instead.
162
124
  */
163
125
  declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
164
126
  inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
165
127
  }): McpxAnyTool;
166
- /**
167
- * Outcome of resolving an API key. `user` must carry `collection`.
168
- */
169
128
  interface McpxAuthResult {
129
+ /** Must carry `collection`. */
170
130
  user: TypedUser;
171
131
  apiKeyId: number | string;
172
132
  /** The `capabilities` group as stored on the key document. */
173
133
  capabilities: unknown;
174
134
  }
135
+ /**
136
+ * Everything the plugin accepts. `collections` is the only required option.
137
+ *
138
+ * A type alias rather than an interface: `definePlugin` constrains its options
139
+ * to `Record<string, unknown>`, which interfaces do not satisfy.
140
+ */
175
141
  type McpxPluginOptions = {
176
142
  /** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
177
143
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
@@ -213,30 +179,25 @@ type McpxPluginOptions = {
213
179
  version?: string;
214
180
  };
215
181
  };
182
+ /** What a key may do with one entity. Globals reuse this shape. */
216
183
  interface McpxCollectionCapabilities {
217
184
  read: boolean;
218
185
  write: boolean;
219
- /**
220
- * Whether the key may publish this entity's draft. Only ever true where the
221
- * config sets `write: "live"` and the entity has drafts.
222
- */
186
+ /** Only ever true where the config sets `write: "live"` and drafts exist. */
223
187
  publish: boolean;
224
188
  }
225
- /**
226
- * Capabilities in force for one request: plugin config AND key checkboxes.
227
- */
189
+ /** In force for one request: plugin config AND key checkboxes. */
228
190
  interface McpxResolvedCapabilities {
229
191
  collections: Record<string, McpxCollectionCapabilities>;
230
192
  globals: Record<string, McpxCollectionCapabilities>;
231
193
  tools: Record<string, boolean>;
232
194
  }
195
+ /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
233
196
  interface McpxRequestContext {
234
197
  apiKeyId: number | string;
235
198
  capabilities: McpxResolvedCapabilities;
236
199
  }
237
- /**
238
- * One reason a human could not publish the draft as it stands.
239
- */
200
+ /** One reason a human could not publish the draft as it stands. */
240
201
  interface PublishBlocker {
241
202
  /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
242
203
  field?: string;
@@ -1,12 +1,9 @@
1
- import { claimPublishIntent, isClaimedPublish } from "./publish-intent.mjs";
1
+ import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
2
2
  import { isMcpxRequest } from "../request.mjs";
3
3
  import { APIError } from "payload";
4
4
  import { hasDraftsEnabled } from "payload/shared";
5
5
  //#region src/write/draft-guard.ts
6
- /**
7
- * Operation arguments that widen or redirect a write. Cleared on every MCP
8
- * create and update, publishes included, so a tool cannot smuggle them in.
9
- */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
6
+ /** Cleared on every MCP write, publishes included, so none can be smuggled in. */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
10
7
  "where",
11
8
  "publishAllLocales",
12
9
  "publishSpecificLocale",
@@ -21,19 +18,13 @@ import { hasDraftsEnabled } from "payload/shared";
21
18
  *
22
19
  * `draft` alone is not enough: Payload's update path only saves a draft when
23
20
  * `data._status !== "published"`, so `_status` is dropped and left to Payload.
24
- * This runs as `beforeOperation`, before Payload reads any of these arguments,
25
- * so it holds for every create and update on an MCP request, not only the
26
- * builtin tools. Deletes are not guarded in v1; custom tools that delete are
27
- * the integrator's responsibility. `restoreVersion` and `duplicate` are outside
28
- * the operation filter too — `restoreVersion` is caught by `refusePublish`
29
- * because it runs the collection's `beforeChange` hooks, and anything going
30
- * straight to `payload.db` bypasses all of this.
21
+ * Writing it here rather than in the tool keeps the tool honest, since this is
22
+ * the only thing that can grant a publish.
31
23
  *
32
- * 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.
24
+ * Not covered: deletes, `duplicate`, files (the local API lifts `file` and
25
+ * `filePath` onto `req` before this runs), and anything going straight to
26
+ * `payload.db`. `restoreVersion` is caught by {@link refusePublish} instead,
27
+ * because it runs the collection's `beforeChange` hooks.
37
28
  */ const scrubWriteArgs = (args, publishing) => {
38
29
  const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
39
30
  if (next["data"] && typeof next["data"] === "object") {
@@ -50,72 +41,49 @@ import { hasDraftsEnabled } from "payload/shared";
50
41
  return next;
51
42
  };
52
43
  const forceDraftWrite = (hookArgs) => {
53
- const { args, collection, operation, req } = hookArgs;
44
+ const { args, operation, req } = hookArgs;
54
45
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
55
- const publishing = operation === "update" && claimPublishIntent({
56
- kind: "collection",
57
- slug: collection.slug,
58
- id: args.id
59
- });
46
+ const publishing = operation === "update" && hasPublishIntent(args.data);
60
47
  return scrubWriteArgs(args, publishing);
61
48
  };
62
49
  /**
63
- * The global counterpart of {@link forceDraftWrite}, with one 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.
76
- *
77
- * The global operation union has no `create` member because a global always
78
- * exists, so only `update` is intercepted.
50
+ * The global counterpart of {@link forceDraftWrite}, with one difference that
51
+ * decides where the guarantee lives: `updateGlobal` destructures `draft` and
52
+ * the publish arguments *before* it runs `beforeOperation` and re-reads only
53
+ * `data` afterwards, so setting them here is a no-op. What lands is `data` with
54
+ * `_status` stripped, which makes {@link refusePublishGlobal} the alarm that
55
+ * actually holds the line. `publishDocument` therefore passes `draft: false` at
56
+ * the call site, and this hook puts `_status` back rather than stripping it.
79
57
  */ const forceDraftWriteGlobal = (hookArgs) => {
80
- const { global, operation, req } = hookArgs;
58
+ const { operation, req } = hookArgs;
81
59
  const args = hookArgs.args;
82
60
  if (!isMcpxRequest(req) || operation !== "update") return args;
83
- const publishing = claimPublishIntent({
84
- kind: "global",
85
- slug: global.slug
86
- });
87
- return scrubWriteArgs(args, publishing);
61
+ return scrubWriteArgs(args, hasPublishIntent(args["data"]));
88
62
  };
89
63
  /**
90
- * 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) => {
64
+ * Throws instead of correcting `_status`, because Payload has already chosen
65
+ * the write branch by the time a `beforeChange` hook runs. Unreachable for a
66
+ * collection if {@link forceDraftWrite} did its job; the guarantee itself for a
67
+ * global. Last hook that needs the marker, so it takes it off.
68
+ */ const refuseUnlessExpected = (req, slug, data) => {
69
+ const publishing = takePublishIntent(data);
98
70
  if (!isMcpxRequest(req)) return;
99
71
  const status = data._status;
100
- const publishing = isClaimedPublish(target.kind, target.slug);
101
72
  const expected = publishing ? "published" : "draft";
102
73
  if (status === expected) return;
103
- req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${target.slug} that would not have been a ${expected} (_status: ${String(status)}).`);
74
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
104
75
  throw new APIError(publishing ? "This publish was refused because it would not have saved a published document." : "MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
105
76
  };
106
- const refusePublish = ({ collection, data, req }) => {
107
- refuseUnlessExpected(req, {
108
- kind: "collection",
109
- slug: collection.slug
110
- }, data);
77
+ /**
78
+ * Installs {@link refuseUnlessExpected} on every collection write. Returns
79
+ * `data` unchanged when the write is allowed; the hook exists for its throw.
80
+ */ const refusePublish = ({ collection, data, req }) => {
81
+ refuseUnlessExpected(req, collection.slug, data);
111
82
  return data;
112
83
  };
113
- /** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
84
+ const refusePublishGlobal = ({ data, global, req }) => {
114
85
  const next = data;
115
- refuseUnlessExpected(req, {
116
- kind: "global",
117
- slug: global.slug
118
- }, next);
86
+ refuseUnlessExpected(req, global.slug, next);
119
87
  return next;
120
88
  };
121
89
  /**
@@ -6,10 +6,7 @@ import { z } from "zod";
6
6
  import { Pointer, applyPatch } from "rfc6902";
7
7
  //#region src/write/patch.ts
8
8
  const POINTER = z.string().regex(JSON_POINTER_PATTERN);
9
- /**
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", [
9
+ /** Discriminated on `op`, so an operation carries only its own members. */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
13
10
  z.strictObject({
14
11
  op: z.literal("add"),
15
12
  path: POINTER,
@@ -40,31 +37,18 @@ const POINTER = z.string().regex(JSON_POINTER_PATTERN);
40
37
  value: z.unknown()
41
38
  })
42
39
  ]).describe("An RFC 6902 operation.");
43
- /**
44
- * Whether a pointer touches a field Payload maintains.
45
- */ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
46
- /**
47
- * The pointer an operation removes a value from, if it removes one at all.
48
- */ const droppedPointer = (operation) => {
40
+ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
41
+ const droppedPointer = (operation) => {
49
42
  if (operation.op === "remove") return operation.path;
50
43
  return operation.op === "move" ? operation.from : void 0;
51
44
  };
52
- /**
53
- * Whether a pointer addresses a list element rather than a field.
54
- */ const isElementPointer = (pointer) => {
45
+ const isElementPointer = (pointer) => {
55
46
  const last = pointer.split("/").pop() ?? "";
56
47
  return last === "-" || /^\d+$/.test(last);
57
48
  };
58
49
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
59
- /**
60
- * A Lexical editor state. Its nodes manage their own ids, so it is never
61
- * descended into.
62
- */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
63
- /**
64
- * Visits every row in a value: a plain object that carries `blockType` or
65
- * sits directly inside an array. Rich text states manage their own nodes and
66
- * are never descended into.
67
- */ const walkRows = (value, visit, isRow = false) => {
50
+ /** Its nodes manage their own ids, so it is never descended into. */ const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
51
+ /** A row is a plain object carrying `blockType`, or one sitting in an array. */ const walkRows = (value, visit, isRow = false) => {
68
52
  if (Array.isArray(value)) {
69
53
  for (const entry of value) walkRows(entry, visit, true);
70
54
  return;
@@ -97,10 +81,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
97
81
  delete row["id"];
98
82
  });
99
83
  };
100
- /**
101
- * Drops every row id from a copy of `value`. Used on create, where no stored
102
- * row exists and any incoming id is client-invented.
103
- */ const stripRowIds = (value) => {
84
+ /** For create, where no stored row exists and any incoming id is invented. */ const stripRowIds = (value) => {
104
85
  const next = structuredClone(value);
105
86
  walkRows(next, (row) => {
106
87
  delete row["id"];
@@ -122,34 +103,24 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
122
103
  op: "add"
123
104
  } : cloned;
124
105
  };
125
- /**
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) => {
106
+ /** The one it carries, or the one it takes from `from`. `remove` writes nothing. */ const effectiveValue = (operation, doc) => {
129
107
  if ("value" in operation) return operation.value;
130
108
  return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
131
109
  };
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) => {
110
+ /** A pointer stopping short addresses a subtree; the fields beneath decide. */ const resolvesReadOnly = (resolution) => {
136
111
  if (resolution.descriptor) return resolution.descriptor.readOnly === true;
137
112
  const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
138
113
  return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
139
114
  };
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, {
115
+ /** An element has no descriptor, so its field is read one segment up. */ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
144
116
  doc: target.doc,
145
117
  pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
146
118
  ref: target.ref
147
119
  }));
148
120
  /**
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.
121
+ * Checked against the document as it stands when this operation runs: both
122
+ * pointers must resolve, the written value must pass write validation, and what
123
+ * it drops must not sit in a read-only field.
153
124
  */ const findOperationProblems = (config, target) => {
154
125
  const { doc, operation, ref } = target;
155
126
  const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
@@ -191,13 +162,10 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
191
162
  }
192
163
  };
193
164
  /**
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.
165
+ * One evolving copy, so an operation depending on an earlier one resolves
166
+ * against the shape it actually modifies, and a failure leaves the original
167
+ * untouched. The caller writes nothing unless the whole batch came back
168
+ * applied, so a partial batch is never persisted.
201
169
  */ const applyPatchOperations = (config, target) => {
202
170
  const next = structuredClone(target.doc);
203
171
  for (const [index, operation] of target.patches.entries()) {
@@ -214,18 +182,15 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
214
182
  reconcileRowIds(next, target.doc);
215
183
  return { next };
216
184
  };
217
- /**
218
- * Keys Payload manages on a row that travel back into the write unchanged.
219
- */ const ROW_KEYS = /* @__PURE__ */ new Set([
185
+ const ROW_KEYS = /* @__PURE__ */ new Set([
220
186
  "blockName",
221
187
  "blockType",
222
188
  "id"
223
189
  ]);
224
190
  /**
225
- * Picks the keys the schema walker describes out of `value`, descending into
226
- * groups, named tabs, arrays and blocks. Everything Payload maintains or
227
- * derives (`_status`, timestamps, join and virtual fields, upload base fields)
228
- * is left out, so the write-back carries only what a client could have set.
191
+ * Everything Payload maintains or derives (`_status`, timestamps, join and
192
+ * virtual fields, upload base fields) is left out, so the write-back carries
193
+ * only what a client could have set.
229
194
  */ const pickDescribed = (config, value, at) => {
230
195
  const { fields, prefix, isRow } = at;
231
196
  const relative = describeAddressableFields(fields).flatMap((descriptor) => {
@@ -279,10 +244,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
279
244
  }
280
245
  return result;
281
246
  };
282
- /**
283
- * The data handed to `payload.update` after a patch: the patched document
284
- * reduced to the fields the client may write, plus row identity keys.
285
- */ const buildWriteData = (config, target, doc) => {
247
+ /** The patched document reduced to writable fields, plus row identity keys. */ const buildWriteData = (config, target, doc) => {
286
248
  return pickDescribed(config, doc, {
287
249
  fields: target.flattenedFields,
288
250
  prefix: [],
@@ -5,23 +5,17 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
5
5
  /**
6
6
  * Runs Payload's own field validation over a draft without saving anything.
7
7
  *
8
- * Draft saves skip validation unless `versions.drafts.validate` is set, so an
9
- * agent building a document incrementally gets no signal until a human presses
10
- * Publish. This is the same traversal a real save runs, exported from
11
- * `payload`, called with `skipValidation: false` so it collects into `errors`
12
- * instead of throwing. The `beforeValidate` pass runs first because some field
13
- * hooks (Lexical's) prepare state in `context` that their `beforeChange`
14
- * counterpart depends on.
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.
15
12
  *
16
13
  * Nothing is written: `data` is a copy, the context is a scratch copy and the
17
14
  * locale merge actions are discarded. `overrideAccess` is true because the
18
15
  * question is "could this be published", not "may this client write it".
19
16
  *
20
- * Limits: only the locale the doc was read in is checked, and field-level
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.
17
+ * `unavailable` marks a traversal that threw, which is not the same answer as a
18
+ * document with nothing wrong with it.
25
19
  */ const collectPublishBlockers = async (req, target) => {
26
20
  const { doc, entity } = target;
27
21
  const id = doc["id"];
@@ -1,39 +1,17 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
1
+ import { randomUUID } from "node:crypto";
2
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;
3
+ const PUBLISH_INTENT = "__mcpxPublishIntent";
4
+ const TOKEN = randomUUID();
5
+ const withPublishIntent = (data) => ({
6
+ ...data,
7
+ [PUBLISH_INTENT]: TOKEN
8
+ });
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;
13
+ delete data[PUBLISH_INTENT];
21
14
  return true;
22
15
  };
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
16
  //#endregion
39
- export { claimPublishIntent, isClaimedPublish, withPublishIntent };
17
+ export { hasPublishIntent, takePublishIntent, withPublishIntent };
@@ -1,9 +1,8 @@
1
1
  import { commitTransaction, initTransaction, killTransaction } from "payload";
2
2
  //#region src/write/transaction.ts
3
3
  /**
4
- * Runs `fn` inside one database transaction on `req`, so a read followed by a
5
- * write is committed or rolled back together. Adapters without transaction
6
- * support, or a request that already owns one, run `fn` as is.
4
+ * Adapters without transaction support, or a request that already owns one, run
5
+ * `fn` as is.
7
6
  *
8
7
  * Atomicity, not isolation: neither SQLite nor Postgres at read committed locks
9
8
  * the row on the read, so an `expectedUpdatedAt` check remains best effort. Nor
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.13",
4
+ "version": "1.0.0-beta.15",
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",