@abinnovision/payloadcms-mcpx 1.0.0-beta.9 → 1.0.0

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 (57) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +328 -211
  3. package/dist/api-keys/fields.mjs +22 -5
  4. package/dist/api-keys/setup-guide.mjs +6 -4
  5. package/dist/auth/resolve.mjs +5 -7
  6. package/dist/capabilities.mjs +23 -4
  7. package/dist/client/index.d.mts +2 -2
  8. package/dist/client/setup-guide.d.mts +1 -1
  9. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  10. package/dist/endpoint/handler.mjs +11 -5
  11. package/dist/endpoint/index.mjs +4 -0
  12. package/dist/endpoint/server.mjs +18 -26
  13. package/dist/i18n.mjs +4 -15
  14. package/dist/index.d.mts +4 -4
  15. package/dist/index.mjs +4 -3
  16. package/dist/options.mjs +30 -26
  17. package/dist/plugin.mjs +1 -0
  18. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  19. package/dist/request.mjs +8 -0
  20. package/dist/result.d.mts +11 -0
  21. package/dist/result.mjs +20 -0
  22. package/dist/schema/describe.mjs +3 -15
  23. package/dist/schema/index.mjs +8 -0
  24. package/dist/schema/lexical-pointer.mjs +125 -0
  25. package/dist/schema/lexical.mjs +195 -27
  26. package/dist/schema/outline.mjs +67 -0
  27. package/dist/schema/pointer.mjs +77 -30
  28. package/dist/schema/shape.mjs +133 -51
  29. package/dist/schema/walk.mjs +44 -64
  30. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  31. package/dist/tools/create-document.mjs +34 -15
  32. package/dist/tools/describe-schema.mjs +21 -7
  33. package/dist/tools/find-documents.mjs +13 -6
  34. package/dist/tools/get-document.mjs +45 -11
  35. package/dist/tools/list-capabilities.mjs +19 -9
  36. package/dist/tools/names.mjs +2 -1
  37. package/dist/tools/patch-document.mjs +32 -21
  38. package/dist/tools/publish-document.mjs +79 -0
  39. package/dist/tools/shared.mjs +84 -32
  40. package/dist/tools/target.mjs +7 -11
  41. package/dist/tools/validate-document.mjs +20 -12
  42. package/dist/types.d.mts +110 -42
  43. package/dist/types.mjs +3 -4
  44. package/dist/version.mjs +1 -1
  45. package/dist/write/draft-guard.mjs +47 -44
  46. package/dist/write/patch.mjs +174 -92
  47. package/dist/write/publish-blockers.mjs +13 -12
  48. package/dist/write/publish-intent.mjs +17 -0
  49. package/dist/write/transaction.mjs +8 -3
  50. package/package.json +3 -3
  51. package/dist/i18n.d.mts +0 -1
  52. package/dist/options.d.mts +0 -2
  53. package/dist/schema/lexical.d.mts +0 -1
  54. package/dist/schema/walk.d.mts +0 -3
  55. package/dist/tools/target.d.mts +0 -3
  56. package/dist/tools/types.d.mts +0 -5
  57. package/dist/write/publish-blockers.d.mts +0 -15
@@ -1,17 +1,24 @@
1
- import { jsonResult } from "../endpoint/result.mjs";
1
+ import { jsonResult } from "../result.mjs";
2
2
  import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
3
  import { requireIdFor, resolveTarget } from "./target.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
4
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
- //#region src/tools/validate-document.ts
6
- const validateDocument = {
6
+ /**
7
+ * Gated on write rather than read, because publish blockers only mean
8
+ * something to a caller who can act on them.
9
+ *
10
+ * It reads the document twice on purpose: once under the key's own access to
11
+ * refuse a caller who may not see it, then privileged, so the check runs over
12
+ * every field rather than the subset the user can read. It carries no
13
+ * `readOnlyHint`, because the traversal fires field hooks.
14
+ */ const validateDocument = defineMcpxTool({
7
15
  name: "validateDocument",
8
16
  description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
9
17
 
10
- Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.`,
11
- annotations: {
12
- readOnlyHint: true,
13
- openWorldHint: false
14
- },
18
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
19
+
20
+ Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
21
+ annotations: { openWorldHint: false },
15
22
  isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
16
23
  inputSchema: (scope) => ({
17
24
  ...targetShape(scope, "write", {
@@ -24,7 +31,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
24
31
  description: "Locale to validate."
25
32
  })
26
33
  }),
27
- handler: async (args, scope) => {
34
+ handler: async ({ args, scope }) => {
28
35
  const target = resolveTarget(scope, args, "write");
29
36
  const id = requireIdFor(target, args.id);
30
37
  const locale = localeOf(scope, args.locale);
@@ -39,7 +46,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
39
46
  locale,
40
47
  privileged: true
41
48
  });
42
- const publishBlockers = await collectPublishBlockers(scope.req, {
49
+ const validation = await collectPublishBlockers(scope.req, {
43
50
  doc,
44
51
  entity: target
45
52
  });
@@ -47,9 +54,10 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
47
54
  ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
48
55
  status: doc["_status"],
49
56
  updatedAt: doc["updatedAt"],
50
- publishBlockers
57
+ publishBlockers: validation.blockers,
58
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
51
59
  });
52
60
  }
53
- };
61
+ });
54
62
  //#endregion
55
63
  export { validateDocument };
package/dist/types.d.mts CHANGED
@@ -12,74 +12,132 @@ declare module "payload" {
12
12
  }
13
13
  }
14
14
  /**
15
- * What an exposed collection offers to MCP clients. A key can only enable
16
- * what the config exposes here.
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.
17
23
  */
24
+ type McpxWriteMode = "draft" | "live" | false;
25
+ /** A key can only enable what the config exposes here. */
18
26
  interface McpxCollectionOptions {
19
- /** Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`. */
27
+ /** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
20
28
  read?: boolean;
21
29
  /**
22
- * Expose `patchDocument`, `createDocument` and `validateDocument`. Default
23
- * `false`. Requires `versions.drafts` unless `allowLiveWrites` is set.
30
+ * Expose `patchDocument`, `validateDocument` and, unless this is an upload
31
+ * collection, `createDocument`, and how far those writes reach. Default
32
+ * `false`.
24
33
  */
25
- write?: boolean;
26
- /**
27
- * Permit writes to a collection without drafts. Such writes land on the live
28
- * document because there is no draft to land on. Default `false`.
29
- */
30
- allowLiveWrites?: boolean;
34
+ write?: McpxWriteMode;
31
35
  }
32
- /**
33
- * What an exposed global offers to MCP clients. Structurally the same as
34
- * {@link McpxCollectionOptions}, kept separate because the tools it names
35
- * differ: a global is a singleton, so neither `findDocuments` nor
36
- * `createDocument` reaches one.
37
- */
36
+ /** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
38
37
  interface McpxGlobalOptions {
39
38
  /** Expose `describeSchema` and `getDocument`. Default `true`. */
40
39
  read?: boolean;
41
40
  /**
42
- * Expose `patchDocument` and `validateDocument`. Default `false`. Requires
43
- * `versions.drafts` unless `allowLiveWrites` is set.
41
+ * Expose `patchDocument` and `validateDocument`, and how far those writes
42
+ * reach. Default `false`.
44
43
  */
45
- write?: boolean;
46
- /**
47
- * Permit writes to a global without drafts. Such writes land on the live
48
- * document because there is no draft to land on. Default `false`.
49
- */
50
- allowLiveWrites?: boolean;
44
+ write?: McpxWriteMode;
51
45
  }
52
46
  type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
47
+ /** What the config exposes, before an API key's checkboxes narrow it. */
48
+ interface McpxExposedEntity {
49
+ slug: string;
50
+ read: boolean;
51
+ write: McpxWriteMode;
52
+ hasDrafts: boolean;
53
+ /** An upload document is a file, and no tool here can supply one. */
54
+ isUpload: boolean;
55
+ /** Name of the capability group on the key document. */
56
+ fieldName: string;
57
+ }
58
+ /** What a tool knows about the current request. */
59
+ interface McpxToolScope {
60
+ req: PayloadRequest;
61
+ capabilities: McpxResolvedCapabilities;
62
+ readable: string[];
63
+ writable: string[];
64
+ publishable: string[];
65
+ readableGlobals: string[];
66
+ writableGlobals: string[];
67
+ publishableGlobals: string[];
68
+ /** `null` when localization is off. */
69
+ locales: null | string[];
70
+ defaultLocale: null | string;
71
+ limits: {
72
+ maxLimit: number;
73
+ maxDepth: number;
74
+ };
75
+ exposure: {
76
+ collections: McpxExposedEntity[];
77
+ globals: McpxExposedEntity[];
78
+ };
79
+ }
53
80
  /**
54
- * A custom tool. It is gated by its own checkbox on every API key and runs
55
- * with `req.user` resolved from the key and `req.context.mcpx` set.
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.
56
84
  */
57
- interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape> {
85
+ interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
58
86
  /** camelCase, unique, not one of the builtin tool names. */
59
87
  name: string;
60
- description: string;
61
- inputSchema?: Shape;
88
+ /** Built per request so it can state what this key's writes actually do. */
89
+ description: string | ((scope: McpxToolScope) => string);
62
90
  annotations?: ToolAnnotations;
91
+ /**
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.
95
+ */
96
+ isEnabled?: (scope: McpxToolScope) => boolean;
97
+ /**
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.
101
+ */
102
+ inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
63
103
  handler(ctx: {
64
- args: z.infer<z.ZodObject<Shape>>;
104
+ args: Args;
105
+ scope: McpxToolScope;
106
+ /** Shorthand for `scope.req`. */
65
107
  req: PayloadRequest;
66
108
  extra: McpxToolExtra;
67
109
  }): CallToolResult | Promise<CallToolResult>;
68
110
  }
111
+ /** Argument type erased, so a registry can hold tools of differing shapes. */
112
+ type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
113
+ /** Fixed shape; arguments inferred from it. */
114
+ declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
115
+ inputSchema?: Shape;
116
+ }): McpxTool<Shape>;
117
+ /** Per-request shape returned as an object literal; arguments inferred from it. */
118
+ declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
119
+ inputSchema: (scope: McpxToolScope) => Shape;
120
+ }): McpxAnyTool;
69
121
  /**
70
- * Identity helper that infers the argument type of a custom tool's handler
71
- * from its input schema.
72
- */
73
- declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
74
- /**
75
- * Outcome of resolving an API key. `user` must carry `collection`.
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.
76
124
  */
125
+ declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
126
+ inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
127
+ }): McpxAnyTool;
77
128
  interface McpxAuthResult {
129
+ /** Must carry `collection`. */
78
130
  user: TypedUser;
79
131
  apiKeyId: number | string;
80
132
  /** The `capabilities` group as stored on the key document. */
81
133
  capabilities: unknown;
82
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
+ */
83
141
  type McpxPluginOptions = {
84
142
  /** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
85
143
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
@@ -108,7 +166,7 @@ type McpxPluginOptions = {
108
166
  /** Upper bound for `depth` on reads. Default 1. */
109
167
  maxDepth?: number;
110
168
  };
111
- tools?: McpxTool[];
169
+ tools?: McpxAnyTool[];
112
170
  auth?: {
113
171
  /** Replace or wrap the default key resolution. Return `null` for 401. */
114
172
  resolve?: (args: {
@@ -121,21 +179,31 @@ type McpxPluginOptions = {
121
179
  version?: string;
122
180
  };
123
181
  };
182
+ /** What a key may do with one entity. Globals reuse this shape. */
124
183
  interface McpxCollectionCapabilities {
125
184
  read: boolean;
126
185
  write: boolean;
186
+ /** Only ever true where the config sets `write: "live"` and drafts exist. */
187
+ publish: boolean;
127
188
  }
128
- /**
129
- * Capabilities in force for one request: plugin config AND key checkboxes.
130
- */
189
+ /** In force for one request: plugin config AND key checkboxes. */
131
190
  interface McpxResolvedCapabilities {
132
191
  collections: Record<string, McpxCollectionCapabilities>;
133
192
  globals: Record<string, McpxCollectionCapabilities>;
134
193
  tools: Record<string, boolean>;
135
194
  }
195
+ /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
136
196
  interface McpxRequestContext {
137
197
  apiKeyId: number | string;
138
198
  capabilities: McpxResolvedCapabilities;
139
199
  }
200
+ /** One reason a human could not publish the draft as it stands. */
201
+ interface PublishBlocker {
202
+ /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
203
+ field?: string;
204
+ message: string;
205
+ /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
206
+ path: string;
207
+ }
140
208
  //#endregion
141
- export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
209
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
package/dist/types.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  //#region src/types.ts
2
- /**
3
- * Identity helper that infers the argument type of a custom tool's handler
4
- * from its input schema.
5
- */ const defineMcpxTool = (tool) => tool;
2
+ function defineMcpxTool(tool) {
3
+ return tool;
4
+ }
6
5
  //#endregion
7
6
  export { defineMcpxTool };
package/dist/version.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  //#region src/version.ts
2
2
  /**
3
3
  * Package version injected at build time; sources under Vitest report "dev".
4
- */ const MCPX_VERSION = "0.0.0";
4
+ */ const MCPX_VERSION = "1.0.0";
5
5
  //#endregion
6
6
  export { MCPX_VERSION };
@@ -1,10 +1,9 @@
1
+ import { hasPublishIntent, takePublishIntent } 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
- /**
5
- * Operation arguments that widen or redirect a write. Cleared on every MCP
6
- * create and update so a tool cannot smuggle them in.
7
- */ 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([
8
7
  "where",
9
8
  "publishAllLocales",
10
9
  "publishSpecificLocale",
@@ -14,26 +13,28 @@ import { hasDraftsEnabled } from "payload/shared";
14
13
  "overwriteExistingFiles"
15
14
  ]);
16
15
  /**
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.
16
+ * Forces every MCP write into a draft save, unless it is the one write
17
+ * `publishDocument` asked for.
23
18
  *
24
19
  * `draft` alone is not enough: Payload's update path only saves a draft when
25
20
  * `data._status !== "published"`, so `_status` is dropped and left to Payload.
26
- * This runs as `beforeOperation`, before Payload reads any of these arguments,
27
- * so it holds for every create and update on an MCP request, not only the
28
- * builtin tools. Deletes are not guarded in v1; custom tools that delete are
29
- * the integrator's responsibility.
30
- */ const scrubWriteArgs = (args) => {
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) => {
31
29
  const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
32
30
  if (next["data"] && typeof next["data"] === "object") {
33
31
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
34
- next["data"] = data;
32
+ next["data"] = publishing ? {
33
+ ...data,
34
+ _status: "published"
35
+ } : data;
35
36
  }
36
- next["draft"] = true;
37
+ next["draft"] = !publishing;
37
38
  next["autosave"] = false;
38
39
  next["overrideLock"] = false;
39
40
  next["trash"] = false;
@@ -42,52 +43,54 @@ import { hasDraftsEnabled } from "payload/shared";
42
43
  const forceDraftWrite = (hookArgs) => {
43
44
  const { args, operation, req } = hookArgs;
44
45
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
45
- return scrubWriteArgs(args);
46
+ const publishing = operation === "update" && hasPublishIntent(args.data);
47
+ return scrubWriteArgs(args, publishing);
46
48
  };
47
49
  /**
48
- * The global counterpart of {@link forceDraftWrite}. Payload invokes a global's
49
- * `beforeOperation` with the whole argument bag and assigns the result back,
50
- * exactly as the collection path does and before it reads `draft`,
51
- * `publishAllLocales` or `data._status`, so the guard has the same reach here:
52
- * every MCP write to a global, builtin tool or custom.
53
- *
54
- * The global operation union has no `create` member because a global always
55
- * exists, so only `update` is intercepted. `STRIPPED_ARGS` covers the three
56
- * publish vectors `updateGlobal` accepts; the rest of the set does not exist on
57
- * that signature and filtering it is a harmless no-op. `slug` survives the
58
- * filter, so the operation still knows what it is updating.
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.
59
57
  */ const forceDraftWriteGlobal = (hookArgs) => {
60
58
  const { operation, req } = hookArgs;
61
59
  const args = hookArgs.args;
62
60
  if (!isMcpxRequest(req) || operation !== "update") return args;
63
- return scrubWriteArgs(args);
61
+ return scrubWriteArgs(args, hasPublishIntent(args["data"]));
64
62
  };
65
63
  /**
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) => {
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);
71
70
  if (!isMcpxRequest(req)) return;
72
71
  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);
72
+ const expected = publishing ? "published" : "draft";
73
+ if (status === expected) return;
74
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
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);
76
76
  };
77
- const refusePublish = ({ collection, data, req }) => {
78
- refuseUnlessDraft(req, collection.slug, 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);
79
82
  return data;
80
83
  };
81
- /** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
84
+ const refusePublishGlobal = ({ data, global, req }) => {
82
85
  const next = data;
83
- refuseUnlessDraft(req, global.slug, next);
86
+ refuseUnlessExpected(req, global.slug, next);
84
87
  return next;
85
88
  };
86
89
  /**
87
90
  * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
88
91
  * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
89
92
  * exist. Applied to the built collection list so nothing can join later
90
- * without being covered.
93
+ * without being covered. Both are appended last, so a user hook cannot win.
91
94
  */ const installDraftGuards = (collections) => collections.map((collection) => ({
92
95
  ...collection,
93
96
  hooks: {
@@ -110,4 +113,4 @@ const refusePublish = ({ collection, data, req }) => {
110
113
  }
111
114
  }));
112
115
  //#endregion
113
- export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
116
+ export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };