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

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.
@@ -6,7 +6,11 @@ import { requireIdFor, resolveTarget } from "./target.mjs";
6
6
  import { defineMcpxTool } from "../types.mjs";
7
7
  import { z } from "zod";
8
8
  import { Pointer } from "rfc6902";
9
- const getDocument = defineMcpxTool({
9
+ /**
10
+ * With `path` the handler returns the subtree plus the `id`, `_status` and
11
+ * `updatedAt` a client needs to write back, so a caller reading one branch
12
+ * still gets the timestamp `expectedUpdatedAt` wants without a second call.
13
+ */ const getDocument = defineMcpxTool({
10
14
  name: "getDocument",
11
15
  description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
12
16
 
@@ -1,11 +1,18 @@
1
+ import { canCreate } from "../capabilities.mjs";
1
2
  import { jsonResult } from "../result.mjs";
2
3
  import { translatorFor } from "../i18n.mjs";
3
4
  import { translateLabel } from "./shared.mjs";
4
5
  import { defineMcpxTool } from "../types.mjs";
5
6
  import { hasDraftValidationEnabled } from "payload/shared";
6
- const listCapabilities = defineMcpxTool({
7
+ /**
8
+ * Registered for every key, including one with no capabilities ticked, so a
9
+ * client always has something to call and gets an empty surface described
10
+ * rather than an empty tool list. The response is assembled from the request
11
+ * scope and the sanitized config, never from the content model, so it stays the
12
+ * same size as a deployment grows.
13
+ */ const listCapabilities = defineMcpxTool({
7
14
  name: "listCapabilities",
8
- description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
15
+ description: `Lists what this key may do: the collections and globals it can read or write, whether a collection can also be created in, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
9
16
 
10
17
  A global is a singleton: it has no id, is not listed by findDocuments and cannot be created. Address one with the "global" argument where a collection document would take "collection" and "id".`,
11
18
  annotations: {
@@ -32,6 +39,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
32
39
  ...description === void 0 ? {} : { description },
33
40
  read: capability.read,
34
41
  write: capability.write,
42
+ create: capability.write && canCreate(entry),
35
43
  publish: capability.publish,
36
44
  drafts: entry.hasDrafts,
37
45
  draftValidation: hasDraftValidationEnabled(config),
@@ -42,7 +42,13 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
42
42
  const actual = pointer.get(saved);
43
43
  return survives(expected, actual) ? [] : [operation.path];
44
44
  });
45
- const patchDocument = defineMcpxTool({
45
+ /**
46
+ * The handler validates the whole batch against the schema and the current
47
+ * document before it writes anything, runs the write in a transaction, then
48
+ * re-reads the saved document to report which pointers survived and what still
49
+ * blocks publishing. Nothing here decides where the write lands: the draft
50
+ * guard does that on the Payload operation.
51
+ */ const patchDocument = defineMcpxTool({
46
52
  name: "patchDocument",
47
53
  description: DESCRIPTION,
48
54
  annotations: {
@@ -5,7 +5,11 @@ import { defineMcpxTool } from "../types.mjs";
5
5
  import { withTransaction } from "../write/transaction.mjs";
6
6
  import { withPublishIntent } from "../write/publish-intent.mjs";
7
7
  import { z } from "zod";
8
- const publishDocument = defineMcpxTool({
8
+ /**
9
+ * The only tool that changes live content, available where the config sets
10
+ * `write: "live"` on a versioned entity and the key has both the `write` and
11
+ * `publish` checkboxes.
12
+ */ const publishDocument = defineMcpxTool({
9
13
  name: "publishDocument",
10
14
  description: `Publishes the current draft, which changes what the public sees. This is the only tool that does; every other write lands as a draft. Call validateDocument first: a document that still has publish blockers is refused, and nothing is written.
11
15
 
@@ -40,7 +44,7 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
40
44
  });
41
45
  if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again before publishing.", { updatedAt: doc["updatedAt"] });
42
46
  const write = {
43
- data: {},
47
+ data: withPublishIntent({}),
44
48
  depth: 0,
45
49
  draft: false,
46
50
  fallbackLocale: false,
@@ -48,20 +52,14 @@ There is no unpublish: reverting to a draft stays a human action in the admin pa
48
52
  req: scope.req,
49
53
  ...locale === void 0 ? {} : { locale }
50
54
  };
51
- await withPublishIntent({
52
- kind: target.kind,
53
- slug: target.slug,
55
+ if (target.kind === "collection") await payload.update({
56
+ ...write,
57
+ collection: target.slug,
54
58
  id
55
- }, async () => {
56
- if (target.kind === "collection") await payload.update({
57
- ...write,
58
- collection: target.slug,
59
- id
60
- });
61
- else await payload.updateGlobal({
62
- ...write,
63
- slug: target.slug
64
- });
59
+ });
60
+ else await payload.updateGlobal({
61
+ ...write,
62
+ slug: target.slug
65
63
  });
66
64
  const saved = await readTarget(scope, {
67
65
  target,
@@ -1,10 +1,13 @@
1
- import { canPublish, isLiveWrite } from "../capabilities.mjs";
1
+ import { canCreate, canPublish, isLiveWrite } from "../capabilities.mjs";
2
2
  import { translateStatic } from "../i18n.mjs";
3
3
  import { NotFound } from "payload";
4
4
  import { z } from "zod";
5
5
  //#region src/tools/shared.ts
6
- const slugEnum = (slugs) => z.enum(slugs);
7
- const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
6
+ /**
7
+ * An out-of-scope slug fails schema validation before a handler runs, so a
8
+ * client only ever sees what its key may touch.
9
+ */ const slugEnum = (slugs) => z.enum(slugs);
10
+ /** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
8
11
  const slugsWhere = (scope, predicate, allowed) => {
9
12
  const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
10
13
  return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
@@ -17,33 +20,38 @@ const slugsWhere = (scope, predicate, allowed) => {
17
20
  collections: scope.writable,
18
21
  globals: scope.writableGlobals
19
22
  });
23
+ /**
24
+ * Slugs this key may write but never create in, because their documents are
25
+ * files. Collection-only, since nothing creates a global either way.
26
+ */ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
27
+ collections: scope.writable,
28
+ globals: []
29
+ });
20
30
  /** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
21
31
  collections: scope.publishable,
22
32
  globals: scope.publishableGlobals
23
33
  });
24
34
  /**
25
- * The sentence the write tools and the server instructions end on: what a write
26
- * actually does for this key, and what it takes to make it public. The three
27
- * groups are distinct — a live-write slug has no draft and no publish step, a
28
- * publishable one has both — so a client is never told its writes are drafts
29
- * while they are not, nor that publishing is out of reach when it is not.
35
+ * What a write actually does for this key, and what it takes to make it public.
36
+ * A live-write slug has no draft and no publish step; a publishable one has
37
+ * both. Stated per key so a client is never told its writes are drafts while
38
+ * they are not, nor that publishing is out of reach when it is not.
30
39
  */ const draftSentence = (scope) => {
31
40
  const live = liveWriteSlugs(scope);
32
41
  const publishable = publishableWriteSlugs(scope);
33
42
  return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
34
43
  };
35
- /**
36
- * Whether two timestamps name the same instant, which is how
37
- * `expectedUpdatedAt` is compared: the value a client read back is a string,
38
- * and what it is compared against may be a Date.
39
- */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
40
- /**
41
- * Widens one branch to the superset a handler sees. The widening itself is
42
- * unchecked — the runtime shape really does vary — so `Branch` checks what it
43
- * can around it.
44
- */ const widen = (branch) => branch;
45
- const slugsFor = (scope, operation) => {
44
+ /** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
45
+ /** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
46
+ /** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
46
47
  switch (operation) {
48
+ case "create": return {
49
+ collections: slugsWhere(scope, canCreate, {
50
+ collections: scope.writable,
51
+ globals: []
52
+ }),
53
+ globals: []
54
+ };
47
55
  case "publish": return {
48
56
  collections: scope.publishable,
49
57
  globals: scope.publishableGlobals
@@ -59,13 +67,9 @@ const slugsFor = (scope, operation) => {
59
67
  }
60
68
  };
61
69
  /**
62
- * The `collection` and `global` arguments.
63
- *
64
- * When the key can reach no global, `global` is left out of the shape entirely
65
- * and `collection` stays required, mirroring how {@link localeShape} omits
66
- * `locale` when localization is off. A deployment without globals therefore
67
- * sees exactly the schema it saw before. Only the mixed case makes either
68
- * argument optional, and the handler enforces the exclusivity there.
70
+ * With no reachable global, `global` is left out and `collection` stays
71
+ * required, so a deployment without globals sees an unchanged schema. Only the
72
+ * mixed case makes either optional, and the handler enforces exclusivity there.
69
73
  */ const targetShape = (scope, operation, descriptions) => {
70
74
  const { collections, globals } = slugsFor(scope, operation);
71
75
  if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
@@ -76,23 +80,23 @@ const slugsFor = (scope, operation) => {
76
80
  });
77
81
  };
78
82
  /**
79
- * The `id` argument, which only a collection document has. Omitted when the key
80
- * can reach no collection, required when it can reach no global, and optional
81
- * in between, where `requireIdFor` enforces the dependency.
83
+ * Only a collection document has one. Optional in the mixed case, where
84
+ * `requireIdFor` enforces the dependency.
82
85
  */ const idShape = (scope, operation) => {
83
86
  const { collections, globals } = slugsFor(scope, operation);
84
87
  if (collections.length === 0) return widen({});
85
88
  if (globals.length === 0) return widen({ id: idSchema });
86
89
  return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
87
90
  };
88
- /**
89
- * The `locale` argument, present only when localization is configured.
90
- */ const localeShape = (scope, options) => {
91
+ const localeShape = (scope, options) => {
91
92
  if (!scope.locales) return widen({});
92
93
  const locale = z.enum(scope.locales);
93
94
  return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
94
95
  };
95
- const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
96
+ /**
97
+ * Defaults to 0 rather than Payload's own default: a client usually wants ids
98
+ * it can write back, and populating a relation costs a query.
99
+ */ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
96
100
  /**
97
101
  * The locale to operate on: the explicit argument, else the request's, else
98
102
  * the default. `undefined` when localization is off.
@@ -133,9 +137,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
133
137
  slug: args.target.slug
134
138
  });
135
139
  };
136
- /**
137
- * Resolves a collection label for the request's language.
138
- */ const translateLabel = (scope, label, fallback) => {
140
+ const translateLabel = (scope, label, fallback) => {
139
141
  const { i18n, t } = scope.req;
140
142
  const resolved = typeof label === "function" ? label({
141
143
  i18n,
@@ -144,4 +146,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
144
146
  return translateStatic(resolved, i18n) ?? fallback;
145
147
  };
146
148
  //#endregion
147
- export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
149
+ export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
@@ -6,13 +6,9 @@ const refOf = (target) => ({
6
6
  slug: target.slug
7
7
  });
8
8
  /**
9
- * Resolves the `collection`/`global` arguments to one entity and checks the key
10
- * may perform `operation` on it.
11
- *
12
- * A tool's `inputSchema` returns a raw shape, which leaves no top-level
13
- * `.refine` to express "exactly one of collection and global". The rule is
14
- * enforced here instead, with a message naming the offending arguments so one
15
- * failed call teaches it.
9
+ * A raw input shape leaves no top-level `.refine` to express "exactly one of
10
+ * collection and global", so the rule is enforced here, with a message naming
11
+ * the offending arguments.
16
12
  */ const resolveTarget = (scope, args, operation) => {
17
13
  const { collection, global } = args;
18
14
  const allowedSlugs = slugsFor(scope, operation);
@@ -3,7 +3,15 @@ import { idShape, localeOf, localeShape, readTarget, targetShape } from "./share
3
3
  import { requireIdFor, resolveTarget } from "./target.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
6
- const validateDocument = defineMcpxTool({
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
 
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
  /**