@abinnovision/payloadcms-mcpx 2.0.0-beta.8 → 2.0.0-beta.9

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.
@@ -2,18 +2,26 @@ import { CapabilityMatrix } from "../api-keys/capability-matrix.mjs";
2
2
  import React from "react";
3
3
  //#region src/admin/capability-matrix.d.ts
4
4
  interface McpxCapabilityMatrixProps {
5
- /** Built from the plugin options at config time. */
5
+ /**
6
+ * Built from the plugin options at config time.
7
+ */
6
8
  matrix: CapabilityMatrix;
7
- /** The group's own config, supplied by Payload. Carries the description. */
9
+ /**
10
+ * The group's own config, supplied by Payload. Carries the description.
11
+ */
8
12
  field?: {
9
13
  admin?: {
10
14
  description?: unknown;
11
15
  };
12
16
  };
13
- /** The `capabilities` group's own path, supplied by Payload. */
17
+ /**
18
+ * The `capabilities` group's own path, supplied by Payload.
19
+ */
14
20
  path?: string;
15
21
  readOnly?: boolean;
16
- /** Whether the key form is split into tabs, from the plugin options. */
22
+ /**
23
+ * Whether the key form is split into tabs, from the plugin options.
24
+ */
17
25
  withinTab?: boolean;
18
26
  }
19
27
  /**
@@ -1,7 +1,9 @@
1
1
  import React from "react";
2
2
  //#region src/admin/setup-guide.d.ts
3
3
  interface McpxSetupGuideProps {
4
- /** Endpoint path below the API route, from the plugin options. */
4
+ /**
5
+ * Endpoint path below the API route, from the plugin options.
6
+ */
5
7
  endpointPath: string;
6
8
  }
7
9
  /**
@@ -7,12 +7,16 @@ import "../options.mjs";
7
7
  */
8
8
  export interface CapabilityRow {
9
9
  fieldName: string;
10
- /** The slug, which is what MCP clients send and what refusals name. */
10
+ /**
11
+ * The slug, which is what MCP clients send and what refusals name.
12
+ */
11
13
  label: string;
12
14
  read: boolean;
13
15
  write: boolean;
14
16
  publish: boolean;
15
- /** Said only where a row departs from what its column header promises. */
17
+ /**
18
+ * Said only where a row departs from what its column header promises.
19
+ */
16
20
  hint?: string;
17
21
  }
18
22
  interface CapabilityTool {
@@ -53,6 +53,8 @@ const toRow = (entity) => ({
53
53
  });
54
54
  const cellPath = (basePath, namespace, fieldName, operation) => `${basePath}.${namespace}.${fieldName}.${operation}`;
55
55
  const toolPath = (basePath, name) => `${basePath}.tools.${name}`;
56
- /** Every path the matrix can address, in a stable order. */ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
56
+ /**
57
+ * Every path the matrix can address, in a stable order.
58
+ */ const capabilityPaths = (matrix, basePath = CAPABILITIES_FIELD) => [...["collections", "globals"].flatMap((namespace) => matrix[namespace].flatMap((row) => CAPABILITY_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => cellPath(basePath, namespace, row.fieldName, operation.id)))), ...matrix.tools.map((tool) => toolPath(basePath, tool.name))];
57
59
  //#endregion
58
60
  export { CAPABILITIES_DESCRIPTION, CAPABILITY_OPERATIONS, capabilityPaths, cellPath, createCapabilityMatrix, toolPath };
@@ -5,7 +5,8 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
5
5
  * Generates the key on create and keeps the lookup index in step with it.
6
6
  * Runs after field `beforeValidate` fallbacks, so on update `data.apiKey` is the
7
7
  * decrypted plaintext of the stored key.
8
- */ const keyBeforeChange = ({ data, operation, req }) => {
8
+ */ const keyBeforeChange = ({ context, data, operation, req }) => {
9
+ if (context["mcpxTouch"]) return data;
9
10
  if (operation === "create" && typeof data["apiKey"] !== "string") data["apiKey"] = generateApiKey();
10
11
  if (typeof data["apiKey"] === "string") data["apiKeyIndex"] = hashApiKey(req.payload.secret, data["apiKey"]);
11
12
  return data;
@@ -34,6 +34,27 @@ const SETUP_GUIDE_FIELD = "setupGuide";
34
34
  defaultValue: true,
35
35
  admin: { description: "Disabled keys are refused without revoking them." }
36
36
  },
37
+ {
38
+ name: "expiresAt",
39
+ type: "date",
40
+ admin: {
41
+ date: { pickerAppearance: "dayAndTime" },
42
+ description: "Refused from this time on. Leave empty for no expiry."
43
+ }
44
+ },
45
+ {
46
+ name: "lastUsedAt",
47
+ type: "date",
48
+ access: {
49
+ create: () => false,
50
+ update: () => false
51
+ },
52
+ admin: {
53
+ readOnly: true,
54
+ date: { pickerAppearance: "dayAndTime" },
55
+ description: "Updated at most once an hour."
56
+ }
57
+ },
37
58
  {
38
59
  name: "apiKey",
39
60
  type: "text",
@@ -2,6 +2,8 @@ import { isPlainObject } from "../guards.mjs";
2
2
  import { hashApiKey } from "../api-keys/key.mjs";
3
3
  //#region src/auth/resolve.ts
4
4
  const BEARER = /^Bearer\s+(\S+)\s*$/i;
5
+ const LAST_USED_INTERVAL_MS = 36e5;
6
+ const toTime = (value) => typeof value === "string" || value instanceof Date ? new Date(value).getTime() : NaN;
5
7
  const relationId = (value) => {
6
8
  if (typeof value === "string" || typeof value === "number") return value;
7
9
  if (typeof value === "object" && value !== null && "id" in value) return value.id;
@@ -39,12 +41,15 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
39
41
  select: {
40
42
  enabled: true,
41
43
  user: true,
42
- capabilities: true
44
+ capabilities: true,
45
+ expiresAt: true,
46
+ lastUsedAt: true
43
47
  }
44
48
  });
45
49
  const keyDoc = docs[0];
46
50
  const userId = relationId(keyDoc?.user);
47
51
  if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
52
+ if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
48
53
  const userCollection = payload.collections[options.userCollection];
49
54
  const lookup = {
50
55
  collection: options.userCollection,
@@ -61,9 +66,24 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
61
66
  showHiddenFields: true,
62
67
  select: { lockUntil: true }
63
68
  })]);
64
- const storedLock = lock?.["lockUntil"];
65
- const lockUntil = typeof storedLock === "string" || storedLock instanceof Date ? new Date(storedLock).getTime() : NaN;
69
+ const lockUntil = toTime(lock?.["lockUntil"]);
66
70
  if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
71
+ const lastUsed = toTime(keyDoc.lastUsedAt);
72
+ if (Number.isNaN(lastUsed) || Date.now() - lastUsed > LAST_USED_INTERVAL_MS) try {
73
+ await payload.update({
74
+ collection: options.apiKeysSlug,
75
+ id: keyDoc.id,
76
+ data: { lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() },
77
+ overrideAccess: true,
78
+ depth: 0,
79
+ context: { mcpxTouch: true }
80
+ });
81
+ } catch (error) {
82
+ payload.logger.warn({
83
+ err: error,
84
+ msg: "Could not record the last use of an MCP API key"
85
+ });
86
+ }
67
87
  return {
68
88
  user: {
69
89
  ...user,
@@ -1,8 +1,14 @@
1
1
  import { isPlainObject } from "./guards.mjs";
2
2
  //#region src/capabilities.ts
3
- /** Group field holding the capability checkboxes on an API key document. */ const CAPABILITIES_FIELD = "capabilities";
4
- /** Covers draft and live writes; {@link isLiveWrite} separates them. */ const canWrite = (entity) => entity.write !== false;
5
- /** The config lets MCP change live content and a draft exists to promote. */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
3
+ /**
4
+ * Group field holding the capability checkboxes on an API key document.
5
+ */ const CAPABILITIES_FIELD = "capabilities";
6
+ /**
7
+ * Covers draft and live writes; {@link isLiveWrite} separates them.
8
+ */ const canWrite = (entity) => entity.write !== false;
9
+ /**
10
+ * The config lets MCP change live content and a draft exists to promote.
11
+ */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
6
12
  /**
7
13
  * An upload document is a file, and no tool here carries one. Its own fields
8
14
  * stay patchable; the first version is made in the admin panel.
@@ -1,11 +1,15 @@
1
1
  import { McpxAnyTool, McpxTool, McpxToolScope } from "./types.mjs";
2
2
  import { z } from "zod";
3
3
  //#region src/define-tool.d.ts
4
- /** Fixed shape; arguments inferred from it. */
4
+ /**
5
+ * Fixed shape; arguments inferred from it.
6
+ */
5
7
  export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
6
8
  inputSchema?: Shape;
7
9
  }): McpxTool<Shape>;
8
- /** Per-request shape returned as an object literal; arguments inferred from it. */
10
+ /**
11
+ * Per-request shape returned as an object literal; arguments inferred from it.
12
+ */
9
13
  export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
10
14
  inputSchema: (scope: McpxToolScope) => Shape;
11
15
  }): McpxAnyTool;
@@ -4,9 +4,13 @@ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { z } from "zod";
6
6
  //#region src/endpoint/server.ts
7
- /** Strict, so an unknown argument is rejected by name instead of stripped. */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
7
+ /**
8
+ * Strict, so an unknown argument is rejected by name instead of stripped.
9
+ */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
8
10
  const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
9
- /** A tool that does not decide for itself is gated by its own checkbox. */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
11
+ /**
12
+ * A tool that does not decide for itself is gated by its own checkbox.
13
+ */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
10
14
  const WORKFLOW = [
11
15
  {
12
16
  tools: ["listCapabilities"],
@@ -36,7 +40,9 @@ const WORKFLOW = [
36
40
  const PATHS = `describeSchema takes and returns schema paths. Every other "path", and "from" in a patch operation, is a pointer into a document, with a 0-based index where a schema path has "*" for an array element or a block slug: "/layout/sections/sectionWrapper/identifier" is "/layout/sections/0/identifier". In rich text a pointer enters the rich text state: "/content/block/callout/tone" is "/content/root/children/3/fields/tone". getDocument "outline" gives the index.`;
37
41
  const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
38
42
  const DRAFTS_ONLY = "Every write is saved as a draft.";
39
- /** The workflow for the tools this key has, and what its writes reach. */ const serverInstructions = (scope) => {
43
+ /**
44
+ * The workflow for the tools this key has, and what its writes reach.
45
+ */ const serverInstructions = (scope) => {
40
46
  const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
41
47
  const steps = WORKFLOW.flatMap(({ tools, does }) => {
42
48
  const present = tools.filter((name) => enabled.has(name));
package/dist/guards.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  //#region src/guards.ts
2
2
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
- /** An own property only, so a client-chosen key never reads the prototype. */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
3
+ /**
4
+ * An own property only, so a client-chosen key never reads the prototype.
5
+ */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
4
6
  //#endregion
5
7
  export { isPlainObject, ownValue };
package/dist/i18n.mjs CHANGED
@@ -21,7 +21,9 @@ const pick = (record, language) => {
21
21
  return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
22
22
  };
23
23
  const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
24
- /** For callers with no request. Both languages miss, so the first entry wins. */ const translateAny = translatorFor({
24
+ /**
25
+ * For callers with no request. Both languages miss, so the first entry wins.
26
+ */ const translateAny = translatorFor({
25
27
  fallbackLanguage: "",
26
28
  language: ""
27
29
  });
package/dist/options.mjs CHANGED
@@ -12,7 +12,9 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
12
12
  const fail = (message) => {
13
13
  throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
14
14
  };
15
- /** The same transform the stock MCP plugin uses to derive field names. */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
15
+ /**
16
+ * The same transform the stock MCP plugin uses to derive field names.
17
+ */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
16
18
  const assertExposable = (collection, apiKeysSlug) => {
17
19
  const { slug } = collection;
18
20
  if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
@@ -127,7 +129,9 @@ const normalizeLimits = (limits) => {
127
129
  maxDepth
128
130
  };
129
131
  };
130
- /** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
132
+ /**
133
+ * Every problem is an `InvalidConfiguration`, so it fails at startup.
134
+ */ const normalizeOptions = (config, options) => {
131
135
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
132
136
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
133
137
  if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
@@ -11,7 +11,9 @@ const LEXICAL_CORE_NODES = [
11
11
  const OPAQUE_NODE_TYPES = /* @__PURE__ */ new Set(["upload"]);
12
12
  const subSchemaCache = /* @__PURE__ */ new WeakMap();
13
13
  const featuresOf = (field) => field.editor?.editorConfig?.features;
14
- /** Editors other than Lexical report only the core nodes. */ const allowedNodeTypes = (field) => {
14
+ /**
15
+ * Editors other than Lexical report only the core nodes.
16
+ */ const allowedNodeTypes = (field) => {
15
17
  const registered = (featuresOf(field)?.nodes ?? []).flatMap((entry) => {
16
18
  const type = entry.node?.getType?.();
17
19
  return type ? [type] : [];
@@ -41,7 +43,9 @@ const lexicalSubSchema = (field, nodeType) => {
41
43
  if (!cached.has(nodeType)) cached.set(nodeType, resolveSubSchema(field, nodeType));
42
44
  return cached.get(nodeType) ?? void 0;
43
45
  };
44
- /** In the order their features registered them. */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
46
+ /**
47
+ * In the order their features registered them.
48
+ */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
45
49
  const KINDS = {
46
50
  array: {
47
51
  accepts: (value) => Array.isArray(value),
@@ -1,7 +1,11 @@
1
1
  //#region src/schema/path.ts
2
2
  const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
3
- /** No segments is the root pointer, `""`. */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
4
- /** Unescapes `~1` and `~0`. The root pointer yields no segments. */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
3
+ /**
4
+ * No segments is the root pointer, `""`.
5
+ */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
6
+ /**
7
+ * Unescapes `~1` and `~0`. The root pointer yields no segments.
8
+ */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
5
9
  const PROTOTYPE_SEGMENTS = /* @__PURE__ */ new Set([
6
10
  "__proto__",
7
11
  "constructor",
@@ -12,7 +16,9 @@ const PROTOTYPE_SEGMENTS = /* @__PURE__ */ new Set([
12
16
  * intermediate tokens but writes them as a final key, so a pointer containing
13
17
  * one is refused before anything resolves it.
14
18
  */ const prototypeSegmentProblem = (pointer) => splitPath(pointer).some((segment) => PROTOTYPE_SEGMENTS.has(segment)) ? `"${pointer}" contains a segment named __proto__, constructor or prototype, which no field or node property uses.` : void 0;
15
- /** `-` included, since RFC 6901 reads it as the position after the last. */ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
19
+ /**
20
+ * `-` included, since RFC 6901 reads it as the position after the last.
21
+ */ const isIndexSegment = (segment) => segment === "-" || /^\d+$/.test(segment);
16
22
  /**
17
23
  * A path Payload reports on a validation error (`layout.0.title`) as a JSON
18
24
  * Pointer, so everything handed back addresses documents the same way. The
@@ -220,7 +220,7 @@ const checkValue = (scope, value) => {
220
220
  }, entry);
221
221
  }
222
222
  };
223
- /** Shape problems with a value about to be written at a resolved pointer. */ const validateWriteValue = (config, target, value) => {
223
+ const validateWriteValue = (config, target, value) => {
224
224
  const problems = [];
225
225
  const scope = {
226
226
  config,
@@ -10,12 +10,17 @@ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
10
10
  "id",
11
11
  "updatedAt"
12
12
  ]);
13
- /** The keys a row carries beside its fields. */ const ROW_KEYS = /* @__PURE__ */ new Set([
13
+ /**
14
+ * The keys a row carries beside its fields.
15
+ */ const ROW_KEYS = /* @__PURE__ */ new Set([
14
16
  "blockName",
15
17
  "blockType",
16
18
  "id"
17
19
  ]);
18
- /** On a flattened field, whichever of `blockReferences` and `blocks` was declared. */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
20
+ /**
21
+ * On a flattened field, whichever of `blockReferences` and `blocks` was
22
+ * declared.
23
+ */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
19
24
  /**
20
25
  * An inlined definition wins over the registry (`config.blocks`). A block's own
21
26
  * fields are identical wherever it appears, but the blocks its children accept
@@ -101,7 +106,10 @@ const isInformative = (descriptor) => descriptor.description !== void 0 || descr
101
106
  * against a document needs. Containers are left out, since only
102
107
  * {@link nodeDescriber} reports one.
103
108
  */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
104
- /** The addressable descriptors under `prefix`, with their paths split relative to it. */ const descriptorsUnder = (fields, prefix) => describeAddressableFields(fields).flatMap((descriptor) => {
109
+ /**
110
+ * The addressable descriptors under `prefix`, with their paths split relative
111
+ * to it.
112
+ */ const descriptorsUnder = (fields, prefix) => describeAddressableFields(fields).flatMap((descriptor) => {
105
113
  const parts = splitPath(descriptor.path);
106
114
  return prefix.every((part, offset) => part === parts[offset]) ? [{
107
115
  descriptor,
@@ -3,8 +3,8 @@ import { errorResult, jsonResult } from "../result.mjs";
3
3
  import { validateWriteValue } from "../schema/shape.mjs";
4
4
  import "../schema/index.mjs";
5
5
  import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
6
- import { readDraft, resolveEntity } from "./document.mjs";
7
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
+ import { readDraft, resolveEntity } from "./document.mjs";
8
8
  import { stripRowIds } from "../write/row-ids.mjs";
9
9
  import { z } from "zod";
10
10
  //#region src/tools/create-document.ts
@@ -1,5 +1,6 @@
1
1
  import { errorResult } from "../result.mjs";
2
2
  import { slugsFor } from "./shared.mjs";
3
+ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
3
4
  import { APIError, Forbidden, NotFound } from "payload";
4
5
  //#region src/tools/document.ts
5
6
  /**
@@ -45,9 +46,13 @@ import { APIError, Forbidden, NotFound } from "payload";
45
46
  id: args.id
46
47
  };
47
48
  };
48
- /** What a response names its subject by: a document's id, or a global's slug. */ const identityOf = (target, id) => target.kind === "collection" ? { id } : { global: target.slug };
49
+ /**
50
+ * What a response names its subject by: a document's id, or a global's slug.
51
+ */ const identityOf = (target, id) => target.kind === "collection" ? { id } : { global: target.slug };
49
52
  const sameInstant = (left, right) => (typeof left === "string" || left instanceof Date) && new Date(left).getTime() === new Date(right).getTime();
50
- /** Refuses a write when the document changed since the client read it. */ const staleReadResult = (doc, expectedUpdatedAt, message) => expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], expectedUpdatedAt) ? errorResult(message, { updatedAt: doc["updatedAt"] }) : void 0;
53
+ /**
54
+ * Refuses a write when the document changed since the client read it.
55
+ */ const staleReadResult = (doc, expectedUpdatedAt, message) => expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], expectedUpdatedAt) ? errorResult(message, { updatedAt: doc["updatedAt"] }) : void 0;
51
56
  /**
52
57
  * Reads the current draft in a fixed locale with no fallback, which is the
53
58
  * shape that may be written back or validated without mixing locales.
@@ -80,5 +85,40 @@ const sameInstant = (left, right) => (typeof left === "string" || left instanceo
80
85
  slug: args.target.slug
81
86
  });
82
87
  };
88
+ /**
89
+ * {@link collectPublishBlockers} over the draft of each locale, each blocker
90
+ * tagged with its locale. Locales run in turn because hooks share `req`, and
91
+ * the request's locale and fallback locale are restored afterwards.
92
+ */ const collectLocaleBlockers = async (scope, target, locales) => {
93
+ const { req } = scope;
94
+ const { fallbackLocale, locale: requestLocale } = req;
95
+ const blockers = [];
96
+ let unavailable = false;
97
+ for (const locale of locales) try {
98
+ const doc = await readDraft(scope, {
99
+ target,
100
+ locale,
101
+ privileged: true
102
+ });
103
+ const result = await collectPublishBlockers(req, {
104
+ doc,
105
+ entity: target
106
+ });
107
+ unavailable ||= result.unavailable === true;
108
+ blockers.push(...result.blockers.map((entry) => ({
109
+ ...entry,
110
+ locale
111
+ })));
112
+ } catch (error) {
113
+ req.payload.logger.warn(`[payloadcms-mcpx] Could not read the ${target.slug} draft in ${locale}: ${error instanceof Error ? error.message : "unknown error"}`);
114
+ unavailable = true;
115
+ }
116
+ if (requestLocale !== void 0) req.locale = requestLocale;
117
+ if (fallbackLocale !== void 0) req.fallbackLocale = fallbackLocale;
118
+ return {
119
+ blockers,
120
+ ...unavailable ? { unavailable: true } : {}
121
+ };
122
+ };
83
123
  //#endregion
84
- export { identityOf, readDraft, resolveDocument, resolveEntity, staleReadResult };
124
+ export { collectLocaleBlockers, identityOf, readDraft, resolveDocument, resolveEntity, staleReadResult };
@@ -4,8 +4,8 @@ import { errorResult, jsonResult } from "../result.mjs";
4
4
  import { JSON_POINTER_PATTERN } from "../schema/path.mjs";
5
5
  import "../schema/index.mjs";
6
6
  import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
7
- import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
8
7
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
8
+ import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
9
9
  import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
10
10
  import { withTransaction } from "../write/transaction.mjs";
11
11
  import { buildWriteData } from "../write/write-data.mjs";
@@ -24,7 +24,9 @@ In a rich text field a pointer continues into the rich text state: "/content/roo
24
24
  The response carries "updatedAt" and, if any, "publishBlockers": what must be fixed before publishing. "publishBlockersUnavailable" means that check failed. The write stands either way. "notApplied" lists pointers whose value did not change or cannot be read back, for example where field access denies the update.`;
25
25
  const POINTER = z.string().regex(JSON_POINTER_PATTERN);
26
26
  const PATCHES_LIMIT = 500;
27
- /** Discriminated on `op`, so an operation carries only its own members. */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
27
+ /**
28
+ * Discriminated on `op`, so an operation carries only its own members.
29
+ */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
28
30
  z.strictObject({
29
31
  op: z.literal("add"),
30
32
  path: POINTER,
@@ -1,17 +1,16 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { jsonResult } from "../result.mjs";
3
- import { entityShape, idShape, localeOf } from "./shared.mjs";
4
- import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
3
+ import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
4
+ import { collectLocaleBlockers, identityOf, readDraft, resolveDocument, staleReadResult } from "./document.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
8
  /**
9
- * Publishes a draft. Available where the config exposes
10
- * `publish` on an entity with drafts and the key has both the `write` and
11
- * `publish` checkboxes.
9
+ * Publishes a draft. Available where the config exposes `publish` on an entity
10
+ * with drafts and the key has both the `write` and `publish` checkboxes.
12
11
  */ const publishDocument = defineMcpxTool({
13
12
  name: "publishDocument",
14
- description: `Makes the current draft of one document or global public. A draft with publish blockers is refused, so call validateDocument first. Publishing validates one locale only, normally the default, so a required field left empty in another locale goes live empty. Run validateDocument for each locale. There is no unpublish. A person takes content offline in the admin panel.`,
13
+ description: `Makes the current draft of one document or global public, in every locale or only in "locale". A scoped publish keeps the other locales of localized fields as last published, and a never-published document goes live in full. A draft with publish blockers is refused, so call validateDocument first. Payload checks only the published locale, so "otherLocaleBlockers" lists what other locales still lack. There is no unpublish.`,
15
14
  annotations: {
16
15
  destructiveHint: true,
17
16
  openWorldHint: false
@@ -20,12 +19,14 @@ import { z } from "zod";
20
19
  inputSchema: (scope) => ({
21
20
  ...entityShape(scope, "publish"),
22
21
  ...idShape(scope, "publish"),
22
+ ...localeShape(scope, { required: false }),
23
23
  expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
24
24
  }),
25
25
  handler: async ({ args, scope }) => {
26
26
  const target = resolveDocument(scope, args, "publish");
27
27
  const { payload } = scope.req;
28
- const locale = localeOf(scope, void 0);
28
+ const locale = localeOf(scope, args.locale);
29
+ const { localization } = scope;
29
30
  return await withTransaction(scope.req, async () => {
30
31
  const doc = await readDraft(scope, {
31
32
  target,
@@ -40,7 +41,8 @@ import { z } from "zod";
40
41
  fallbackLocale: false,
41
42
  overrideAccess: false,
42
43
  req: scope.req,
43
- ...locale === void 0 ? {} : { locale }
44
+ ...locale === void 0 ? {} : { locale },
45
+ ...args.locale === void 0 ? {} : { publishSpecificLocale: args.locale }
44
46
  };
45
47
  if (target.kind === "collection") await payload.update({
46
48
  ...write,
@@ -56,10 +58,13 @@ import { z } from "zod";
56
58
  locale,
57
59
  privileged: true
58
60
  });
61
+ const others = await collectLocaleBlockers(scope, target, localization?.locales.filter((entry) => entry !== locale) ?? []);
59
62
  return jsonResult({
60
63
  ...identityOf(target, saved["id"]),
61
64
  status: saved["_status"],
62
- updatedAt: saved["updatedAt"]
65
+ updatedAt: saved["updatedAt"],
66
+ ...others.blockers.length > 0 ? { otherLocaleBlockers: others.blockers } : {},
67
+ ...others.unavailable ? { otherLocaleBlockersUnavailable: true } : {}
63
68
  });
64
69
  });
65
70
  }
@@ -8,7 +8,9 @@ import { APIError, getLocalizedPaths } from "payload";
8
8
  * them: `and` and `or` (any case) hold arrays of clauses, every other key is a
9
9
  * path.
10
10
  */ const wherePaths = (where) => isPlainObject(where) ? Object.entries(where).flatMap(([key, clause]) => ["and", "or"].includes(key.toLowerCase()) && Array.isArray(clause) ? clause.flatMap(wherePaths) : [key]) : [];
11
- /** The paths of a `sort`: a field name, `-` for descending, comma separated. */ const sortPaths = (sort) => (sort ?? "").split(",").map((entry) => entry.trim().replace(/^-/, "")).filter(Boolean);
11
+ /**
12
+ * The paths of a `sort`: a field name, `-` for descending, comma separated.
13
+ */ const sortPaths = (sort) => (sort ?? "").split(",").map((entry) => entry.trim().replace(/^-/, "")).filter(Boolean);
12
14
  const normalise = (path) => path === "_id" ? "id" : path.replaceAll("__", ".");
13
15
  const isRefusedPath = (scope, args, seen = /* @__PURE__ */ new Set()) => {
14
16
  const key = `${args.collection}:${args.path}`;
@@ -5,7 +5,9 @@ import { z } from "zod";
5
5
  * An out-of-scope slug fails schema validation before a handler runs, so a
6
6
  * client only ever sees what its key may touch.
7
7
  */ const slugEnum = (slugs) => z.enum(slugs);
8
- /** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]);
8
+ /**
9
+ * Payload's id type follows the adapter, so both forms are handed on as read.
10
+ */ const idSchema = z.union([z.string(), z.number()]);
9
11
  const slugsOf = (scope, key) => ({
10
12
  collections: scope.collections[key],
11
13
  globals: scope.globals[key]
@@ -22,7 +24,9 @@ const slugsWhere = (scope, predicate, allowed) => {
22
24
  globals: []
23
25
  });
24
26
  const widen = (branch) => branch;
25
- /** The one list the shape helpers and {@link resolveEntity} both read. */ const slugsFor = (scope, operation) => {
27
+ /**
28
+ * The one list the shape helpers and {@link resolveEntity} both read.
29
+ */ const slugsFor = (scope, operation) => {
26
30
  switch (operation) {
27
31
  case "create": return {
28
32
  collections: slugsWhere(scope, canCreate, {
@@ -62,7 +66,10 @@ const widen = (branch) => branch;
62
66
  })
63
67
  };
64
68
  };
65
- /** Slugs reachable by `operation` whose writes go live, because they have no draft to write. */ const liveWriteSlugs = (scope, operation) => slugsWhere(scope, isLiveWrite, slugsFor(scope, operation));
69
+ /**
70
+ * Slugs reachable by `operation` whose writes go live, because they have no
71
+ * draft to write.
72
+ */ const liveWriteSlugs = (scope, operation) => slugsWhere(scope, isLiveWrite, slugsFor(scope, operation));
66
73
  /**
67
74
  * Stated on the tools that write, since a client calling them must know before
68
75
  * the call which writes are public.
@@ -72,7 +79,7 @@ const widen = (branch) => branch;
72
79
  };
73
80
  /**
74
81
  * With no reachable global, `global` is left out and `collection` stays
75
- * required, so a deployment without globals sees an unchanged schema. Only the
82
+ * required, so a deployment without globals gets no extra argument. Only the
76
83
  * mixed case makes either optional, and the handler enforces exclusivity there.
77
84
  * `globalRule` states that rule on `global` for a tool without an `id`.
78
85
  */ const entityShape = (scope, operation, globalRule) => {
@@ -94,7 +101,9 @@ const widen = (branch) => branch;
94
101
  if (globals.length === 0) return widen({ id: idSchema });
95
102
  return widen({ id: idSchema.optional().describe("Required with \"collection\", omitted with \"global\".") });
96
103
  };
97
- /** For the reads that fall back to the default locale, unlike the writes. */ const READ_LOCALE_DESCRIPTION = "Default: the default locale. A value missing in this locale is shown from the default locale.";
104
+ /**
105
+ * For the reads that fall back to the default locale, unlike the writes.
106
+ */ const READ_LOCALE_DESCRIPTION = "Default: the default locale. A value missing in this locale is shown from the default locale.";
98
107
  const localeShape = (scope, options) => {
99
108
  if (!scope.localization) return widen({});
100
109
  const enumerated = z.enum(scope.localization.locales);
@@ -1,8 +1,8 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { jsonResult } from "../result.mjs";
3
3
  import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
4
- import { identityOf, readDraft, resolveDocument } from "./document.mjs";
5
4
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
+ import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from "./document.mjs";
6
6
  /**
7
7
  * Gated on write rather than read, because publish blockers only mean something
8
8
  * to a caller who can act on them.
@@ -13,16 +13,17 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
13
13
  * the traversal fires field hooks.
14
14
  */ const validateDocument = defineMcpxTool({
15
15
  name: "validateDocument",
16
- description: `Lists what still blocks publishing one document or global, without saving. Returns "publishBlockers", each with a pointer and a message. An empty list means it can be published, unless "publishBlockersUnavailable" is true: then the check itself failed. The check runs the field hooks a save runs.`,
16
+ description: `Lists what still blocks publishing one document or global, without saving. Checks one locale when "locale" is given, otherwise every locale. Returns "publishBlockers", each with a pointer and a message, and a "locale" when every locale was checked. An empty list means it can be published, unless "publishBlockersUnavailable" is true: then the check itself failed. The check runs the field hooks a save runs.`,
17
17
  annotations: { openWorldHint: false },
18
18
  isEnabled: (scope) => scope.collections.writable.length + scope.globals.writable.length > 0,
19
19
  inputSchema: (scope) => ({
20
20
  ...entityShape(scope, "write"),
21
21
  ...idShape(scope, "write"),
22
- ...localeShape(scope, { required: true })
22
+ ...localeShape(scope, { required: false })
23
23
  }),
24
24
  handler: async ({ args, scope }) => {
25
25
  const target = resolveDocument(scope, args, "write");
26
+ const { localization } = scope;
26
27
  const locale = localeOf(scope, args.locale);
27
28
  await readDraft(scope, {
28
29
  target,
@@ -33,7 +34,7 @@ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
33
34
  locale,
34
35
  privileged: true
35
36
  });
36
- const validation = await collectPublishBlockers(scope.req, {
37
+ const validation = localization && args.locale === void 0 ? await collectLocaleBlockers(scope, target, localization.locales) : await collectPublishBlockers(scope.req, {
37
38
  doc,
38
39
  entity: target
39
40
  });
@@ -12,7 +12,10 @@ const NOISE = [
12
12
  "_status"
13
13
  ];
14
14
  const NEWEST_FIRST = ["-updatedAt", "-id"];
15
- /** The document or global as the key's user sees it, without `admin.hidden` fields. */ const readLive = async (scope, read, options) => {
15
+ /**
16
+ * The document or global as the key's user sees it, without `admin.hidden`
17
+ * fields.
18
+ */ const readLive = async (scope, read, options) => {
16
19
  const shared = {
17
20
  depth: read.depth,
18
21
  draft: options.draft,
@@ -44,7 +47,9 @@ const assertReadable = async (scope, read) => {
44
47
  select: {}
45
48
  });
46
49
  };
47
- /** `parent` comes back as a raw id or, populated, as the document itself. */ const isVersionOf = (version, id) => {
50
+ /**
51
+ * `parent` comes back as a raw id or, populated, as the document itself.
52
+ */ const isVersionOf = (version, id) => {
48
53
  const parent = version["parent"];
49
54
  const parentId = typeof parent === "object" && parent !== null ? parent["id"] : parent;
50
55
  return (typeof parentId === "string" || typeof parentId === "number") && String(parentId) === String(id);
@@ -76,7 +81,9 @@ const assertReadable = async (scope, read) => {
76
81
  if (read.target.kind === "collection" && !isVersionOf(version, read.target.id)) return null;
77
82
  return withoutHidden(scope, read, version);
78
83
  };
79
- /** One page of a document's or global's history, newest first. */ const queryVersions = async (scope, read, options) => {
84
+ /**
85
+ * One page of a document's or global's history, newest first.
86
+ */ const queryVersions = async (scope, read, options) => {
80
87
  await assertReadable(scope, read);
81
88
  const where = {
82
89
  ...read.target.kind === "collection" ? { parent: { equals: read.target.id } } : {},
@@ -100,7 +107,9 @@ const assertReadable = async (scope, read) => {
100
107
  slug: read.target.slug
101
108
  }));
102
109
  };
103
- /** The newest version whose status is published, or `null` if none is. */ const loadPublished = async (scope, read) => {
110
+ /**
111
+ * The newest version whose status is published, or `null` if none is.
112
+ */ const loadPublished = async (scope, read) => {
104
113
  if (!hasDraftsEnabled(read.target.config)) return null;
105
114
  const [newest] = (await queryVersions(scope, read, {
106
115
  status: "published",
package/dist/types.d.mts CHANGED
@@ -29,7 +29,9 @@ export type McpxWriteMode = "draft" | "live" | false;
29
29
  * shorthand for `{}`, which exposes everything the entity supports.
30
30
  */
31
31
  export interface McpxCollectionOptions {
32
- /** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
32
+ /**
33
+ * Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`.
34
+ */
33
35
  read?: boolean;
34
36
  /**
35
37
  * Expose `patchDocument`, `validateDocument` and, unless this is an upload
@@ -44,10 +46,15 @@ export interface McpxCollectionOptions {
44
46
  */
45
47
  publish?: boolean;
46
48
  }
47
- /** The same options. A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
49
+ /**
50
+ * The same options. A singleton, so neither `findDocuments` nor
51
+ * `createDocument` reaches one.
52
+ */
48
53
  export type McpxGlobalOptions = McpxCollectionOptions;
49
54
  export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
50
- /** What the config exposes, before an API key's checkboxes narrow it. */
55
+ /**
56
+ * What the config exposes, before an API key's checkboxes narrow it.
57
+ */
51
58
  export interface McpxExposedEntity {
52
59
  slug: string;
53
60
  read: boolean;
@@ -58,24 +65,34 @@ export interface McpxExposedEntity {
58
65
  * readable. Says nothing about drafts; see `hasDrafts`.
59
66
  */
60
67
  hasVersions: boolean;
61
- /** An upload document is a file, and no tool here can supply one. */
68
+ /**
69
+ * An upload document is a file, and no tool here can supply one.
70
+ */
62
71
  isUpload: boolean;
63
- /** Name of the capability group on the key document. */
72
+ /**
73
+ * Name of the capability group on the key document.
74
+ */
64
75
  fieldName: string;
65
76
  }
66
- /** The slugs a key may read, write and publish. */
77
+ /**
78
+ * The slugs a key may read, write and publish.
79
+ */
67
80
  export interface McpxScopeSlugs {
68
81
  readable: string[];
69
82
  writable: string[];
70
83
  publishable: string[];
71
84
  }
72
- /** What a tool knows about the current request. */
85
+ /**
86
+ * What a tool knows about the current request.
87
+ */
73
88
  export interface McpxToolScope {
74
89
  req: PayloadRequest;
75
90
  capabilities: McpxResolvedCapabilities;
76
91
  collections: McpxScopeSlugs;
77
92
  globals: McpxScopeSlugs;
78
- /** `null` when localization is off. */
93
+ /**
94
+ * `null` when localization is off.
95
+ */
79
96
  localization: null | {
80
97
  locales: string[];
81
98
  defaultLocale: string;
@@ -95,9 +112,13 @@ export interface McpxToolScope {
95
112
  * per request, leaving no static shape to infer from.
96
113
  */
97
114
  export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
98
- /** camelCase, unique, not one of the builtin tool names. */
115
+ /**
116
+ * camelCase, unique, not one of the builtin tool names.
117
+ */
99
118
  name: string;
100
- /** Built per request so it can state what this key's writes do. */
119
+ /**
120
+ * Built per request so it can state what this key's writes do.
121
+ */
101
122
  description: string | ((scope: McpxToolScope) => string);
102
123
  annotations?: ToolAnnotations;
103
124
  /**
@@ -115,18 +136,26 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
115
136
  handler(ctx: {
116
137
  args: Args;
117
138
  scope: McpxToolScope;
118
- /** Shorthand for `scope.req`. */
139
+ /**
140
+ * Shorthand for `scope.req`.
141
+ */
119
142
  req: PayloadRequest;
120
143
  extra: McpxToolExtra;
121
144
  }): CallToolResult | Promise<CallToolResult>;
122
145
  }
123
- /** Argument type erased, so a registry can hold tools of differing shapes. */
146
+ /**
147
+ * Argument type erased, so a registry can hold tools of differing shapes.
148
+ */
124
149
  export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
125
150
  export interface McpxAuthResult {
126
- /** Must carry `collection`. */
151
+ /**
152
+ * Must carry `collection`.
153
+ */
127
154
  user: TypedUser;
128
155
  apiKeyId: DocumentId;
129
- /** The `capabilities` group as stored on the key document. */
156
+ /**
157
+ * The `capabilities` group as stored on the key document.
158
+ */
130
159
  capabilities: unknown;
131
160
  }
132
161
  /**
@@ -136,31 +165,47 @@ export interface McpxAuthResult {
136
165
  * to `Record<string, unknown>`, which interfaces do not satisfy.
137
166
  */
138
167
  export type McpxPluginOptions = {
139
- /** Allow-list of collections. `true` is shorthand for `{}`. */
168
+ /**
169
+ * Allow-list of collections. `true` is shorthand for `{}`.
170
+ */
140
171
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
141
- /** Allow-list of globals. `true` is shorthand for `{}`. */
172
+ /**
173
+ * Allow-list of globals. `true` is shorthand for `{}`.
174
+ */
142
175
  globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
143
- /** Collection the keys act as. Default `config.admin.user`, then `users`. */
176
+ /**
177
+ * Collection the keys act as. Default `config.admin.user`, then `users`.
178
+ */
144
179
  userCollection?: CollectionSlug;
145
180
  apiKeys?: {
146
- /** Slug of the generated API key collection. Default `mcpx-api-keys`. */
181
+ /**
182
+ * Slug of the generated API key collection. Default `mcpx-api-keys`.
183
+ */
147
184
  slug?: string;
148
185
  /**
149
186
  * Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
150
187
  * client config. Default `true`. The snippets contain the key in full.
151
188
  */
152
189
  setupGuide?: boolean;
153
- /** Final override applied to the generated collection. */
190
+ /**
191
+ * Final override applied to the generated collection.
192
+ */
154
193
  overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
155
194
  };
156
195
  endpoint?: {
157
- /** Endpoint path below the API route. Default `/mcpx`. */
196
+ /**
197
+ * Endpoint path below the API route. Default `/mcpx`.
198
+ */
158
199
  path?: string;
159
200
  };
160
201
  limits?: {
161
- /** Upper bound for `findDocuments.limit`. Default 25. */
202
+ /**
203
+ * Upper bound for `findDocuments.limit`. Default 25.
204
+ */
162
205
  maxLimit?: number;
163
- /** Upper bound for `depth` on reads. Default 1. */
206
+ /**
207
+ * Upper bound for `depth` on reads. Default 1.
208
+ */
164
209
  maxDepth?: number;
165
210
  };
166
211
  tools?: McpxAnyTool[];
@@ -180,20 +225,28 @@ export type McpxPluginOptions = {
180
225
  version?: string;
181
226
  };
182
227
  };
183
- /** What a key may do with one entity. Globals reuse this shape. */
228
+ /**
229
+ * What a key may do with one entity. Globals reuse this shape.
230
+ */
184
231
  export interface McpxCollectionCapabilities {
185
232
  read: boolean;
186
233
  write: boolean;
187
- /** Only ever true where the config lets writes publish and drafts exist. */
234
+ /**
235
+ * Only ever true where the config lets writes publish and drafts exist.
236
+ */
188
237
  publish: boolean;
189
238
  }
190
- /** In force for one request: the plugin config and the key checkboxes together. */
239
+ /**
240
+ * In force for one request: the plugin config and the key checkboxes together.
241
+ */
191
242
  export interface McpxResolvedCapabilities {
192
243
  collections: Record<string, McpxCollectionCapabilities>;
193
244
  globals: Record<string, McpxCollectionCapabilities>;
194
245
  tools: Record<string, boolean>;
195
246
  }
196
- /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
247
+ /**
248
+ * Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
249
+ */
197
250
  export interface McpxRequestContext {
198
251
  apiKeyId: DocumentId;
199
252
  capabilities: McpxResolvedCapabilities;
@@ -6,14 +6,13 @@ import { hasDraftsEnabled } from "payload/shared";
6
6
  const STRIPPED_ARGS = /* @__PURE__ */ new Set([
7
7
  "where",
8
8
  "publishAllLocales",
9
- "publishSpecificLocale",
10
9
  "unpublishAllLocales",
11
10
  "duplicateFromID",
12
11
  "selectedLocales",
13
12
  "overwriteExistingFiles"
14
13
  ]);
15
14
  const scrubWriteArgs = (args, publishing) => {
16
- const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
15
+ const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key) && (publishing || key !== "publishSpecificLocale")));
17
16
  if (next["data"] && typeof next["data"] === "object") {
18
17
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
19
18
  next["data"] = publishing ? {
@@ -34,17 +33,20 @@ const forceDraftWrite = (hookArgs) => {
34
33
  return scrubWriteArgs(args, publishing);
35
34
  };
36
35
  /**
37
- * The global counterpart of {@link forceDraftWrite}. `updateGlobal` destructures
38
- * `draft` and the publish arguments before it runs `beforeOperation` and
39
- * re-reads only `data` afterwards, so setting them here has no effect. Only
40
- * `data` with `_status` stripped lands, so {@link refusePublishGlobal} is what
41
- * enforces the guard. `publishDocument` therefore passes `draft: false` at the
42
- * call site, and this hook restores `_status` instead of stripping it.
36
+ * The global counterpart of {@link forceDraftWrite}. `updateGlobal` reads
37
+ * `draft` and the publish arguments before this hook runs, so only changes to
38
+ * `data` take effect. A write to a global with drafts that did not ask for a
39
+ * draft save is therefore refused here, and `_status` is restored for a publish.
43
40
  */ const forceDraftWriteGlobal = (hookArgs) => {
44
- const { operation, req } = hookArgs;
41
+ const { global, operation, req } = hookArgs;
45
42
  const args = hookArgs.args;
46
43
  if (!isMcpxRequest(req) || operation !== "update") return args;
47
- return scrubWriteArgs(args, hasPublishIntent(args["data"]));
44
+ const publishing = hasPublishIntent(args["data"]);
45
+ if (!publishing && args["draft"] !== true && hasDraftsEnabled(global)) {
46
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${global.slug} that did not ask for a draft save.`);
47
+ throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
48
+ }
49
+ return scrubWriteArgs(args, publishing);
48
50
  };
49
51
  const refuseUnlessExpected = (req, slug, data) => {
50
52
  const publishing = takePublishIntent(data);
@@ -8,7 +8,9 @@ const withPublishIntent = (data) => ({
8
8
  [PUBLISH_INTENT]: TOKEN
9
9
  });
10
10
  const hasPublishIntent = (data) => isPlainObject(data) && data[PUBLISH_INTENT] === TOKEN;
11
- /** Called by the last hook that needs the marker, which removes it. */ const takePublishIntent = (data) => {
11
+ /**
12
+ * Called by the last hook that needs the marker, which removes it.
13
+ */ const takePublishIntent = (data) => {
12
14
  if (!hasPublishIntent(data)) return false;
13
15
  delete data[PUBLISH_INTENT];
14
16
  return true;
@@ -33,7 +33,9 @@ const walkRows = (value, visit, isRow = false) => {
33
33
  delete row["id"];
34
34
  });
35
35
  };
36
- /** For create, where no stored row exists and any incoming id is invented. */ const stripRowIds = (value) => {
36
+ /**
37
+ * For create, where no stored row exists and any incoming id is invented.
38
+ */ const stripRowIds = (value) => {
37
39
  const next = structuredClone(value);
38
40
  walkRows(next, (row) => {
39
41
  delete row["id"];
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": "2.0.0-beta.8",
4
+ "version": "2.0.0-beta.9",
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",
@@ -65,6 +65,7 @@
65
65
  "@abinnovision/eslint-config-base": "^3.4.2",
66
66
  "@abinnovision/prettier-config": "^2.2.0",
67
67
  "@arethetypeswrong/core": "^0.18.5",
68
+ "@internal/test-utils": "0.0.0",
68
69
  "@payloadcms/db-sqlite": "3.89.0",
69
70
  "@payloadcms/richtext-lexical": "3.89.0",
70
71
  "@payloadcms/ui": "3.89.0",